forked from Cutlery/immich
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14ce849f90 | |||
| 49502f3230 | |||
| fdf4f93208 | |||
| 5d6bfa5a3e | |||
| 5c37b27fcf | |||
| 0ed5dc869a | |||
| ac9e2cd316 | |||
| af0f2f005b | |||
| 543ff6f7fd | |||
| f249a3761b | |||
| b96f04efec | |||
| 1404b6441d | |||
| 91f8297a61 | |||
| 97206faadb | |||
| e3d6f7adb3 | |||
| 000e1f17c5 | |||
| ee4120c5f7 | |||
| 6faa597aaf | |||
| 21210ca297 | |||
| 11f1ade8f2 | |||
| 40c1bfa27b | |||
| 6c2bf550bc | |||
| f28c369c16 | |||
| fb9b854bf1 | |||
| fe9348e049 | |||
| 8a6af72588 | |||
| 610b03d16c | |||
| 3d6eadb595 | |||
| 68ebcf218d | |||
| 46d640b7a1 | |||
| 839cc4f3f8 | |||
| 4f77d06592 | |||
| 8c1e782f8f | |||
| cda2ff3d1f |
@@ -168,13 +168,13 @@ jobs:
|
||||
poetry install --with dev
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
poetry run ruff check --format=github app export
|
||||
poetry run ruff check --format=github app
|
||||
- name: Check black formatting
|
||||
run: |
|
||||
poetry run black --check app export
|
||||
poetry run black --check app
|
||||
- name: Run mypy type checking
|
||||
run: |
|
||||
poetry run mypy --install-types --non-interactive --strict app/ export/
|
||||
poetry run mypy --install-types --non-interactive app/
|
||||
- name: Run tests and coverage
|
||||
run: |
|
||||
poetry run pytest --cov app
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -36,8 +36,7 @@ def deployed_app() -> TestClient:
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def responses() -> dict[str, Any]:
|
||||
responses: dict[str, Any] = json.load(open("responses.json", "r"))
|
||||
return responses
|
||||
return json.load(open("responses.json", "r"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
||||
@@ -7,7 +7,7 @@ from zipfile import BadZipFile
|
||||
import orjson
|
||||
from fastapi import FastAPI, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
|
||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile # type: ignore
|
||||
from starlette.formparsers import MultiPartParser
|
||||
|
||||
from app.models.base import InferenceModel
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import snapshot_download
|
||||
from typing_extensions import Buffer
|
||||
|
||||
from ..config import get_cache_dir, get_hf_model_name, log, settings
|
||||
from ..schemas import ModelType
|
||||
@@ -140,12 +139,11 @@ class InferenceModel(ABC):
|
||||
|
||||
|
||||
# HF deep copies configs, so we need to make session options picklable
|
||||
class PicklableSessionOptions(ort.SessionOptions): # type: ignore[misc]
|
||||
class PicklableSessionOptions(ort.SessionOptions):
|
||||
def __getstate__(self) -> bytes:
|
||||
return pickle.dumps([(attr, getattr(self, attr)) for attr in dir(self) if not callable(getattr(self, attr))])
|
||||
|
||||
def __setstate__(self, state: Buffer) -> None:
|
||||
self.__init__() # type: ignore[misc]
|
||||
attrs: list[tuple[str, Any]] = pickle.loads(state)
|
||||
for attr, val in attrs:
|
||||
def __setstate__(self, state: Any) -> None:
|
||||
self.__init__() # type: ignore
|
||||
for attr, val in pickle.loads(state):
|
||||
setattr(self, attr, val)
|
||||
|
||||
@@ -6,7 +6,7 @@ from aiocache.plugins import BasePlugin, TimingPlugin
|
||||
|
||||
from app.models import from_model_type
|
||||
|
||||
from ..schemas import ModelType, has_profiling
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
|
||||
@@ -50,20 +50,20 @@ class ModelCache:
|
||||
|
||||
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
|
||||
async with OptimisticLock(self.cache, key) as lock:
|
||||
model: InferenceModel | None = await self.cache.get(key)
|
||||
model = await self.cache.get(key)
|
||||
if model is None:
|
||||
model = from_model_type(model_type, model_name, **model_kwargs)
|
||||
await lock.cas(model, ttl=self.ttl)
|
||||
return model
|
||||
|
||||
async def get_profiling(self) -> dict[str, float] | None:
|
||||
if not has_profiling(self.cache):
|
||||
if not hasattr(self.cache, "profiling"):
|
||||
return None
|
||||
|
||||
return self.cache.profiling
|
||||
return self.cache.profiling # type: ignore
|
||||
|
||||
|
||||
class RevalidationPlugin(BasePlugin): # type: ignore[misc]
|
||||
class RevalidationPlugin(BasePlugin):
|
||||
"""Revalidates cache item's TTL after cache hit."""
|
||||
|
||||
async def post_get(
|
||||
|
||||
@@ -51,7 +51,7 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
|
||||
def _predict(self, image_or_text: Image.Image | str) -> ndarray_f32:
|
||||
def _predict(self, image_or_text: Image.Image | str) -> list[float]:
|
||||
if isinstance(image_or_text, bytes):
|
||||
image_or_text = Image.open(BytesIO(image_or_text))
|
||||
|
||||
@@ -60,16 +60,16 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
if self.mode == "text":
|
||||
raise TypeError("Cannot encode image as text-only model")
|
||||
|
||||
outputs: ndarray_f32 = self.vision_model.run(None, self.transform(image_or_text))[0][0]
|
||||
outputs = self.vision_model.run(None, self.transform(image_or_text))
|
||||
case str():
|
||||
if self.mode == "vision":
|
||||
raise TypeError("Cannot encode text as vision-only model")
|
||||
|
||||
outputs = self.text_model.run(None, self.tokenize(image_or_text))[0][0]
|
||||
outputs = self.text_model.run(None, self.tokenize(image_or_text))
|
||||
case _:
|
||||
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
|
||||
|
||||
return outputs
|
||||
return outputs[0][0].tolist()
|
||||
|
||||
@abstractmethod
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
@@ -151,13 +151,11 @@ class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
|
||||
@cached_property
|
||||
def model_cfg(self) -> dict[str, Any]:
|
||||
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
|
||||
return model_cfg
|
||||
return json.load(self.model_cfg_path.open())
|
||||
|
||||
@cached_property
|
||||
def preprocess_cfg(self) -> dict[str, Any]:
|
||||
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open())
|
||||
return preprocess_cfg
|
||||
return json.load(self.preprocess_cfg_path.open())
|
||||
|
||||
|
||||
class MCLIPEncoder(OpenCLIPEncoder):
|
||||
|
||||
@@ -8,7 +8,7 @@ from insightface.model_zoo import ArcFaceONNX, RetinaFace
|
||||
from insightface.utils.face_align import norm_crop
|
||||
|
||||
from app.config import clean_name
|
||||
from app.schemas import BoundingBox, Face, ModelType, ndarray_f32
|
||||
from app.schemas import ModelType, ndarray_f32
|
||||
|
||||
from .base import InferenceModel
|
||||
|
||||
@@ -52,7 +52,7 @@ class FaceRecognizer(InferenceModel):
|
||||
)
|
||||
self.rec_model.prepare(ctx_id=0)
|
||||
|
||||
def _predict(self, image: ndarray_f32 | bytes) -> list[Face]:
|
||||
def _predict(self, image: ndarray_f32 | bytes) -> list[dict[str, Any]]:
|
||||
if isinstance(image, bytes):
|
||||
image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR)
|
||||
bboxes, kpss = self.det_model.detect(image)
|
||||
@@ -67,20 +67,21 @@ class FaceRecognizer(InferenceModel):
|
||||
height, width, _ = image.shape
|
||||
for (x1, y1, x2, y2), score, kps in zip(bboxes, scores, kpss):
|
||||
cropped_img = norm_crop(image, kps)
|
||||
embedding: ndarray_f32 = self.rec_model.get_feat(cropped_img)[0]
|
||||
face: Face = {
|
||||
"imageWidth": width,
|
||||
"imageHeight": height,
|
||||
"boundingBox": {
|
||||
"x1": x1,
|
||||
"y1": y1,
|
||||
"x2": x2,
|
||||
"y2": y2,
|
||||
},
|
||||
"score": score,
|
||||
"embedding": embedding,
|
||||
}
|
||||
results.append(face)
|
||||
embedding = self.rec_model.get_feat(cropped_img)[0].tolist()
|
||||
results.append(
|
||||
{
|
||||
"imageWidth": width,
|
||||
"imageHeight": height,
|
||||
"boundingBox": {
|
||||
"x1": x1,
|
||||
"y1": y1,
|
||||
"x2": x2,
|
||||
"y2": y2,
|
||||
},
|
||||
"score": score,
|
||||
"embedding": embedding,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
@property
|
||||
|
||||
@@ -66,7 +66,7 @@ class ImageClassifier(InferenceModel):
|
||||
def _predict(self, image: Image.Image | bytes) -> list[str]:
|
||||
if isinstance(image, bytes):
|
||||
image = Image.open(BytesIO(image))
|
||||
predictions: list[dict[str, Any]] = self.model(image)
|
||||
predictions: list[dict[str, Any]] = self.model(image) # type: ignore
|
||||
tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score]
|
||||
|
||||
return tags
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from enum import StrEnum
|
||||
from typing import Any, Protocol, TypeAlias, TypedDict, TypeGuard
|
||||
from typing import TypeAlias
|
||||
|
||||
import numpy as np
|
||||
from pydantic import BaseModel
|
||||
|
||||
ndarray_f32: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
|
||||
ndarray_i64: TypeAlias = np.ndarray[int, np.dtype[np.int64]]
|
||||
ndarray_i32: TypeAlias = np.ndarray[int, np.dtype[np.int32]]
|
||||
|
||||
def to_lower_camel(string: str) -> str:
|
||||
tokens = [token.capitalize() if i > 0 else token for i, token in enumerate(string.split("_"))]
|
||||
return "".join(tokens)
|
||||
|
||||
|
||||
class TextModelRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class TextResponse(BaseModel):
|
||||
@@ -17,7 +22,7 @@ class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class BoundingBox(TypedDict):
|
||||
class BoundingBox(BaseModel):
|
||||
x1: int
|
||||
y1: int
|
||||
x2: int
|
||||
@@ -30,17 +35,6 @@ class ModelType(StrEnum):
|
||||
FACIAL_RECOGNITION = "facial-recognition"
|
||||
|
||||
|
||||
class HasProfiling(Protocol):
|
||||
profiling: dict[str, float]
|
||||
|
||||
|
||||
class Face(TypedDict):
|
||||
boundingBox: BoundingBox
|
||||
embedding: ndarray_f32
|
||||
imageWidth: int
|
||||
imageHeight: int
|
||||
score: float
|
||||
|
||||
|
||||
def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
|
||||
return hasattr(obj, "profiling") and type(obj.profiling) == dict
|
||||
ndarray_f32: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
|
||||
ndarray_i64: TypeAlias = np.ndarray[int, np.dtype[np.int64]]
|
||||
ndarray_i32: TypeAlias = np.ndarray[int, np.dtype[np.int32]]
|
||||
|
||||
@@ -75,9 +75,9 @@ class TestCLIP:
|
||||
embedding = clip_encoder.predict(pil_image)
|
||||
|
||||
assert clip_encoder.mode == "vision"
|
||||
assert isinstance(embedding, np.ndarray)
|
||||
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
|
||||
assert embedding.dtype == np.float32
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == clip_model_cfg["embed_dim"]
|
||||
assert all([isinstance(num, float) for num in embedding])
|
||||
clip_encoder.vision_model.run.assert_called_once()
|
||||
|
||||
def test_basic_text(
|
||||
@@ -97,9 +97,9 @@ class TestCLIP:
|
||||
embedding = clip_encoder.predict("test search query")
|
||||
|
||||
assert clip_encoder.mode == "text"
|
||||
assert isinstance(embedding, np.ndarray)
|
||||
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
|
||||
assert embedding.dtype == np.float32
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == clip_model_cfg["embed_dim"]
|
||||
assert all([isinstance(num, float) for num in embedding])
|
||||
clip_encoder.text_model.run.assert_called_once()
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ class TestFaceRecognition:
|
||||
for face in faces:
|
||||
assert face["imageHeight"] == 800
|
||||
assert face["imageWidth"] == 600
|
||||
assert isinstance(face["embedding"], np.ndarray)
|
||||
assert face["embedding"].shape[0] == 512
|
||||
assert face["embedding"].dtype == np.float32
|
||||
assert isinstance(face["embedding"], list)
|
||||
assert len(face["embedding"]) == 512
|
||||
assert all([isinstance(num, float) for num in face["embedding"]])
|
||||
|
||||
det_model.detect.assert_called_once()
|
||||
assert rec_model.get_feat.call_count == num_faces
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import tempfile
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from math import e
|
||||
from pathlib import Path
|
||||
|
||||
import open_clip
|
||||
@@ -70,12 +69,10 @@ def export_image_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig,
|
||||
output_path = Path(output_path)
|
||||
|
||||
def encode_image(image: torch.Tensor) -> torch.Tensor:
|
||||
output = model.encode_image(image, normalize=True)
|
||||
assert isinstance(output, torch.Tensor)
|
||||
return output
|
||||
return model.encode_image(image, normalize=True)
|
||||
|
||||
args = (torch.randn(1, 3, model_cfg.image_size, model_cfg.image_size),)
|
||||
traced = torch.jit.trace(encode_image, args) # type: ignore[no-untyped-call]
|
||||
traced = torch.jit.trace(encode_image, args)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
@@ -94,12 +91,10 @@ def export_text_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, o
|
||||
output_path = Path(output_path)
|
||||
|
||||
def encode_text(text: torch.Tensor) -> torch.Tensor:
|
||||
output = model.encode_text(text, normalize=True)
|
||||
assert isinstance(output, torch.Tensor)
|
||||
return output
|
||||
return model.encode_text(text, normalize=True)
|
||||
|
||||
args = (torch.ones(1, model_cfg.sequence_length, dtype=torch.int32),)
|
||||
traced = torch.jit.trace(encode_text, args) # type: ignore[no-untyped-call]
|
||||
traced = torch.jit.trace(encode_text, args)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.86.0"
|
||||
version = "1.85.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 110,
|
||||
"android.injected.version.name" => "1.86.0",
|
||||
"android.injected.version.code" => 109,
|
||||
"android.injected.version.name" => "1.85.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')
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
{
|
||||
"add_to_album_bottom_sheet_added": "Ajouté à {album}",
|
||||
"add_to_album_bottom_sheet_already_exists": "Déjà dans {album}",
|
||||
"advanced_settings_prefer_remote_subtitle": "Certains appareils sont très lents à charger des vignettes à partir de ressources présentes sur l'appareil. Activez ce paramètre pour charger des images externes à la place.",
|
||||
"advanced_settings_prefer_remote_title": "Préférer les images externes",
|
||||
"advanced_settings_self_signed_ssl_subtitle": "Permet d'ignorer la vérification du certificat SSL pour le point d'accès du serveur. Requis pour les certificats auto-signés.",
|
||||
"advanced_settings_self_signed_ssl_title": "Autoriser les certificats SSL auto-signés",
|
||||
"advanced_settings_tile_subtitle": "Paramètres d'utilisateur avancés",
|
||||
"advanced_settings_tile_title": "Avancé",
|
||||
"advanced_settings_troubleshooting_subtitle": "Activer des fonctions supplémentaires pour le dépannage",
|
||||
"advanced_settings_troubleshooting_title": "Dépannage",
|
||||
"album_info_card_backup_album_excluded": "EXCLUS",
|
||||
"album_info_card_backup_album_included": "INCLUS",
|
||||
"album_thumbnail_card_item": "1 élément",
|
||||
"album_thumbnail_card_items": "{} éléments",
|
||||
"album_thumbnail_card_shared": " · Partagé",
|
||||
"album_thumbnail_owned": "Possédé",
|
||||
"album_thumbnail_shared_by": "Partagé par {}",
|
||||
"album_viewer_appbar_share_delete": "Supprimer l'album",
|
||||
"album_viewer_appbar_share_err_delete": "Échec de la suppression de l'album",
|
||||
"album_viewer_appbar_share_err_leave": "Impossible de quitter l'album",
|
||||
"album_viewer_appbar_share_err_remove": "Il y a des problèmes lors de la suppression des éléments de l'album",
|
||||
"album_viewer_appbar_share_err_title": "Échec de la modification du titre de l'album",
|
||||
"album_viewer_appbar_share_leave": "Quitter l'album",
|
||||
"album_viewer_appbar_share_remove": "Retirer de l'album",
|
||||
"album_viewer_appbar_share_to": "Partager à",
|
||||
"album_viewer_page_share_add_users": "Ajouter des utilisateurs",
|
||||
"all_people_page_title": "Personnes",
|
||||
"all_videos_page_title": "Vidéos",
|
||||
"app_bar_signout_dialog_content": "Êtes-vous sûr de vouloir vous déconnecter?",
|
||||
"app_bar_signout_dialog_ok": "Oui",
|
||||
"app_bar_signout_dialog_title": "Se déconnecter",
|
||||
"archive_page_no_archived_assets": "Aucun élément archivé n'a été trouvé",
|
||||
"archive_page_title": "Archive ({})",
|
||||
"asset_list_layout_settings_dynamic_layout_title": "Affichage dynamique",
|
||||
"asset_list_layout_settings_group_automatically": "Automatique",
|
||||
"asset_list_layout_settings_group_by": "Grouper les éléments par",
|
||||
"asset_list_layout_settings_group_by_month": "Mois",
|
||||
"asset_list_layout_settings_group_by_month_day": "Mois + jour",
|
||||
"asset_list_settings_subtitle": "Paramètres de disposition de la grille de photos",
|
||||
"asset_list_settings_title": "Grille de photos",
|
||||
"backup_album_selection_page_albums_device": "Albums sur l'appareil ({})",
|
||||
"backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure",
|
||||
"backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.",
|
||||
"backup_album_selection_page_select_albums": "Sélectionner les albums",
|
||||
"backup_album_selection_page_selection_info": "Informations sur la sélection",
|
||||
"backup_album_selection_page_total_assets": "Total des éléments uniques",
|
||||
"backup_all": "Tout",
|
||||
"backup_background_service_backup_failed_message": "Échec de la sauvegarde des éléments. Nouvelle tentative...",
|
||||
"backup_background_service_connection_failed_message": "Impossible de se connecter au serveur. Nouvelle tentative...",
|
||||
"backup_background_service_current_upload_notification": "Transfert {}",
|
||||
"backup_background_service_default_notification": "Recherche de nouveaux éléments...",
|
||||
"backup_background_service_error_title": "Erreur de sauvegarde",
|
||||
"backup_background_service_in_progress_notification": "Sauvegarde de vos éléments...",
|
||||
"backup_background_service_upload_failure_notification": "Impossible de transférer {}",
|
||||
"backup_controller_page_albums": "Sauvegarder les albums",
|
||||
"backup_controller_page_background_app_refresh_disabled_content": "Activez le rafraîchissement de l'application en arrière-plan dans Paramètres > Général > Rafraîchissement de l'application en arrière-plan afin d'utiliser la sauvegarde en arrière-plan.",
|
||||
"backup_controller_page_background_app_refresh_disabled_title": "Rafraîchissement de l'application en arrière-plan désactivé",
|
||||
"backup_controller_page_background_app_refresh_enable_button_text": "Aller aux paramètres",
|
||||
"backup_controller_page_background_battery_info_link": "Montrez-moi comment",
|
||||
"backup_controller_page_background_battery_info_message": "Pour une expérience optimale de la sauvegarde en arrière-plan, veuillez désactiver toute optimisation de la batterie limitant l'activité en arrière-plan pour Immich.\n\nÉtant donné que cela est spécifique à chaque appareil, veuillez consulter les informations requises pour le fabricant de votre appareil.",
|
||||
"backup_controller_page_background_battery_info_ok": "OK",
|
||||
"backup_controller_page_background_battery_info_title": "Optimisation de la batterie",
|
||||
"backup_controller_page_background_charging": "Seulement pendant la charge",
|
||||
"backup_controller_page_background_configure_error": "Échec de la configuration du service d'arrière-plan",
|
||||
"backup_controller_page_background_delay": "Retarder la sauvegarde des nouveaux éléments d'actif: {}",
|
||||
"backup_controller_page_background_description": "Activez le service d'arrière-plan pour sauvegarder automatiquement tous les nouveaux éléments sans avoir à ouvrir l'application.",
|
||||
"backup_controller_page_background_is_off": "La sauvegarde automatique en arrière-plan est désactivée",
|
||||
"backup_controller_page_background_is_on": "La sauvegarde automatique en arrière-plan est activée",
|
||||
"backup_controller_page_background_turn_off": "Désactiver le service d'arrière-plan",
|
||||
"backup_controller_page_background_turn_on": "Activer le service d'arrière-plan",
|
||||
"backup_controller_page_background_wifi": "Uniquement sur WiFi",
|
||||
"backup_controller_page_backup": "Sauvegardé",
|
||||
"backup_controller_page_backup_selected": "Sélectionné: ",
|
||||
"backup_controller_page_backup_sub": "Photos et vidéos sauvegardées",
|
||||
"backup_controller_page_cancel": "Annuler",
|
||||
"backup_controller_page_created": "Créé le: {}",
|
||||
"backup_controller_page_desc_backup": "Activez la sauvegarde pour envoyer automatiquement les nouveaux éléments sur le serveur.",
|
||||
"backup_controller_page_excluded": "Exclus: ",
|
||||
"backup_controller_page_failed": "Échec de l'opération ({})",
|
||||
"backup_controller_page_filename": "Nom du fichier: {} [{}]",
|
||||
"backup_controller_page_id": "ID: {}",
|
||||
"backup_controller_page_info": "Informations de sauvegarde",
|
||||
"backup_controller_page_none_selected": "Aucune sélection",
|
||||
"backup_controller_page_remainder": "Restant",
|
||||
"backup_controller_page_remainder_sub": "Photos et albums restants à sauvegarder à partir de la sélection",
|
||||
"backup_controller_page_select": "Sélectionner",
|
||||
"backup_controller_page_server_storage": "Stockage du serveur",
|
||||
"backup_controller_page_start_backup": "Démarrer la sauvegarde",
|
||||
"backup_controller_page_status_off": "La sauvegarde est désactivée",
|
||||
"backup_controller_page_status_on": "La sauvegarde est activée",
|
||||
"backup_controller_page_storage_format": "{} de {} utilisé",
|
||||
"backup_controller_page_to_backup": "Albums à sauvegarder",
|
||||
"backup_controller_page_total": "Total",
|
||||
"backup_controller_page_total_sub": "Toutes les photos et vidéos uniques des albums sélectionnés",
|
||||
"backup_controller_page_turn_off": "Désactiver la sauvegarde",
|
||||
"backup_controller_page_turn_on": "Activer la sauvegarde",
|
||||
"backup_controller_page_uploading_file_info": "Transfert des informations du fichier",
|
||||
"backup_err_only_album": "Impossible de retirer le seul album",
|
||||
"backup_info_card_assets": "éléments",
|
||||
"backup_manual_cancelled": "Annulé",
|
||||
"backup_manual_failed": "Echec",
|
||||
"backup_manual_in_progress": "Téléchargement déjà en cours. Essayez après un instant",
|
||||
"backup_manual_success": "Succès ",
|
||||
"backup_manual_title": "Statut du téléchargement ",
|
||||
"cache_settings_album_thumbnails": "vignettes de la page bibliothèque ({} éléments)",
|
||||
"cache_settings_clear_cache_button": "Effacer le cache",
|
||||
"cache_settings_clear_cache_button_title": "Efface le cache de l'application. Cela aura un impact significatif sur les performances de l'application jusqu'à ce que le cache soit reconstruit.",
|
||||
"cache_settings_image_cache_size": "Taille du cache des images ({} éléments)",
|
||||
"cache_settings_statistics_album": "vignettes de la bibliothèque",
|
||||
"cache_settings_statistics_assets": "{} éléments ({})",
|
||||
"cache_settings_statistics_full": "Images complètes",
|
||||
"cache_settings_statistics_shared": "vignettes d'albums partagés",
|
||||
"cache_settings_statistics_thumbnail": "vignettes",
|
||||
"cache_settings_statistics_title": "Utilisation du cache",
|
||||
"cache_settings_subtitle": "Contrôler le comportement de mise en cache de l'application mobile Immich",
|
||||
"cache_settings_thumbnail_size": "Taille du cache des vignettes ({} éléments)",
|
||||
"cache_settings_tile_subtitle": "Contrôler le comportement du stockage local",
|
||||
"cache_settings_tile_title": "Stockage local",
|
||||
"cache_settings_title": "Paramètres de mise en cache",
|
||||
"change_password_form_confirm_password": "Confirmez le mot de passe",
|
||||
"change_password_form_description": "Bonjour {firstName} {lastName},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé de changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.",
|
||||
"change_password_form_new_password": "Nouveau mot de passe",
|
||||
"change_password_form_password_mismatch": "Les mots de passe ne correspondent pas",
|
||||
"change_password_form_reenter_new_password": "Saisissez à nouveau le nouveau mot de passe",
|
||||
"common_add_to_album": "Ajouter à l'album",
|
||||
"common_change_password": "Modifier le mot de passe",
|
||||
"common_create_new_album": "Créer un nouvel album",
|
||||
"common_server_error": "Veuillez vérifier votre connexion réseau, vous assurer que le serveur est accessible et que les versions de l'application et du serveur sont compatibles.",
|
||||
"common_shared": "Partagé",
|
||||
"control_bottom_app_bar_add_to_album": "Ajouter à l'album",
|
||||
"control_bottom_app_bar_album_info": "{} éléments",
|
||||
"control_bottom_app_bar_album_info_shared": "{} éléments - Partagés",
|
||||
"control_bottom_app_bar_archive": "Archive",
|
||||
"control_bottom_app_bar_create_new_album": "Créer un nouvel album",
|
||||
"control_bottom_app_bar_delete": "Supprimer",
|
||||
"control_bottom_app_bar_favorite": "Favoris",
|
||||
"control_bottom_app_bar_share": "Partager",
|
||||
"control_bottom_app_bar_share_to": "Partager à",
|
||||
"control_bottom_app_bar_stack": "Empiler",
|
||||
"control_bottom_app_bar_unarchive": "Désarchiver",
|
||||
"control_bottom_app_bar_upload": "Téléverser",
|
||||
"create_album_page_untitled": "Sans titre",
|
||||
"create_shared_album_page_create": "Créer",
|
||||
"create_shared_album_page_share": "Partager",
|
||||
"create_shared_album_page_share_add_assets": "AJOUTER DES ÉLÉMENTS",
|
||||
"create_shared_album_page_share_select_photos": "Sélectionner les photos",
|
||||
"curated_location_page_title": "Places",
|
||||
"curated_object_page_title": "Objets",
|
||||
"daily_title_text_date": "E, dd MMM",
|
||||
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
||||
"date_format": "E, LLL d, y • h:mm a",
|
||||
"delete_dialog_alert": "Ces éléments seront définitivement supprimés de Immich et de votre appareil.",
|
||||
"delete_dialog_cancel": "Annuler",
|
||||
"delete_dialog_ok": "Supprimer",
|
||||
"delete_dialog_title": "Supprimer définitivement",
|
||||
"delete_shared_link_dialog_content": "Êtes-vous sûr de vouloir supprimer ce lien partagé?",
|
||||
"delete_shared_link_dialog_title": "Supprimer le lien partagé",
|
||||
"description_input_hint_text": "Ajouter une description...",
|
||||
"description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails",
|
||||
"exif_bottom_sheet_description": "Ajouter une description...",
|
||||
"exif_bottom_sheet_details": "DÉTAILS",
|
||||
"exif_bottom_sheet_location": "LOCALISATION",
|
||||
"experimental_settings_new_asset_list_subtitle": "En cours de développement",
|
||||
"experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale",
|
||||
"experimental_settings_subtitle": "Utilisez à vos dépends!",
|
||||
"experimental_settings_title": "Expérimental",
|
||||
"favorites_page_no_favorites": "Aucun élément favori n'a été trouvé",
|
||||
"favorites_page_title": "Favoris",
|
||||
"home_page_add_to_album_conflicts": "{added} éléments ajoutés à l'album {album}. Les éléments {failed} sont déjà dans l'album.",
|
||||
"home_page_add_to_album_err_local": "Impossible d'ajouter des éléments locaux aux albums pour le moment, étape ignorée",
|
||||
"home_page_add_to_album_success": "{added} éléments ajoutés à l'album {album}.",
|
||||
"home_page_archive_err_local": "Impossible d'archiver les ressources locales pour l'instant, étape ignorée",
|
||||
"home_page_building_timeline": "Construction de la chronologie",
|
||||
"home_page_favorite_err_local": "Impossible d'ajouter des éléments locaux aux favoris pour le moment, étape ignorée",
|
||||
"home_page_first_time_notice": "Si c'est la première fois que vous utilisez l'application, veillez à choisir un ou plusieurs albums de sauvegarde afin que la chronologie puisse alimenter les photos et les vidéos de cet ou ces albums.",
|
||||
"home_page_upload_err_limit": "Limite de téléchargement de 30 éléments en même temps, demande ignorée",
|
||||
"image_viewer_page_state_provider_download_error": "Erreur de téléchargement",
|
||||
"image_viewer_page_state_provider_download_success": "Téléchargement réussi",
|
||||
"image_viewer_page_state_provider_share_error": "Erreur de partage",
|
||||
"library_page_albums": "Albums",
|
||||
"library_page_archive": "Archive",
|
||||
"library_page_device_albums": "Albums sur l'appareil",
|
||||
"library_page_favorites": "Favoris",
|
||||
"library_page_new_album": "Nouvel album",
|
||||
"library_page_sharing": "Partage",
|
||||
"library_page_sort_created": "Créations les plus récentes",
|
||||
"library_page_sort_last_modified": "Dernière modification",
|
||||
"library_page_sort_most_recent_photo": "Photo la plus récente",
|
||||
"library_page_sort_title": "Titre de l'album",
|
||||
"login_disabled": "La connexion a été désactivée ",
|
||||
"login_form_api_exception": "Erreur de l'API. Veuillez vérifier l'URL du serveur et et réessayer.",
|
||||
"login_form_button_text": "Connexion",
|
||||
"login_form_email_hint": "votreemail@email.com",
|
||||
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
|
||||
"login_form_endpoint_url": "URL du point d'accès au serveur",
|
||||
"login_form_err_http": "Veuillez préciser http:// ou https://",
|
||||
"login_form_err_invalid_email": "E-mail invalide",
|
||||
"login_form_err_invalid_url": "URL invalide",
|
||||
"login_form_err_leading_whitespace": "Espace en début de ligne",
|
||||
"login_form_err_trailing_whitespace": "Espace de fin de ligne",
|
||||
"login_form_failed_get_oauth_server_config": "Erreur de connexion par OAuth, vérifiez l\"URL du serveur",
|
||||
"login_form_failed_get_oauth_server_disable": "La fonctionnalité OAuth n'est pas disponible sur ce serveur",
|
||||
"login_form_failed_login": "Erreur de connexion, vérifiez l'url du serveur, l'email et le mot de passe",
|
||||
"login_form_handshake_exception": "Il y a eu une exception de liaison avec le serveur. Activez la prise en charge des certificats auto-signés dans les paramètres si vous utilisez un certificat auto-signé.",
|
||||
"login_form_label_email": "E-mail",
|
||||
"login_form_label_password": "Mot de passe",
|
||||
"login_form_next_button": "Suivant",
|
||||
"login_form_password_hint": "mot de passe",
|
||||
"login_form_save_login": "Rester connecté",
|
||||
"login_form_server_empty": "Saisissez l'URL du serveur.",
|
||||
"login_form_server_error": "Impossible de se connecter au serveur.",
|
||||
"login_password_changed_error": "Une erreur s'est produite lors de la mise à jour de votre mot de passe",
|
||||
"login_password_changed_success": "Mot de passe mis à jour avec succès",
|
||||
"map_cannot_get_user_location": "Impossible d'obtenir la localisation de l'utilisateur",
|
||||
"map_location_dialog_cancel": "Annuler",
|
||||
"map_location_dialog_yes": "Oui",
|
||||
"map_location_service_disabled_content": "Le service de localisation doit être activé pour afficher les éléments de votre emplacement actuel. Souhaitez-vous l'activer maintenant?",
|
||||
"map_location_service_disabled_title": "Service de localisation désactivé",
|
||||
"map_no_assets_in_bounds": "Pas de photos dans cette zone",
|
||||
"map_no_location_permission_content": "L'autorisation de localisation est nécessaire pour afficher les éléments de votre emplacement actuel. Souhaitez-vous l'autoriser maintenant?",
|
||||
"map_no_location_permission_title": "Permission de localisation refusée",
|
||||
"map_settings_dark_mode": "Mode sombre",
|
||||
"map_settings_dialog_cancel": "Annuler",
|
||||
"map_settings_dialog_save": "Sauvegarder",
|
||||
"map_settings_dialog_title": "Paramètres de la carte",
|
||||
"map_settings_include_show_archived": "Inclure les archives",
|
||||
"map_settings_only_relative_range": "Plage de dates",
|
||||
"map_settings_only_show_favorites": "Afficher uniquement les favoris",
|
||||
"map_zoom_to_see_photos": "Dézoomer pour voir les photos",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"motion_photos_page_title": "Photos avec mouvement",
|
||||
"notification_permission_dialog_cancel": "Annuler",
|
||||
"notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sélectionnez Autoriser.",
|
||||
"notification_permission_dialog_settings": "Paramètres",
|
||||
"notification_permission_list_tile_content": "Accordez la permission d'activer les notifications.",
|
||||
"notification_permission_list_tile_enable_button": "Activer les notifications",
|
||||
"notification_permission_list_tile_title": "Permission de notification",
|
||||
"partner_page_add_partner": "Ajouter un partenaire",
|
||||
"partner_page_empty_message": "Vos photos ne sont pas encore partagées avec un partenaire.",
|
||||
"partner_page_no_more_users": "Plus d'utilisateurs à ajouter",
|
||||
"partner_page_partner_add_failed": "Échec de l'ajout d'un partenaire",
|
||||
"partner_page_select_partner": "Sélectionner un partenaire",
|
||||
"partner_page_shared_to_title": "Partagé avec",
|
||||
"partner_page_stop_sharing_content": "{} ne pourra plus accéder à vos photos.",
|
||||
"partner_page_stop_sharing_title": "Arrêter de partager vos photos?",
|
||||
"partner_page_title": "Partenaire",
|
||||
"permission_onboarding_continue_anyway": "Continuer quand même",
|
||||
"permission_onboarding_get_started": "Commencer",
|
||||
"permission_onboarding_go_to_settings": "Accéder aux paramètres",
|
||||
"permission_onboarding_grant_permission": "Accorder l'autorisation",
|
||||
"permission_onboarding_log_out": "Se déconnecter",
|
||||
"permission_onboarding_permission_denied": "Permission refusée. Pour utiliser Immich, accordez lautorisation pour les photos et vidéos dans les Paramètres.",
|
||||
"permission_onboarding_permission_granted": "Permission accordée! Vous êtes prêts.",
|
||||
"permission_onboarding_permission_limited": "Permission limitée. Pour permettre à Immich de sauvegarder et de gérer l'ensemble de votre bibliothèque, accordez l'autorisation pour les photos et vidéos dans les Paramètres.",
|
||||
"permission_onboarding_request": "Immich demande l'autorisation de visionner vos photos et vidéo",
|
||||
"profile_drawer_app_logs": "Journaux",
|
||||
"profile_drawer_client_server_up_to_date": "Le client et le serveur sont à jour",
|
||||
"profile_drawer_documentation": "Documentation",
|
||||
"profile_drawer_github": "GitHub",
|
||||
"profile_drawer_settings": "Paramètres",
|
||||
"profile_drawer_sign_out": "Se déconnecter",
|
||||
"profile_drawer_trash": "Corbeille",
|
||||
"recently_added_page_title": "Récemment ajouté",
|
||||
"search_bar_hint": "Rechercher vos photos",
|
||||
"search_page_categories": "Catégories",
|
||||
"search_page_favorites": "Favoris",
|
||||
"search_page_motion_photos": "Photos avec mouvement",
|
||||
"search_page_no_objects": "Aucune information disponible sur les objets",
|
||||
"search_page_no_places": "Aucune information disponible sur la localisation",
|
||||
"search_page_people": "Personnes",
|
||||
"search_page_places": "Lieux",
|
||||
"search_page_recently_added": "Récemment ajouté",
|
||||
"search_page_screenshots": "Captures d'écran",
|
||||
"search_page_selfies": "Selfies",
|
||||
"search_page_things": "Objets",
|
||||
"search_page_videos": "Vidéos",
|
||||
"search_page_view_all_button": "Voir tout",
|
||||
"search_page_your_activity": "Votre activité",
|
||||
"search_result_page_new_search_hint": "Nouvelle recherche",
|
||||
"search_suggestion_list_smart_search_hint_1": "La recherche intelligente est activée par défaut. Pour rechercher des métadonnées, utilisez la syntaxe suivante",
|
||||
"search_suggestion_list_smart_search_hint_2": "m:votre-terme-de-recherche",
|
||||
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
|
||||
"select_user_for_sharing_page_err_album": "Échec de la création de l'album",
|
||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||
"server_info_box_app_version": "Version de l'application",
|
||||
"server_info_box_server_url": "URL du serveur",
|
||||
"server_info_box_server_version": "Version du serveur",
|
||||
"setting_image_viewer_help": "Le visualiseur de détails charge d'abord la petite vignette, puis l'aperçu de taille moyenne (s'il est activé), enfin l'original (s'il est activé).",
|
||||
"setting_image_viewer_original_subtitle": "Activez cette option pour charger l'image en résolution originale (volumineux!). Désactiver pour réduire l'utilisation des données (réseau et cache de l'appareil).",
|
||||
"setting_image_viewer_original_title": "Charger l'image originale",
|
||||
"setting_image_viewer_preview_subtitle": "Activer pour charger une image de résolution moyenne. Désactiver pour charger directement l'original ou utiliser uniquement la vignette.",
|
||||
"setting_image_viewer_preview_title": "Charger l'image d'aperçu",
|
||||
"setting_notifications_notify_failures_grace_period": "Notifier les échecs de la sauvegarde en arrière-plan: {}",
|
||||
"setting_notifications_notify_hours": "{} heures",
|
||||
"setting_notifications_notify_immediately": "immédiatement",
|
||||
"setting_notifications_notify_minutes": "{} minutes",
|
||||
"setting_notifications_notify_never": "jamais",
|
||||
"setting_notifications_notify_seconds": "{} secondes",
|
||||
"setting_notifications_single_progress_subtitle": "Informations détaillées sur la progression du transfert par élément",
|
||||
"setting_notifications_single_progress_title": "Afficher la progression du détail de la sauvegarde en arrière-plan",
|
||||
"setting_notifications_subtitle": "Ajustez vos préférences de notification",
|
||||
"setting_notifications_title": "Notifications",
|
||||
"setting_notifications_total_progress_subtitle": "Progression globale du transfert (effectué/total des éléments)",
|
||||
"setting_notifications_total_progress_title": "Afficher la progression totale de la sauvegarde en arrière-plan",
|
||||
"setting_pages_app_bar_settings": "Paramètres",
|
||||
"settings_require_restart": "Veuillez redémarrer Immich pour appliquer ce paramètre",
|
||||
"share_add": "Ajouter",
|
||||
"share_add_photos": "Ajouter des photos",
|
||||
"share_add_title": "Ajouter un titre",
|
||||
"share_create_album": "Créer un album",
|
||||
"shared_album_activities_input_disable": "Les commentaires sont désactivés",
|
||||
"shared_album_activities_input_hint": "Dire quelque chose",
|
||||
"shared_album_activity_remove_content": "Souhaitez-vous supprimer cette activité?",
|
||||
"shared_album_activity_remove_title": "Supprimer l'activité",
|
||||
"shared_album_activity_setting_subtitle": "Laisser les autres réagir",
|
||||
"shared_album_activity_setting_title": "Commentaires et likes",
|
||||
"share_dialog_preparing": "Préparation...",
|
||||
"shared_link_app_bar_title": "Liens partagés",
|
||||
"shared_link_create_app_bar_title": "Créer un lien pour partager",
|
||||
"shared_link_create_info": "Permettre à toute personne ayant le lien de voir la ou les photos sélectionnées",
|
||||
"shared_link_create_submit_button": "Créer le lien",
|
||||
"shared_link_edit_allow_download": "Autoriser les utilisateurs publics à télécharger",
|
||||
"shared_link_edit_allow_upload": "Autoriser les utilisateurs publics à téléverser",
|
||||
"shared_link_edit_app_bar_title": "Modifier le lien",
|
||||
"shared_link_edit_change_expiry": "Modifier le délai d'expiration",
|
||||
"shared_link_edit_description": "Description",
|
||||
"shared_link_edit_description_hint": "Saisir la description du partage",
|
||||
"shared_link_edit_expire_after": "Expire après",
|
||||
"shared_link_edit_password": "Mot de passe",
|
||||
"shared_link_edit_password_hint": "Saisir le mot de passe de partage",
|
||||
"shared_link_edit_show_meta": "Afficher les métadonnées",
|
||||
"shared_link_edit_submit_button": "Mettre à jour le lien",
|
||||
"shared_link_empty": "Vous n'avez pas de liens partagés",
|
||||
"shared_link_manage_links": "Gérer les liens partagés",
|
||||
"share_done": "Fait",
|
||||
"share_invite": "Inviter à l'album",
|
||||
"sharing_page_album": "Albums partagés",
|
||||
"sharing_page_description": "Créez des albums partagés pour partager des photos et des vidéos avec les personnes de votre réseau.",
|
||||
"sharing_page_empty_list": "LISTE VIDE",
|
||||
"sharing_silver_appbar_create_shared_album": "Créer un album partagé",
|
||||
"sharing_silver_appbar_shared_links": "Liens partagés",
|
||||
"sharing_silver_appbar_share_partner": "Partager avec un partenaire",
|
||||
"tab_controller_nav_library": "Bibliothèque",
|
||||
"tab_controller_nav_photos": "Photos",
|
||||
"tab_controller_nav_search": "Recherche",
|
||||
"tab_controller_nav_sharing": "Partage",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Afficher l'indicateur de stockage sur les tuiles des éléments",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Nombre d'éléments par ligne ({})",
|
||||
"theme_setting_dark_mode_switch": "Mode sombre",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Ajustez la qualité de la visionneuse d'images détaillées",
|
||||
"theme_setting_image_viewer_quality_title": "Qualité de la visualisation des images",
|
||||
"theme_setting_system_theme_switch": "Automatique (suivre les paramètres du système)",
|
||||
"theme_setting_theme_subtitle": "Choisissez le thème de l'application",
|
||||
"theme_setting_theme_title": "Thème",
|
||||
"theme_setting_three_stage_loading_subtitle": "Le chargement en trois étapes peut améliorer les performances de chargement, mais entraîne une augmentation significative de la charge du réseau.",
|
||||
"theme_setting_three_stage_loading_title": "Activer le chargement en trois étapes",
|
||||
"translated_text_options": "Options",
|
||||
"trash_page_delete": "Supprimer",
|
||||
"trash_page_delete_all": "Tout supprimer",
|
||||
"trash_page_empty_trash_btn": "Vider la corbeille",
|
||||
"trash_page_empty_trash_dialog_content": "Voulez-vous vider les éléments de la corbeille? Ces objets seront définitivement retirés d'Immich",
|
||||
"trash_page_empty_trash_dialog_ok": "Ok",
|
||||
"trash_page_info": "Les éléments mis à la corbeille seront définitivement supprimés au bout de {} jours.",
|
||||
"trash_page_no_assets": "Pas d'éléments dans la corbeille",
|
||||
"trash_page_restore": "Restaurer",
|
||||
"trash_page_restore_all": "Tout restaurer",
|
||||
"trash_page_select_assets_btn": "Sélectionner les éléments",
|
||||
"trash_page_select_btn": "Sélectionner",
|
||||
"trash_page_title": "Corbeille ({})",
|
||||
"upload_dialog_cancel": "Annuler",
|
||||
"upload_dialog_info": "Voulez-vous sauvegarder la sélection vers le serveur?",
|
||||
"upload_dialog_ok": "Télécharger ",
|
||||
"upload_dialog_title": "Télécharger cet élément ",
|
||||
"version_announcement_overlay_ack": "Confirmer",
|
||||
"version_announcement_overlay_release_notes": "notes de mise à jour",
|
||||
"version_announcement_overlay_text_1": "Bonjour, une nouvelle version de",
|
||||
"version_announcement_overlay_text_2": "veuillez prendre le temps de visiter le ",
|
||||
"version_announcement_overlay_text_3": " et assurez-vous que votre configuration docker-compose et .env est à jour pour éviter toute erreur de configuration, en particulier si vous utilisez WatchTower ou tout autre mécanisme qui gère la mise à jour automatique de votre application serveur.",
|
||||
"version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89",
|
||||
"viewer_remove_from_stack": "Retirer de la pile",
|
||||
"viewer_stack_use_as_main_asset": "Utiliser comme élément principal",
|
||||
"viewer_unstack": "Désempiler"
|
||||
}
|
||||
@@ -169,4 +169,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.1
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
<string>es</string>
|
||||
<string>vi</string>
|
||||
<string>fr</string>
|
||||
<string>fr</string>
|
||||
<string>ja</string>
|
||||
<string>pl</string>
|
||||
<string>fi</string>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.86.0"
|
||||
version_number: "1.85.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -9,7 +9,6 @@ const List<Locale> locales = [
|
||||
Locale('it', 'IT'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('vi', 'VN'),
|
||||
Locale('fr', 'CA'),
|
||||
Locale('fr', 'FR'),
|
||||
Locale('ja', 'JP'),
|
||||
Locale('pl', 'PL'),
|
||||
|
||||
@@ -274,7 +274,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
// The app is currently in background. Perform the necessary cleanups which
|
||||
// are on-hold for upload completion
|
||||
if (appState != AppStateEnum.active && appState != AppStateEnum.resumed) {
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
ref.read(appStateProvider.notifier).handleAppInactivity();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,6 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
SizedBox(
|
||||
height: 70,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: renderActionButtons(),
|
||||
),
|
||||
|
||||
@@ -60,7 +60,7 @@ class User {
|
||||
bool isPartnerSharedWith;
|
||||
bool isAdmin;
|
||||
String profileImagePath;
|
||||
@Enumerated(EnumType.ordinal)
|
||||
@Enumerated(EnumType.name)
|
||||
AvatarColorEnum avatarColor;
|
||||
bool memoryEnabled;
|
||||
bool inTimeline;
|
||||
@@ -103,7 +103,6 @@ class User {
|
||||
}
|
||||
|
||||
enum AvatarColorEnum {
|
||||
// do not change this order or reuse indices for other purposes, adding is OK
|
||||
primary,
|
||||
pink,
|
||||
red,
|
||||
|
||||
@@ -20,7 +20,7 @@ const UserSchema = CollectionSchema(
|
||||
r'avatarColor': PropertySchema(
|
||||
id: 0,
|
||||
name: r'avatarColor',
|
||||
type: IsarType.byte,
|
||||
type: IsarType.string,
|
||||
enumMap: _UseravatarColorEnumValueMap,
|
||||
),
|
||||
r'email': PropertySchema(
|
||||
@@ -123,6 +123,7 @@ int _userEstimateSize(
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
var bytesCount = offsets.last;
|
||||
bytesCount += 3 + object.avatarColor.name.length * 3;
|
||||
bytesCount += 3 + object.email.length * 3;
|
||||
bytesCount += 3 + object.id.length * 3;
|
||||
bytesCount += 3 + object.name.length * 3;
|
||||
@@ -136,7 +137,7 @@ void _userSerialize(
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeByte(offsets[0], object.avatarColor.index);
|
||||
writer.writeString(offsets[0], object.avatarColor.name);
|
||||
writer.writeString(offsets[1], object.email);
|
||||
writer.writeString(offsets[2], object.id);
|
||||
writer.writeBool(offsets[3], object.inTimeline);
|
||||
@@ -157,7 +158,7 @@ User _userDeserialize(
|
||||
) {
|
||||
final object = User(
|
||||
avatarColor:
|
||||
_UseravatarColorValueEnumMap[reader.readByteOrNull(offsets[0])] ??
|
||||
_UseravatarColorValueEnumMap[reader.readStringOrNull(offsets[0])] ??
|
||||
AvatarColorEnum.primary,
|
||||
email: reader.readString(offsets[1]),
|
||||
id: reader.readString(offsets[2]),
|
||||
@@ -181,7 +182,7 @@ P _userDeserializeProp<P>(
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (_UseravatarColorValueEnumMap[reader.readByteOrNull(offset)] ??
|
||||
return (_UseravatarColorValueEnumMap[reader.readStringOrNull(offset)] ??
|
||||
AvatarColorEnum.primary) as P;
|
||||
case 1:
|
||||
return (reader.readString(offset)) as P;
|
||||
@@ -209,28 +210,28 @@ P _userDeserializeProp<P>(
|
||||
}
|
||||
|
||||
const _UseravatarColorEnumValueMap = {
|
||||
'primary': 0,
|
||||
'pink': 1,
|
||||
'red': 2,
|
||||
'yellow': 3,
|
||||
'blue': 4,
|
||||
'green': 5,
|
||||
'purple': 6,
|
||||
'orange': 7,
|
||||
'gray': 8,
|
||||
'amber': 9,
|
||||
r'primary': r'primary',
|
||||
r'pink': r'pink',
|
||||
r'red': r'red',
|
||||
r'yellow': r'yellow',
|
||||
r'blue': r'blue',
|
||||
r'green': r'green',
|
||||
r'purple': r'purple',
|
||||
r'orange': r'orange',
|
||||
r'gray': r'gray',
|
||||
r'amber': r'amber',
|
||||
};
|
||||
const _UseravatarColorValueEnumMap = {
|
||||
0: AvatarColorEnum.primary,
|
||||
1: AvatarColorEnum.pink,
|
||||
2: AvatarColorEnum.red,
|
||||
3: AvatarColorEnum.yellow,
|
||||
4: AvatarColorEnum.blue,
|
||||
5: AvatarColorEnum.green,
|
||||
6: AvatarColorEnum.purple,
|
||||
7: AvatarColorEnum.orange,
|
||||
8: AvatarColorEnum.gray,
|
||||
9: AvatarColorEnum.amber,
|
||||
r'primary': AvatarColorEnum.primary,
|
||||
r'pink': AvatarColorEnum.pink,
|
||||
r'red': AvatarColorEnum.red,
|
||||
r'yellow': AvatarColorEnum.yellow,
|
||||
r'blue': AvatarColorEnum.blue,
|
||||
r'green': AvatarColorEnum.green,
|
||||
r'purple': AvatarColorEnum.purple,
|
||||
r'orange': AvatarColorEnum.orange,
|
||||
r'gray': AvatarColorEnum.gray,
|
||||
r'amber': AvatarColorEnum.amber,
|
||||
};
|
||||
|
||||
Id _userGetId(User object) {
|
||||
@@ -421,11 +422,14 @@ extension UserQueryWhere on QueryBuilder<User, User, QWhereClause> {
|
||||
|
||||
extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorEqualTo(
|
||||
AvatarColorEnum value) {
|
||||
AvatarColorEnum value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'avatarColor',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -433,12 +437,14 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorGreaterThan(
|
||||
AvatarColorEnum value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'avatarColor',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -446,12 +452,14 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorLessThan(
|
||||
AvatarColorEnum value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'avatarColor',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -461,6 +469,7 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
|
||||
AvatarColorEnum upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
@@ -469,6 +478,75 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'avatarColor',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'avatarColor',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorContains(
|
||||
String value,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'avatarColor',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorMatches(
|
||||
String pattern,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'avatarColor',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'avatarColor',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> avatarColorIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
property: r'avatarColor',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -1538,9 +1616,10 @@ extension UserQuerySortThenBy on QueryBuilder<User, User, QSortThenBy> {
|
||||
}
|
||||
|
||||
extension UserQueryWhereDistinct on QueryBuilder<User, User, QDistinct> {
|
||||
QueryBuilder<User, User, QDistinct> distinctByAvatarColor() {
|
||||
QueryBuilder<User, User, QDistinct> distinctByAvatarColor(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'avatarColor');
|
||||
return query.addDistinctBy(r'avatarColor', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,13 @@ enum AppStateEnum {
|
||||
}
|
||||
|
||||
class AppStateNotiifer extends StateNotifier<AppStateEnum> {
|
||||
final Ref _ref;
|
||||
bool _wasPaused = false;
|
||||
final Ref ref;
|
||||
|
||||
AppStateNotiifer(this._ref) : super(AppStateEnum.active);
|
||||
AppStateNotiifer(this.ref) : super(AppStateEnum.active);
|
||||
|
||||
void updateAppState(AppStateEnum appState) {
|
||||
state = appState;
|
||||
}
|
||||
|
||||
AppStateEnum getAppState() {
|
||||
return state;
|
||||
@@ -40,72 +43,65 @@ class AppStateNotiifer extends StateNotifier<AppStateEnum> {
|
||||
void handleAppResume() {
|
||||
state = AppStateEnum.resumed;
|
||||
|
||||
// no need to resume because app was never really paused
|
||||
if (!_wasPaused) return;
|
||||
_wasPaused = false;
|
||||
|
||||
final isAuthenticated = _ref.read(authenticationProvider).isAuthenticated;
|
||||
final permission = _ref.read(galleryPermissionNotifier);
|
||||
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
|
||||
final permission = ref.watch(galleryPermissionNotifier);
|
||||
|
||||
// Needs to be logged in and have gallery permissions
|
||||
if (isAuthenticated && (permission.isGranted || permission.isLimited)) {
|
||||
_ref.read(backupProvider.notifier).resumeBackup();
|
||||
_ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
_ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
switch (_ref.read(tabProvider)) {
|
||||
ref.read(backupProvider.notifier).resumeBackup();
|
||||
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
switch (ref.read(tabProvider)) {
|
||||
case TabEnum.home:
|
||||
_ref.read(assetProvider.notifier).getAllAsset();
|
||||
_ref.read(assetProvider.notifier).getPartnerAssets();
|
||||
ref.read(assetProvider.notifier).getAllAsset();
|
||||
ref.read(assetProvider.notifier).getPartnerAssets();
|
||||
case TabEnum.search:
|
||||
// nothing to do
|
||||
case TabEnum.sharing:
|
||||
_ref.read(assetProvider.notifier).getPartnerAssets();
|
||||
_ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
ref.read(assetProvider.notifier).getPartnerAssets();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
case TabEnum.library:
|
||||
_ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
}
|
||||
}
|
||||
|
||||
_ref.read(websocketProvider.notifier).connect();
|
||||
ref.watch(websocketProvider.notifier).connect();
|
||||
|
||||
_ref.read(releaseInfoProvider.notifier).checkGithubReleaseInfo();
|
||||
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
|
||||
|
||||
_ref
|
||||
.read(notificationPermissionProvider.notifier)
|
||||
ref
|
||||
.watch(notificationPermissionProvider.notifier)
|
||||
.getNotificationPermission();
|
||||
_ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
|
||||
ref.watch(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
|
||||
|
||||
_ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
|
||||
ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
|
||||
|
||||
_ref.invalidate(memoryFutureProvider);
|
||||
ref.invalidate(memoryFutureProvider);
|
||||
}
|
||||
|
||||
void handleAppInactivity() {
|
||||
state = AppStateEnum.inactive;
|
||||
// do not stop/clean up anything on inactivity: issued on every orientation change
|
||||
|
||||
// Do not handle inactivity if manual upload is in progress
|
||||
if (ref.watch(backupProvider.notifier).backupProgress !=
|
||||
BackUpProgressEnum.manualInProgress) {
|
||||
ImmichLogger().flush();
|
||||
ref.read(websocketProvider.notifier).disconnect();
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
}
|
||||
}
|
||||
|
||||
void handleAppPause() {
|
||||
state = AppStateEnum.paused;
|
||||
_wasPaused = true;
|
||||
// Do not cancel backup if manual upload is in progress
|
||||
if (_ref.read(backupProvider.notifier).backupProgress !=
|
||||
BackUpProgressEnum.manualInProgress) {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
}
|
||||
_ref.read(websocketProvider.notifier).disconnect();
|
||||
ImmichLogger().flush();
|
||||
}
|
||||
|
||||
void handleAppDetached() {
|
||||
state = AppStateEnum.detached;
|
||||
// no guarantee this is called at all
|
||||
_ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||
ref.watch(manualUploadProvider.notifier).cancelBackup();
|
||||
}
|
||||
|
||||
void handleAppHidden() {
|
||||
state = AppStateEnum.hidden;
|
||||
// do not stop/clean up anything on inactivity: issued on every orientation change
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,19 +63,21 @@ class WebsocketState {
|
||||
}
|
||||
|
||||
class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
WebsocketNotifier(this._ref)
|
||||
WebsocketNotifier(this.ref)
|
||||
: super(
|
||||
WebsocketState(socket: null, isConnected: false, pendingChanges: []),
|
||||
);
|
||||
) {
|
||||
debounce = Debounce(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
}
|
||||
|
||||
final _log = Logger('WebsocketNotifier');
|
||||
final Ref _ref;
|
||||
final Debounce _debounce = Debounce(const Duration(milliseconds: 500));
|
||||
final log = Logger('WebsocketNotifier');
|
||||
final Ref ref;
|
||||
late final Debounce debounce;
|
||||
|
||||
/// Connects websocket to server unless already connected
|
||||
void connect() {
|
||||
if (state.isConnected) return;
|
||||
final authenticationState = _ref.read(authenticationProvider);
|
||||
connect() {
|
||||
var authenticationState = ref.read(authenticationProvider);
|
||||
|
||||
if (authenticationState.isAuthenticated) {
|
||||
final accessToken = Store.get(StoreKey.accessToken);
|
||||
@@ -116,7 +118,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
});
|
||||
|
||||
socket.on('error', (errorMessage) {
|
||||
_log.severe("Websocket Error - $errorMessage");
|
||||
log.severe("Websocket Error - $errorMessage");
|
||||
state = WebsocketState(
|
||||
isConnected: false,
|
||||
socket: null,
|
||||
@@ -136,7 +138,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
disconnect() {
|
||||
debugPrint("Attempting to disconnect from websocket");
|
||||
|
||||
var socket = state.socket?.disconnect();
|
||||
@@ -150,30 +152,30 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
}
|
||||
}
|
||||
|
||||
void stopListenToEvent(String eventName) {
|
||||
stopListenToEvent(String eventName) {
|
||||
debugPrint("Stop listening to event $eventName");
|
||||
state.socket?.off(eventName);
|
||||
}
|
||||
|
||||
void listenUploadEvent() {
|
||||
listenUploadEvent() {
|
||||
debugPrint("Start listening to event on_upload_success");
|
||||
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
|
||||
}
|
||||
|
||||
void addPendingChange(PendingAction action, dynamic value) {
|
||||
addPendingChange(PendingAction action, dynamic value) {
|
||||
state = state.copyWith(
|
||||
pendingChanges: [...state.pendingChanges, PendingChange(action, value)],
|
||||
);
|
||||
}
|
||||
|
||||
void handlePendingChanges() {
|
||||
handlePendingChanges() {
|
||||
final deleteChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetDelete)
|
||||
.toList();
|
||||
if (deleteChanges.isNotEmpty) {
|
||||
List<String> remoteIds =
|
||||
deleteChanges.map((a) => a.value.toString()).toList();
|
||||
_ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||
ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.where((c) => c.action != PendingAction.assetDelete)
|
||||
@@ -182,27 +184,27 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleOnUploadSuccess(dynamic data) {
|
||||
_handleOnUploadSuccess(dynamic data) {
|
||||
final dto = AssetResponseDto.fromJson(data);
|
||||
if (dto != null) {
|
||||
final newAsset = Asset.remote(dto);
|
||||
_ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
||||
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleOnConfigUpdate(dynamic _) {
|
||||
_ref.read(serverInfoProvider.notifier).getServerFeatures();
|
||||
_ref.read(serverInfoProvider.notifier).getServerConfig();
|
||||
_handleOnConfigUpdate(dynamic _) {
|
||||
ref.read(serverInfoProvider.notifier).getServerFeatures();
|
||||
ref.read(serverInfoProvider.notifier).getServerConfig();
|
||||
}
|
||||
|
||||
// Refresh updated assets
|
||||
void _handleServerUpdates(dynamic _) {
|
||||
_ref.read(assetProvider.notifier).getAllAsset();
|
||||
_handleServerUpdates(dynamic _) {
|
||||
ref.read(assetProvider.notifier).getAllAsset();
|
||||
}
|
||||
|
||||
void _handleOnAssetDelete(dynamic data) {
|
||||
_handleOnAssetDelete(dynamic data) {
|
||||
addPendingChange(PendingAction.assetDelete, data);
|
||||
_debounce(handlePendingChanges);
|
||||
debounce(handlePendingChanges);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,13 +34,12 @@ class ControlBoxButton extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(10),
|
||||
shape: const CircleBorder(),
|
||||
onPressed: onPressed,
|
||||
minWidth: 75.0,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(iconData, size: 24),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12.0),
|
||||
|
||||
@@ -30,7 +30,6 @@ class UserCircleAvatar extends ConsumerWidget {
|
||||
user.name[0].toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: isDarkTheme && user.avatarColor == AvatarColorEnum.primary
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
|
||||
@@ -40,25 +40,12 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||
log.severe(e);
|
||||
}
|
||||
|
||||
try {
|
||||
isSuccess = await ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.setSuccessLoginInfo(
|
||||
accessToken: accessToken,
|
||||
serverUrl: serverUrl,
|
||||
offlineLogin: deviceIsOffline,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
ref.read(authenticationProvider.notifier).logout();
|
||||
|
||||
log.severe(
|
||||
'Cannot set success login info: $error',
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
|
||||
context.autoPush(const LoginRoute());
|
||||
}
|
||||
isSuccess =
|
||||
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
|
||||
accessToken: accessToken,
|
||||
serverUrl: serverUrl,
|
||||
offlineLogin: deviceIsOffline,
|
||||
);
|
||||
}
|
||||
|
||||
// If the device is offline and there is a currentUser stored locallly
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.86.0
|
||||
- API version: 1.85.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.86.0+110
|
||||
version: 1.85.0+109
|
||||
isar_version: &isar_version 3.1.0+1
|
||||
|
||||
environment:
|
||||
|
||||
@@ -5773,7 +5773,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.86.0",
|
||||
"version": "1.85.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -7656,7 +7656,8 @@
|
||||
"PartnerResponseDto": {
|
||||
"properties": {
|
||||
"avatarColor": {
|
||||
"$ref": "#/components/schemas/UserAvatarColor"
|
||||
"$ref": "#/components/schemas/UserAvatarColor",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "date-time",
|
||||
@@ -9250,7 +9251,8 @@
|
||||
"UserDto": {
|
||||
"properties": {
|
||||
"avatarColor": {
|
||||
"$ref": "#/components/schemas/UserAvatarColor"
|
||||
"$ref": "#/components/schemas/UserAvatarColor",
|
||||
"nullable": true
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
@@ -9277,7 +9279,8 @@
|
||||
"UserResponseDto": {
|
||||
"properties": {
|
||||
"avatarColor": {
|
||||
"$ref": "#/components/schemas/UserAvatarColor"
|
||||
"$ref": "#/components/schemas/UserAvatarColor",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "date-time",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.86.0",
|
||||
"version": "1.85.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.86.0",
|
||||
"version": "1.85.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.22.11",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.86.0",
|
||||
"version": "1.85.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserAvatarColor, UserEntity } from '@app/infra/entities';
|
||||
import { Optional } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum } from 'class-validator';
|
||||
|
||||
@@ -19,8 +20,9 @@ export class UserDto {
|
||||
email!: string;
|
||||
profileImagePath!: string;
|
||||
@IsEnum(UserAvatarColor)
|
||||
@Optional()
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor!: UserAvatarColor;
|
||||
avatarColor!: UserAvatarColor | null;
|
||||
}
|
||||
|
||||
export class UserResponseDto extends UserDto {
|
||||
|
||||
@@ -355,10 +355,10 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
describe('deleteProfileImage', () => {
|
||||
it('should send an http error has no profile image', async () => {
|
||||
it('should do nothing if the user has no profile image', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.admin);
|
||||
|
||||
await expect(sut.deleteProfileImage(userStub.admin)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await sut.deleteProfileImage(userStub.admin);
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -96,18 +96,20 @@ export class UserService {
|
||||
const { profileImagePath: oldpath } = await this.findOrFail(authUser.id, { withDeleted: false });
|
||||
const updatedUser = await this.userRepository.update(authUser.id, { profileImagePath: fileInfo.path });
|
||||
if (oldpath !== '') {
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } });
|
||||
const files = [oldpath];
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
}
|
||||
return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath);
|
||||
}
|
||||
|
||||
async deleteProfileImage(authUser: AuthUserDto): Promise<void> {
|
||||
const user = await this.findOrFail(authUser.id, { withDeleted: false });
|
||||
if (user.profileImagePath === '') {
|
||||
throw new BadRequestException("Can't delete a missing profile Image");
|
||||
if (user.profileImagePath !== '') {
|
||||
await this.userRepository.update(authUser.id, { profileImagePath: '' });
|
||||
const files = [user.profileImagePath];
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
}
|
||||
await this.userRepository.update(authUser.id, { profileImagePath: '' });
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } });
|
||||
return;
|
||||
}
|
||||
|
||||
async getProfileImage(id: string): Promise<ImmichReadStream> {
|
||||
@@ -124,7 +126,7 @@ export class UserService {
|
||||
throw new BadRequestException('Admin account does not exist');
|
||||
}
|
||||
|
||||
const providedPassword = await ask(mapUser(admin));
|
||||
const providedPassword = await ask(admin);
|
||||
const password = providedPassword || randomBytes(24).toString('base64').replace(/\W/g, '');
|
||||
|
||||
await this.userCore.updateUser(admin, admin.id, { password });
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
SignUpDto,
|
||||
UserResponseDto,
|
||||
ValidateAccessTokenResponseDto,
|
||||
mapUser,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
@@ -72,7 +71,7 @@ export class AuthController {
|
||||
@Post('change-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
changePassword(@AuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
|
||||
return this.service.changePassword(authUser, dto).then(mapUser);
|
||||
return this.service.changePassword(authUser, dto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.86.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -2,28 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* light */
|
||||
--immich-primary: 66 80 175;
|
||||
--immich-bg: 255 255 255;
|
||||
--immich-fg: 0 0 0;
|
||||
--immich-gray: 246 246 244;
|
||||
--immich-error: 229 115 115;
|
||||
--immich-success: 129 199 132;
|
||||
--immich-warning: 255 183 77;
|
||||
|
||||
/* dark */
|
||||
--immich-dark-primary: 172 203 250;
|
||||
--immich-dark-bg: 0 0 0;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
--immich-dark-error: 211 47 47;
|
||||
--immich-dark-success: 56 142 60;
|
||||
--immich-dark-warning: 245 124 0;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Work Sans';
|
||||
src: url('$lib/assets/fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations');
|
||||
|
||||
@@ -6,18 +6,10 @@
|
||||
import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import {
|
||||
mdiCalendar,
|
||||
mdiCameraIris,
|
||||
mdiClose,
|
||||
mdiImageOutline,
|
||||
mdiMapMarkerOutline,
|
||||
mdiInformationOutline,
|
||||
} from '@mdi/js';
|
||||
import { mdiCalendar, mdiCameraIris, mdiClose, mdiImageOutline, mdiMapMarkerOutline } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import Map from '../shared-components/map/map.svelte';
|
||||
|
||||
@@ -85,9 +77,6 @@
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
let showAssetPath = false;
|
||||
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
||||
</script>
|
||||
|
||||
<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
@@ -226,15 +215,8 @@
|
||||
<div><Icon path={mdiImageOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p class="break-all flex place-items-center gap-2">
|
||||
{#if isOwner}
|
||||
{asset.originalFileName}
|
||||
<button title="Show File Location" on:click={toggleAssetPath}>
|
||||
<Icon path={mdiInformationOutline} />
|
||||
</button>
|
||||
{:else}
|
||||
{getAssetFilename(asset)}
|
||||
{/if}
|
||||
<p class="break-all">
|
||||
{getAssetFilename(asset)}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
|
||||
@@ -248,11 +230,6 @@
|
||||
{/if}
|
||||
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
|
||||
</div>
|
||||
{#if showAssetPath}
|
||||
<p class="text-xs opacity-50 break-all" transition:slide={{ duration: 250 }}>
|
||||
{asset.originalPath}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -15,22 +15,24 @@
|
||||
<FullScreenModal on:clickOutside={() => dispatch('close')} on:escape={() => dispatch('close')}>
|
||||
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
|
||||
<div
|
||||
class=" rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg p-4"
|
||||
class=" rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary text-sm">
|
||||
SELECT AVATAR COLOR
|
||||
<div class="flex px-2 pt-2 items-center">
|
||||
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
Select avatar color
|
||||
</h1>
|
||||
<div>
|
||||
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center p-4 mt-4">
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{#each colors as color}
|
||||
<button on:click={() => dispatch('choose', color)}>
|
||||
<UserAvatar {user} {color} size="xl" showProfileImage={false} />
|
||||
</button>
|
||||
<div>
|
||||
<button on:click={() => dispatch('choose', color)}>
|
||||
<UserAvatar {user} {color} size="xl" showProfileImage={false} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
sm: 'w-7 h-7',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-16 h-16',
|
||||
xl: 'w-20 h-20',
|
||||
xxl: 'w-24 h-24',
|
||||
xxxl: 'w-28 h-28',
|
||||
};
|
||||
|
||||
+14
-14
@@ -6,22 +6,22 @@ module.exports = {
|
||||
extend: {
|
||||
colors: {
|
||||
// Light Theme
|
||||
'immich-primary': 'rgb(var(--immich-primary) / <alpha-value>)',
|
||||
'immich-bg': 'rgb(var(--immich-bg) / <alpha-value>)',
|
||||
'immich-fg': 'rgb(var(--immich-fg) / <alpha-value>)',
|
||||
'immich-gray': 'rgb(var(--immich-gray) / <alpha-value>)',
|
||||
'immich-error': 'rgb(var(--immich-error) / <alpha-value>)',
|
||||
'immich-success': 'rgb(var(--immich-success) / <alpha-value>)',
|
||||
'immich-warning': 'rgb(var(--immich-warning) / <alpha-value>)',
|
||||
'immich-primary': '#4250af',
|
||||
'immich-bg': 'white',
|
||||
'immich-fg': 'black',
|
||||
'immich-gray': '#F6F6F4',
|
||||
'immich-error': '#e57373',
|
||||
'immich-success': '#81c784',
|
||||
'immich-warning': '#ffb74d',
|
||||
|
||||
// Dark Theme
|
||||
'immich-dark-primary': 'rgb(var(--immich-dark-primary) / <alpha-value>)',
|
||||
'immich-dark-bg': 'rgb(var(--immich-dark-bg) / <alpha-value>)',
|
||||
'immich-dark-fg': 'rgb(var(--immich-dark-fg) / <alpha-value>)',
|
||||
'immich-dark-gray': 'rgb(var(--immich-dark-gray) / <alpha-value>)',
|
||||
'immich-dark-error': 'rgb(var(--immich-dark-error) / <alpha-value>)',
|
||||
'immich-dark-success': 'rgb(var(--immich-dark-success) / <alpha-value>)',
|
||||
'immich-dark-warning': 'rgb(var(--immich-dark-warning) / <alpha-value>)',
|
||||
'immich-dark-primary': '#adcbfa',
|
||||
'immich-dark-bg': 'black',
|
||||
'immich-dark-fg': '#e5e7eb',
|
||||
'immich-dark-gray': '#212121',
|
||||
'immich-dark-error': '#d32f2f',
|
||||
'immich-dark-success': '#388e3c',
|
||||
'immich-dark-warning': '#f57c00',
|
||||
},
|
||||
fontFamily: {
|
||||
'immich-title': ['Snowburst One', 'cursive'],
|
||||
|
||||
Reference in New Issue
Block a user