Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions 28eb1bc13c chore: version v2.2.3 2025-11-04 03:14:34 +00:00
Brandon Wees 1e4779cf48 fix(mobile): ignore patch releases for app version alerts (#23565)
* fix(mobile): ignore patch releases for app version alerts

* chore: make difference type nullable to indicate when versions match

* chore: add error handling for semver parsing

* chore: tests
2025-11-03 21:09:32 -06:00
Sergey Katsubo 0647c22956 fix(mobile): handle empty original filename (#23469)
* Handle empty original filename

* Handle TypeError from photo_manager titleAsync

* More compact exception log
2025-11-03 21:09:18 -06:00
Alex b8087b4fa2 chore: ios prod build with correct argument, get version number from pubspec (#23554)
* chore: ios prod build with correct argument, get version number from pubspec

* Update mobile/ios/fastlane/Fastfile

Co-authored-by: bo0tzz <git@bo0tzz.me>

---------

Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-11-03 10:11:11 -06:00
Jonathan S d94cb9641b chore: correct hosted isar paths in fdroid_build_isar.sh (#23529)
This should hopefully unblock F-Droid builds, which are a few versions behind.

Based on the suggestion in https://github.com/immich-app/immich/pull/22757#issuecomment-3404516987
2025-11-03 08:35:56 -06:00
Daniel Dietzler 517c3e1d4c fix: exif gps parsing of malformed data (#23551)
* fix: exif gps parsing of malformed data

* chore: e2e test
2025-11-03 09:02:41 -05:00
Ben 619de2a5e4 fix(web): search bar accessibility (#23550)
* fix: always show search type when search bar is focused

* fix: indicate search type to screen reader users
2025-11-03 08:31:57 -05:00
Mert 79d0e3e1ed fix(ml): ocr inputs not resized correctly (#23541)
* fix resizing, use pillow

* unused import

* linting

* lanczos

* optimizations

fused operations

unused import
2025-11-03 07:21:30 +00:00
github-actions f5ff36a1f8 chore: version v2.2.2 2025-11-02 21:56:36 +00:00
Alex b5efc9c16e fix: passing secrets to trigger workflow (#23447)
* fix: passing secrets to trigger workflow

* pass secrets to workflow call
2025-11-02 15:54:35 -06:00
Alex 1036076b0d fix: disable prunning for more investigation (#23531) 2025-11-02 15:54:03 -06:00
29 changed files with 355 additions and 93 deletions
+24
View File
@@ -20,6 +20,30 @@ on:
required: true required: true
ANDROID_STORE_PASSWORD: ANDROID_STORE_PASSWORD:
required: true required: true
APP_STORE_CONNECT_API_KEY_ID:
required: true
APP_STORE_CONNECT_API_KEY_ISSUER_ID:
required: true
APP_STORE_CONNECT_API_KEY:
required: true
IOS_CERTIFICATE_P12:
required: true
IOS_CERTIFICATE_PASSWORD:
required: true
IOS_PROVISIONING_PROFILE:
required: true
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
FASTLANE_TEAM_ID:
required: true
pull_request: pull_request:
push: push:
branches: [main] branches: [main]
+14
View File
@@ -99,6 +99,20 @@ jobs:
ALIAS: ${{ secrets.ALIAS }} ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with: with:
ref: ${{ needs.bump_version.outputs.ref }} ref: ${{ needs.bump_version.outputs.ref }}
environment: production environment: production
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.99", "version": "2.2.101",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
+8
View File
@@ -1,4 +1,12 @@
[ [
{
"label": "v2.2.3",
"url": "https://docs.v2.2.3.archive.immich.app"
},
{
"label": "v2.2.2",
"url": "https://docs.v2.2.2.archive.immich.app"
},
{ {
"label": "v2.2.1", "label": "v2.2.1",
"url": "https://docs.v2.2.1.archive.immich.app" "url": "https://docs.v2.2.1.archive.immich.app"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.2.1", "version": "2.2.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+10
View File
@@ -1140,6 +1140,16 @@ describe('/asset', () => {
}, },
}, },
}, },
{
input: 'metadata/gps-position/empty_gps.jpg',
expected: {
type: AssetTypeEnum.Image,
exifInfo: {
latitude: null,
longitude: null,
},
},
},
]; ];
it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => { it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => {
@@ -1,8 +1,10 @@
from typing import Any from typing import Any
import cv2
import numpy as np import numpy as np
from numpy.typing import NDArray
from PIL import Image from PIL import Image
from rapidocr.ch_ppocr_det import TextDetector as RapidTextDetector from rapidocr.ch_ppocr_det.utils import DBPostProcess
from rapidocr.inference_engine.base import FileInfo, InferSession from rapidocr.inference_engine.base import FileInfo, InferSession
from rapidocr.utils import DownloadFile, DownloadFileInput from rapidocr.utils import DownloadFile, DownloadFileInput
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
@@ -10,11 +12,10 @@ from rapidocr.utils.typings import ModelType as RapidModelType
from immich_ml.config import log from immich_ml.config import log
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.models.transforms import decode_cv2
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
from immich_ml.sessions.ort import OrtSession from immich_ml.sessions.ort import OrtSession
from .schemas import OcrOptions, TextDetectionOutput from .schemas import TextDetectionOutput
class TextDetector(InferenceModel): class TextDetector(InferenceModel):
@@ -24,13 +25,20 @@ class TextDetector(InferenceModel):
def __init__(self, model_name: str, **model_kwargs: Any) -> None: def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX) super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
self.max_resolution = 736 self.max_resolution = 736
self.min_score = 0.5 self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
self.score_mode = "fast" self.std_inv = np.float32(1.0) / (np.array([0.5, 0.5, 0.5], dtype=np.float32) * 255.0)
self._empty: TextDetectionOutput = { self._empty: TextDetectionOutput = {
"image": np.empty(0, dtype=np.float32),
"boxes": np.empty(0, dtype=np.float32), "boxes": np.empty(0, dtype=np.float32),
"scores": np.empty(0, dtype=np.float32), "scores": np.empty(0, dtype=np.float32),
} }
self.postprocess = DBPostProcess(
thresh=0.3,
box_thresh=model_kwargs.get("minScore", 0.5),
max_candidates=1000,
unclip_ratio=1.6,
use_dilation=True,
score_mode="fast",
)
def _download(self) -> None: def _download(self) -> None:
model_info = InferSession.get_model_url( model_info = InferSession.get_model_url(
@@ -52,35 +60,65 @@ class TextDetector(InferenceModel):
def _load(self) -> ModelSession: def _load(self) -> ModelSession:
# TODO: support other runtime sessions # TODO: support other runtime sessions
session = OrtSession(self.model_path) return OrtSession(self.model_path)
self.model = RapidTextDetector(
OcrOptions(
session=session.session,
limit_side_len=self.max_resolution,
limit_type="min",
box_thresh=self.min_score,
score_mode=self.score_mode,
)
)
return session
def _predict(self, inputs: bytes | Image.Image) -> TextDetectionOutput: # partly adapted from RapidOCR
results = self.model(decode_cv2(inputs)) def _predict(self, inputs: Image.Image) -> TextDetectionOutput:
if results.boxes is None or results.scores is None or results.img is None: w, h = inputs.size
if w < 32 or h < 32:
return self._empty
out = self.session.run(None, {"x": self._transform(inputs)})[0]
boxes, scores = self.postprocess(out, (h, w))
if len(boxes) == 0:
return self._empty return self._empty
return { return {
"image": results.img, "boxes": self.sorted_boxes(boxes),
"boxes": np.array(results.boxes, dtype=np.float32), "scores": np.array(scores, dtype=np.float32),
"scores": np.array(results.scores, dtype=np.float32),
} }
# adapted from RapidOCR
def _transform(self, img: Image.Image) -> NDArray[np.float32]:
if img.height < img.width:
ratio = float(self.max_resolution) / img.height
else:
ratio = float(self.max_resolution) / img.width
resize_h = int(img.height * ratio)
resize_w = int(img.width * ratio)
resize_h = int(round(resize_h / 32) * 32)
resize_w = int(round(resize_w / 32) * 32)
resized_img = img.resize((int(resize_w), int(resize_h)), resample=Image.Resampling.LANCZOS)
img_np: NDArray[np.float32] = cv2.cvtColor(np.array(resized_img, dtype=np.float32), cv2.COLOR_RGB2BGR) # type: ignore
img_np -= self.mean
img_np *= self.std_inv
img_np = np.transpose(img_np, (2, 0, 1))
return np.expand_dims(img_np, axis=0)
def sorted_boxes(self, dt_boxes: NDArray[np.float32]) -> NDArray[np.float32]:
if len(dt_boxes) == 0:
return dt_boxes
# Sort by y, then identify lines, then sort by (line, x)
y_order = np.argsort(dt_boxes[:, 0, 1], kind="stable")
sorted_y = dt_boxes[y_order, 0, 1]
line_ids = np.empty(len(dt_boxes), dtype=np.int32)
line_ids[0] = 0
np.cumsum(np.abs(np.diff(sorted_y)) >= 10, out=line_ids[1:])
# Create composite sort key for final ordering
# Shift line_ids by large factor, add x for tie-breaking
sort_key = line_ids[y_order] * 1e6 + dt_boxes[y_order, 0, 0]
final_order = np.argsort(sort_key, kind="stable")
sorted_boxes: NDArray[np.float32] = dt_boxes[y_order[final_order]]
return sorted_boxes
def configure(self, **kwargs: Any) -> None: def configure(self, **kwargs: Any) -> None:
if (max_resolution := kwargs.get("maxResolution")) is not None: if (max_resolution := kwargs.get("maxResolution")) is not None:
self.max_resolution = max_resolution self.max_resolution = max_resolution
self.model.limit_side_len = max_resolution
if (min_score := kwargs.get("minScore")) is not None: if (min_score := kwargs.get("minScore")) is not None:
self.min_score = min_score self.postprocess.box_thresh = min_score
self.model.postprocess_op.box_thresh = min_score
if (score_mode := kwargs.get("scoreMode")) is not None: if (score_mode := kwargs.get("scoreMode")) is not None:
self.score_mode = score_mode self.postprocess.score_mode = score_mode
self.model.postprocess_op.score_mode = score_mode
@@ -1,9 +1,8 @@
from typing import Any from typing import Any
import cv2
import numpy as np import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
from PIL.Image import Image from PIL import Image
from rapidocr.ch_ppocr_rec import TextRecInput from rapidocr.ch_ppocr_rec import TextRecInput
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
from rapidocr.inference_engine.base import FileInfo, InferSession from rapidocr.inference_engine.base import FileInfo, InferSession
@@ -14,6 +13,7 @@ from rapidocr.utils.vis_res import VisRes
from immich_ml.config import log, settings from immich_ml.config import log, settings
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.models.transforms import pil_to_cv2
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
from immich_ml.sessions.ort import OrtSession from immich_ml.sessions.ort import OrtSession
@@ -65,17 +65,16 @@ class TextRecognizer(InferenceModel):
) )
return session return session
def _predict(self, _: Image, texts: TextDetectionOutput) -> TextRecognitionOutput: def _predict(self, img: Image.Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
boxes, img, box_scores = texts["boxes"], texts["image"], texts["scores"] boxes, box_scores = texts["boxes"], texts["scores"]
if boxes.shape[0] == 0: if boxes.shape[0] == 0:
return self._empty return self._empty
rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes))) rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes)))
if rec.txts is None: if rec.txts is None:
return self._empty return self._empty
height, width = img.shape[0:2] boxes[:, :, 0] /= img.width
boxes[:, :, 0] /= width boxes[:, :, 1] /= img.height
boxes[:, :, 1] /= height
text_scores = np.array(rec.scores) text_scores = np.array(rec.scores)
valid_text_score_idx = text_scores > self.min_score valid_text_score_idx = text_scores > self.min_score
@@ -87,7 +86,7 @@ class TextRecognizer(InferenceModel):
"textScore": text_scores[valid_text_score_idx], "textScore": text_scores[valid_text_score_idx],
} }
def get_crop_img_list(self, img: NDArray[np.float32], boxes: NDArray[np.float32]) -> list[NDArray[np.float32]]: def get_crop_img_list(self, img: Image.Image, boxes: NDArray[np.float32]) -> list[NDArray[np.uint8]]:
img_crop_width = np.maximum( img_crop_width = np.maximum(
np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1) np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1)
).astype(np.int32) ).astype(np.int32)
@@ -98,22 +97,55 @@ class TextRecognizer(InferenceModel):
pts_std[:, 1:3, 0] = img_crop_width[:, None] pts_std[:, 1:3, 0] = img_crop_width[:, None]
pts_std[:, 2:4, 1] = img_crop_height[:, None] pts_std[:, 2:4, 1] = img_crop_height[:, None]
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1).tolist() img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1)
imgs: list[NDArray[np.float32]] = [] all_coeffs = self._get_perspective_transform(pts_std, boxes)
for box, pts_std, dst_size in zip(list(boxes), list(pts_std), img_crop_sizes): imgs: list[NDArray[np.uint8]] = []
M = cv2.getPerspectiveTransform(box, pts_std) for coeffs, dst_size in zip(all_coeffs, img_crop_sizes):
dst_img: NDArray[np.float32] = cv2.warpPerspective( dst_img = img.transform(
img, size=tuple(dst_size),
M, method=Image.Transform.PERSPECTIVE,
dst_size, data=tuple(coeffs),
borderMode=cv2.BORDER_REPLICATE, resample=Image.Resampling.BICUBIC,
flags=cv2.INTER_CUBIC, )
) # type: ignore
dst_height, dst_width = dst_img.shape[0:2] dst_width, dst_height = dst_img.size
if dst_height * 1.0 / dst_width >= 1.5: if dst_height * 1.0 / dst_width >= 1.5:
dst_img = np.rot90(dst_img) dst_img = dst_img.rotate(90, expand=True)
imgs.append(dst_img) imgs.append(pil_to_cv2(dst_img))
return imgs return imgs
def _get_perspective_transform(self, src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.float32]:
N = src.shape[0]
x, y = src[:, :, 0], src[:, :, 1]
u, v = dst[:, :, 0], dst[:, :, 1]
A = np.zeros((N, 8, 9), dtype=np.float32)
# Fill even rows (0, 2, 4, 6): [x, y, 1, 0, 0, 0, -u*x, -u*y, -u]
A[:, ::2, 0] = x
A[:, ::2, 1] = y
A[:, ::2, 2] = 1
A[:, ::2, 6] = -u * x
A[:, ::2, 7] = -u * y
A[:, ::2, 8] = -u
# Fill odd rows (1, 3, 5, 7): [0, 0, 0, x, y, 1, -v*x, -v*y, -v]
A[:, 1::2, 3] = x
A[:, 1::2, 4] = y
A[:, 1::2, 5] = 1
A[:, 1::2, 6] = -v * x
A[:, 1::2, 7] = -v * y
A[:, 1::2, 8] = -v
# Solve using SVD for all matrices at once
_, _, Vt = np.linalg.svd(A)
H = Vt[:, -1, :].reshape(N, 3, 3)
H = H / H[:, 2:3, 2:3]
# Extract the 8 coefficients for each transformation
return np.column_stack(
[H[:, 0, 0], H[:, 0, 1], H[:, 0, 2], H[:, 1, 0], H[:, 1, 1], H[:, 1, 2], H[:, 2, 0], H[:, 2, 1]]
) # pyright: ignore[reportReturnType]
def configure(self, **kwargs: Any) -> None: def configure(self, **kwargs: Any) -> None:
self.min_score = kwargs.get("minScore", self.min_score) self.min_score = kwargs.get("minScore", self.min_score)
@@ -7,7 +7,6 @@ from typing_extensions import TypedDict
class TextDetectionOutput(TypedDict): class TextDetectionOutput(TypedDict):
image: npt.NDArray[np.float32]
boxes: npt.NDArray[np.float32] boxes: npt.NDArray[np.float32]
scores: npt.NDArray[np.float32] scores: npt.NDArray[np.float32]
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.2.1" version = "2.2.3"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0" requires-python = ">=3.10,<4.0"
-1
View File
@@ -88,7 +88,6 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
fi fi
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3024, "android.injected.version.code" => 3026,
"android.injected.version.name" => "2.2.1", "android.injected.version.name" => "2.2.3",
} }
) )
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') 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')
+15 -2
View File
@@ -32,6 +32,17 @@ platform :ios do
) )
end end
# Helper method to get version from pubspec.yaml
def get_version_from_pubspec
require 'yaml'
pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml")
pubspec = YAML.load_file(pubspec_path)
version_string = pubspec['version']
version_string ? version_string.split('+').first : nil
end
# Helper method to configure code signing for all targets # Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "") def configure_code_signing(bundle_id_suffix: "")
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
@@ -158,7 +169,8 @@ platform :ios do
# Build and upload with version number # Build and upload with version number
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
version_number: "2.1.0" version_number: get_version_from_pubspec,
distribute_external: false,
) )
end end
@@ -168,8 +180,9 @@ platform :ios do
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
targets: ["Runner", "ShareExtension", "WidgetExtension"] targets: ["Runner", "ShareExtension", "WidgetExtension"]
) )
increment_version_number( increment_version_number(
version_number: "2.2.1" version_number: get_version_from_pubspec
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
@@ -132,7 +132,8 @@ class SyncStreamService {
return; return;
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion // SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
case SyncEntityType.syncCompleteV1: case SyncEntityType.syncCompleteV1:
return _syncStreamRepository.pruneAssets(); return;
// return _syncStreamRepository.pruneAssets();
// Request to reset the client state. Clear everything related to remote entities // Request to reset the client state. Clear everything related to remote entities
case SyncEntityType.syncResetV1: case SyncEntityType.syncResetV1:
return _syncStreamRepository.reset(); return _syncStreamRepository.reset();
@@ -67,7 +67,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
return; return;
} }
if (clientVersion < serverVersion) { if (clientVersion < serverVersion && clientVersion.differenceType(serverVersion) != SemVerType.patch) {
state = state.copyWith(versionStatus: VersionStatus.clientOutOfDate); state = state.copyWith(versionStatus: VersionStatus.clientOutOfDate);
return; return;
} }
@@ -89,9 +89,16 @@ class AssetMediaRepository {
return null; return null;
} }
// titleAsync gets the correct original filename for some assets on iOS try {
// otherwise using the `entity.title` would return a random GUID // titleAsync gets the correct original filename for some assets on iOS
return await entity.titleAsync; // otherwise using the `entity.title` would return a random GUID
final originalFilename = await entity.titleAsync;
// treat empty filename as missing
return originalFilename.isNotEmpty ? originalFilename : null;
} catch (e) {
_log.warning("Failed to get original filename for asset: $id. Error: $e");
return null;
}
} }
// TODO: make this more efficient // TODO: make this more efficient
+29 -1
View File
@@ -1,3 +1,5 @@
enum SemVerType { major, minor, patch }
class SemVer { class SemVer {
final int major; final int major;
final int minor; final int minor;
@@ -15,8 +17,20 @@ class SemVer {
} }
factory SemVer.fromString(String version) { factory SemVer.fromString(String version) {
if (version.toLowerCase().startsWith("v")) {
version = version.substring(1);
}
final parts = version.split("-")[0].split('.'); final parts = version.split("-")[0].split('.');
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); if (parts.length != 3) {
throw FormatException('Invalid semantic version string: $version');
}
try {
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
} catch (e) {
throw FormatException('Invalid semantic version string: $version');
}
} }
bool operator >(SemVer other) { bool operator >(SemVer other) {
@@ -54,6 +68,20 @@ class SemVer {
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch; return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
} }
SemVerType? differenceType(SemVer other) {
if (major != other.major) {
return SemVerType.major;
}
if (minor != other.minor) {
return SemVerType.minor;
}
if (patch != other.patch) {
return SemVerType.patch;
}
return null;
}
@override @override
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
} }
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.2.1 - API version: 2.2.3
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.2.1+3024 version: 2.2.3+3026
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
+5 -5
View File
@@ -8,11 +8,11 @@ bash tool/build_android.sh x64
bash tool/build_android.sh armv7 bash tool/build_android.sh armv7
bash tool/build_android.sh arm64 bash tool/build_android.sh arm64
mv libisar_android_arm64.so libisar.so mv libisar_android_arm64.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/ mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/
mv libisar_android_armv7.so libisar.so mv libisar_android_armv7.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/ mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/
mv libisar_android_x64.so libisar.so mv libisar_android_x64.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/ mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86_64/
mv libisar_android_x86.so libisar.so mv libisar_android_x86.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/ mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86/
) )
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
void main() {
group('SemVer', () {
test('Parses valid semantic version strings correctly', () {
final version = SemVer.fromString('1.2.3');
expect(version.major, 1);
expect(version.minor, 2);
expect(version.patch, 3);
});
test('Throws FormatException for invalid version strings', () {
expect(() => SemVer.fromString('1.2'), throwsFormatException);
expect(() => SemVer.fromString('a.b.c'), throwsFormatException);
expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException);
});
test('Compares equal versons correctly', () {
final v1 = SemVer.fromString('1.2.3');
final v2 = SemVer.fromString('1.2.3');
expect(v1 == v2, isTrue);
expect(v1 > v2, isFalse);
expect(v1 < v2, isFalse);
});
test('Compares major version correctly', () {
final v1 = SemVer.fromString('2.0.0');
final v2 = SemVer.fromString('1.9.9');
expect(v1 == v2, isFalse);
expect(v1 > v2, isTrue);
expect(v1 < v2, isFalse);
});
test('Compares minor version correctly', () {
final v1 = SemVer.fromString('1.3.0');
final v2 = SemVer.fromString('1.2.9');
expect(v1 == v2, isFalse);
expect(v1 > v2, isTrue);
expect(v1 < v2, isFalse);
});
test('Compares patch version correctly', () {
final v1 = SemVer.fromString('1.2.4');
final v2 = SemVer.fromString('1.2.3');
expect(v1 == v2, isFalse);
expect(v1 > v2, isTrue);
expect(v1 < v2, isFalse);
});
test('Gives correct major difference type', () {
final v1 = SemVer.fromString('2.0.0');
final v2 = SemVer.fromString('1.9.9');
expect(v1.differenceType(v2), SemVerType.major);
});
test('Gives correct minor difference type', () {
final v1 = SemVer.fromString('1.3.0');
final v2 = SemVer.fromString('1.2.9');
expect(v1.differenceType(v2), SemVerType.minor);
});
test('Gives correct patch difference type', () {
final v1 = SemVer.fromString('1.2.4');
final v2 = SemVer.fromString('1.2.3');
expect(v1.differenceType(v2), SemVerType.patch);
});
test('Gives null difference type for equal versions', () {
final v1 = SemVer.fromString('1.2.3');
final v2 = SemVer.fromString('1.2.3');
expect(v1.differenceType(v2), isNull);
});
test('toString returns correct format', () {
final version = SemVer.fromString('1.2.3');
expect(version.toString(), '1.2.3');
});
test('Parses versions with leading v correctly', () {
final version1 = SemVer.fromString('v1.2.3');
expect(version1.major, 1);
expect(version1.minor, 2);
expect(version1.patch, 3);
final version2 = SemVer.fromString('V1.2.3');
expect(version2.major, 1);
expect(version2.minor, 2);
expect(version2.patch, 3);
});
});
}
+1 -1
View File
@@ -10006,7 +10006,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "2.2.1", "version": "2.2.3",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.2.1", "version": "2.2.3",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 2.2.1 * 2.2.3
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "2.2.1", "version": "2.2.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+6 -8
View File
@@ -236,8 +236,8 @@ export class MetadataService extends BaseService {
latitude: number | null = null, latitude: number | null = null,
longitude: number | null = null; longitude: number | null = null;
if (this.hasGeo(exifTags)) { if (this.hasGeo(exifTags)) {
latitude = exifTags.GPSLatitude; latitude = Number(exifTags.GPSLatitude);
longitude = exifTags.GPSLongitude; longitude = Number(exifTags.GPSLongitude);
if (reverseGeocoding.enabled) { if (reverseGeocoding.enabled) {
geo = await this.mapRepository.reverseGeocode({ latitude, longitude }); geo = await this.mapRepository.reverseGeocode({ latitude, longitude });
} }
@@ -894,12 +894,10 @@ export class MetadataService extends BaseService {
}; };
} }
private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } { private hasGeo(tags: ImmichTags) {
return ( const lat = Number(tags.GPSLatitude);
tags.GPSLatitude !== undefined && const lng = Number(tags.GPSLongitude);
tags.GPSLongitude !== undefined && return !Number.isNaN(lat) && !Number.isNaN(lng) && (lat !== 0 || lng !== 0);
(tags.GPSLatitude !== 0 || tags.GPSLatitude !== 0)
);
} }
private getAutoStackId(tags: ImmichTags | null): string | null { private getAutoStackId(tags: ImmichTags | null): string | null {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "2.2.1", "version": "2.2.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -30,10 +30,10 @@
let showSuggestions = $state(false); let showSuggestions = $state(false);
let isSearchSuggestions = $state(false); let isSearchSuggestions = $state(false);
let selectedId: string | undefined = $state(); let selectedId: string | undefined = $state();
let isFocus = $state(false);
let close: (() => Promise<void>) | undefined; let close: (() => Promise<void>) | undefined;
const listboxId = generateId(); const listboxId = generateId();
const searchTypeId = generateId();
onDestroy(() => { onDestroy(() => {
searchStore.isSearchEnabled = false; searchStore.isSearchEnabled = false;
@@ -161,12 +161,10 @@
const openDropdown = () => { const openDropdown = () => {
showSuggestions = true; showSuggestions = true;
isFocus = true;
}; };
const closeDropdown = () => { const closeDropdown = () => {
showSuggestions = false; showSuggestions = false;
isFocus = false;
searchHistoryBox?.clearSelection(); searchHistoryBox?.clearSelection();
}; };
@@ -251,6 +249,7 @@
aria-activedescendant={selectedId ?? ''} aria-activedescendant={selectedId ?? ''}
aria-expanded={showSuggestions && isSearchSuggestions} aria-expanded={showSuggestions && isSearchSuggestions}
aria-autocomplete="list" aria-autocomplete="list"
aria-describedby={searchTypeId}
use:shortcuts={[ use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
@@ -287,12 +286,12 @@
/> />
</div> </div>
{#if isFocus} {#if searchStore.isSearchEnabled}
<div <div
class="absolute inset-y-0 flex items-center" id={searchTypeId}
class="absolute inset-y-0 flex items-center end-16"
class:max-md:hidden={value} class:max-md:hidden={value}
class:end-16={isFocus} class:end-28={value.length > 0}
class:end-28={isFocus && value.length > 0}
> >
<p <p
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs" class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"