diff --git a/deployment/mise.toml b/deployment/mise.toml
index 00c6826343..61a50bb666 100644
--- a/deployment/mise.toml
+++ b/deployment/mise.toml
@@ -1,5 +1,5 @@
[tools]
-terragrunt = "1.0.1"
+terragrunt = "1.0.2"
opentofu = "1.11.6"
[tasks."tg:fmt"]
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index 979d7fc0ee..25751879f3 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
- image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
+ image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md
index 84681fdfa6..aa19e28cc1 100644
--- a/docs/docs/administration/postgres-standalone.md
+++ b/docs/docs/administration/postgres-standalone.md
@@ -81,7 +81,7 @@ VectorChord is the successor extension to pgvecto.rs, allowing for higher perfor
### Migrating from pgvecto.rs
-Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so.
+Support for pgvecto.rs has been dropped as of 3.0, hence all users currently using pgvecto.rs should migrate to VectorChord. There are two primary approaches to do so.
The easiest option is to have both extensions installed during the migration:
diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md
index 518b003c3a..09e34c5107 100644
--- a/docs/docs/guides/remote-access.md
+++ b/docs/docs/guides/remote-access.md
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
### Cons
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
-- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
+- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
- Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index b29c233153..1b67637ac0 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -81,7 +81,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_PASSWORD` | Database password | `postgres` | server, database\*1 |
| `DB_DATABASE_NAME` | Database name | `immich` | server, database\*1 |
| `DB_SSL_MODE` | Database SSL mode | | server |
-| `DB_VECTOR_EXTENSION`\*2 | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
+| `DB_VECTOR_EXTENSION`\*2 | Database vector extension (one of [`vectorchord`, `pgvector`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])\*3 | `SSD` | database |
diff --git a/docs/docs/install/upgrading.md b/docs/docs/install/upgrading.md
index 12e5c9c342..38fc056f80 100644
--- a/docs/docs/install/upgrading.md
+++ b/docs/docs/install/upgrading.md
@@ -130,7 +130,3 @@ These storage mediums have different performance characteristics. As a result, t
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
It’s a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
-
-#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
-
-VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts
index 3d7971d6f0..2ec7aecb0e 100644
--- a/e2e/src/responses.ts
+++ b/e2e/src/responses.ts
@@ -2,82 +2,43 @@ import { expect } from 'vitest';
export const errorDto = {
unauthorized: {
- error: 'Unauthorized',
- statusCode: 401,
message: 'Authentication required',
- correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
- error: 'Unauthorized',
- statusCode: 401,
message,
- correlationId: expect.any(String),
}),
forbidden: {
- error: 'Forbidden',
- statusCode: 403,
message: expect.any(String),
- correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
- error: 'Forbidden',
- statusCode: 403,
message: `Missing required permission: ${permission}`,
- correlationId: expect.any(String),
}),
wrongPassword: {
- error: 'Bad Request',
- statusCode: 400,
message: 'Wrong password',
- correlationId: expect.any(String),
},
invalidToken: {
- error: 'Unauthorized',
- statusCode: 401,
message: 'Invalid user token',
- correlationId: expect.any(String),
},
invalidShareKey: {
- error: 'Unauthorized',
- statusCode: 401,
message: 'Invalid share key',
- correlationId: expect.any(String),
},
passwordRequired: {
- error: 'Unauthorized',
- statusCode: 401,
message: 'Password required',
- correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({
- error: 'Bad Request',
- statusCode: 400,
message: message ?? expect.anything(),
- correlationId: expect.any(String),
}),
noPermission: {
- error: 'Bad Request',
- statusCode: 400,
message: expect.stringContaining('Not found or no'),
- correlationId: expect.any(String),
},
incorrectLogin: {
- error: 'Unauthorized',
- statusCode: 401,
message: 'Incorrect email or password',
- correlationId: expect.any(String),
},
alreadyHasAdmin: {
- error: 'Bad Request',
- statusCode: 400,
message: 'The server already has an admin',
- correlationId: expect.any(String),
},
invalidEmail: {
- error: 'Bad Request',
- statusCode: 400,
message: ['email must be an email'],
- correlationId: expect.any(String),
},
};
diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts
index 9dcb431a4b..157fdfc84c 100644
--- a/e2e/src/specs/server/api/oauth.e2e-spec.ts
+++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts
@@ -332,9 +332,7 @@ describe(`/oauth`, () => {
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(500);
expect(body).toMatchObject({
- error: 'Internal Server Error',
message: 'Failed to finish oauth',
- statusCode: 500,
});
});
@@ -495,11 +493,10 @@ describe(`/oauth`, () => {
});
it('should reject OAuth discovery over HTTP', async () => {
- const { status, body } = await request(app)
+ const { status } = await request(app)
.post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(500);
- expect(body).toMatchObject({ statusCode: 500 });
});
});
});
diff --git a/e2e/src/ui/generators/timeline/model-objects.ts b/e2e/src/ui/generators/timeline/model-objects.ts
index e300de1161..f5654afd5e 100644
--- a/e2e/src/ui/generators/timeline/model-objects.ts
+++ b/e2e/src/ui/generators/timeline/model-objects.ts
@@ -32,8 +32,12 @@ export function generateThumbhash(rng: SeededRandom): string {
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
}
-export function generateDuration(rng: SeededRandom): string {
- return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
+export function generateDuration(rng: SeededRandom): number {
+ return (
+ rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) *
+ 1000 +
+ rng.nextInt(0, 1000)
+ );
}
export function generateUUID(): string {
diff --git a/e2e/src/ui/generators/timeline/timeline-config.ts b/e2e/src/ui/generators/timeline/timeline-config.ts
index 992480eef9..4dea2f4f78 100644
--- a/e2e/src/ui/generators/timeline/timeline-config.ts
+++ b/e2e/src/ui/generators/timeline/timeline-config.ts
@@ -43,7 +43,7 @@ export type MockTimelineAsset = {
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
- duration: string | null;
+ duration: number | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile
index 8126ff0859..46c32f3d6a 100644
--- a/machine-learning/Dockerfile
+++ b/machine-learning/Dockerfile
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
- wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
- wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
- wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
+ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
+ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
+ wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
- wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
+ wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
diff --git a/machine-learning/immich_ml/main.py b/machine-learning/immich_ml/main.py
index e7e3a719bb..4fca7a2e2b 100644
--- a/machine-learning/immich_ml/main.py
+++ b/machine-learning/immich_ml/main.py
@@ -183,7 +183,10 @@ async def predict(
text: str | None = Form(default=None),
) -> Any:
if image is not None:
- inputs: Image | str = await run(lambda: decode_pil(image))
+ decoded = await run(lambda: decode_pil(image))
+ if decoded.width == 0 or decoded.height == 0:
+ raise HTTPException(400, "Image has zero width or height")
+ inputs: Image | str = decoded
elif text is not None:
inputs = text
else:
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index 640996f54a..d61df51e38 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
- "huggingface-hub>=0.20.1,<1.0",
+ "huggingface-hub>=1.0,<2.0",
"insightface>=0.7.3,<1.0",
"numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
- "pillow>=12.2,<12.3",
+ "pillow>=12.2,<13",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",
diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py
index 0182c57c67..cce334e40e 100644
--- a/machine-learning/test_main.py
+++ b/machine-learning/test_main.py
@@ -1198,6 +1198,19 @@ class TestLoad:
mock_model.model_format = ModelFormat.ONNX
+@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
+def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
+ with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
+ response = deployed_app.post(
+ "http://localhost:3003/predict",
+ data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
+ files={"image": b"fake image bytes"},
+ )
+
+ assert response.status_code == 400
+ assert "zero" in response.json()["detail"].lower()
+
+
def test_root_endpoint(deployed_app: TestClient) -> None:
response = deployed_app.get("http://localhost:3003")
diff --git a/mise.toml b/mise.toml
index 367eb75da9..b7398a0e8d 100644
--- a/mise.toml
+++ b/mise.toml
@@ -15,9 +15,9 @@ config_roots = [
[tools]
node = "24.15.0"
-flutter = "3.41.6"
-pnpm = "10.33.0"
-terragrunt = "1.0.1"
+flutter = "3.41.7"
+pnpm = "10.33.1"
+terragrunt = "1.0.2"
opentofu = "1.11.6"
java = "21.0.2"
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index b9cd68cb7b..fbffa69ba5 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -751,7 +751,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -760,7 +760,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
- PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile;
+ PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile;
PRODUCT_NAME = "Immich-Profile";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -895,7 +895,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -904,7 +904,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
- PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug;
+ PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug;
PRODUCT_NAME = "Immich-Debug";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -925,7 +925,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -958,7 +958,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -975,7 +975,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget;
+ PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.Widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1001,7 +1001,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1041,7 +1041,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1057,7 +1057,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget;
+ PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.Widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1081,7 +1081,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1098,7 +1098,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1125,7 +1125,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1166,7 +1166,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
- DEVELOPMENT_TEAM = 2F67MQ8R79;
+ DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1182,7 +1182,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
diff --git a/mobile/ios/ShareExtension/Info.plist b/mobile/ios/ShareExtension/Info.plist
index 0f52fbffdf..dbed75e380 100644
--- a/mobile/ios/ShareExtension/Info.plist
+++ b/mobile/ios/ShareExtension/Info.plist
@@ -1,35 +1,35 @@
-
- AppGroupId
- $(CUSTOM_GROUP_ID)
- NSExtension
-
- NSExtensionAttributes
-
- IntentsSupported
-
- INSendMessageIntent
-
- NSExtensionActivationRule
- SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
+
+ AppGroupId
+ $(CUSTOM_GROUP_ID)
+ NSExtension
+
+ NSExtensionAttributes
+
+ IntentsSupported
+
+ INSendMessageIntent
+
+ NSExtensionActivationRule
+ SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0
).@count > 0
- PHSupportedMediaTypes
-
- Video
- Image
-
-
- NSExtensionMainStoryboard
- MainInterface
- NSExtensionPointIdentifier
- com.apple.share-services
-
-
-
\ No newline at end of file
+ PHSupportedMediaTypes
+
+ Video
+ Image
+
+
+ NSExtensionMainStoryboard
+ MainInterface
+ NSExtensionPointIdentifier
+ com.apple.share-services
+
+
+
diff --git a/mobile/ios/ShareExtension/ShareExtension.entitlements b/mobile/ios/ShareExtension/ShareExtension.entitlements
index 4ad1a257d8..d16dcca065 100644
--- a/mobile/ios/ShareExtension/ShareExtension.entitlements
+++ b/mobile/ios/ShareExtension/ShareExtension.entitlements
@@ -1,10 +1,10 @@
-
- com.apple.security.application-groups
-
- group.app.immich.share
-
-
-
+
+ com.apple.security.application-groups
+
+ group.app.immich.share
+
+
+
\ No newline at end of file
diff --git a/mobile/ios/WidgetExtension/WidgetExtension.entitlements b/mobile/ios/WidgetExtension/WidgetExtension.entitlements
index 4ad1a257d8..d16dcca065 100644
--- a/mobile/ios/WidgetExtension/WidgetExtension.entitlements
+++ b/mobile/ios/WidgetExtension/WidgetExtension.entitlements
@@ -1,10 +1,10 @@
-
- com.apple.security.application-groups
-
- group.app.immich.share
-
-
-
+
+ com.apple.security.application-groups
+
+ group.app.immich.share
+
+
+
\ No newline at end of file
diff --git a/mobile/ios/fastlane/Appfile b/mobile/ios/fastlane/Appfile
index e233ba2dcc..77318e3603 100644
--- a/mobile/ios/fastlane/Appfile
+++ b/mobile/ios/fastlane/Appfile
@@ -1,5 +1,5 @@
app_identifier "app.alextran.immich" # The bundle identifier of your app
-apple_id "alex.tran1502@gmail.com" # Your Apple email address
+apple_id "altran@futo.org" # Your Apple email address
# For more information about the Appfile, see:
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index 9c31ced00d..ff9fc4580f 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -17,10 +17,11 @@ default_platform(:ios)
platform :ios do
# Constants
- TEAM_ID = "2F67MQ8R79"
- CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
+ TEAM_ID = "2W7AC6T8T5"
+ CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich"
-
+ DEV_BUNDLE_ID = "tech.futo.immich.testflight"
+
# Helper method to get App Store Connect API key
def get_api_key
app_store_connect_api_key(
@@ -44,47 +45,45 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
- def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
- bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
-
+ def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
# Runner (main app)
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
- bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
+ bundle_identifier: base_bundle_id,
profile_name: profile_name_main,
targets: ["Runner"]
)
-
+
# ShareExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
- bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
+ bundle_identifier: "#{base_bundle_id}.ShareExtension",
profile_name: profile_name_share,
targets: ["ShareExtension"]
)
-
+
# WidgetExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
- bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
+ bundle_identifier: "#{base_bundle_id}.Widget",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
-
+
# Helper method to build and upload to TestFlight
def build_and_upload(
api_key:,
- bundle_id_suffix: "",
+ base_bundle_id:,
configuration: "Release",
distribute_external: true,
version_number: nil,
@@ -92,9 +91,8 @@ end
profile_name_share:,
profile_name_widget:
)
- bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
- app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
-
+ app_identifier = base_bundle_id
+
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
@@ -138,31 +136,31 @@ end
desc "iOS Development Build to TestFlight (requires separate bundle ID)"
lane :gha_testflight_dev do
api_key = get_api_key
-
+
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call
- sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
+ sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
-
- sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
+
+ sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
-
- sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
+
+ sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
-
+
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
- bundle_id_suffix: "development",
+ base_bundle_id: DEV_BUNDLE_ID,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
-
+
# Build and upload
build_and_upload(
api_key: api_key,
- bundle_id_suffix: "development",
+ base_bundle_id: DEV_BUNDLE_ID,
configuration: "Profile",
distribute_external: false,
profile_name_main: main_profile_name,
@@ -189,6 +187,7 @@ end
# Configure code signing for production bundle IDs
configure_code_signing(
+ base_bundle_id: BASE_BUNDLE_ID,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
@@ -197,6 +196,7 @@ end
# Build and upload with version number
build_and_upload(
api_key: api_key,
+ base_bundle_id: BASE_BUNDLE_ID,
version_number: get_version_from_pubspec,
distribute_external: false,
profile_name_main: main_profile_name,
@@ -243,30 +243,30 @@ end
desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do
- # Use the same build process as production, just skip the upload
- # This ensures PR builds validate the same way as production builds
-
+ # Use the same build process as the dev TestFlight lane, just skip the upload
+ # This ensures PR builds validate the same way as dev TestFlight builds
+
api_key = get_api_key
-
+
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
- sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
+ sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
-
- sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
+
+ sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
-
- sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
+
+ sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
-
+
# Configure code signing for dev bundle IDs
configure_code_signing(
- bundle_id_suffix: "development",
+ base_bundle_id: DEV_BUNDLE_ID,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
-
+
# Build the app (same as gha_testflight_dev but without upload)
build_app(
scheme: "Runner",
@@ -277,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
- "#{BASE_BUNDLE_ID}.development" => main_profile_name,
- "#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
- "#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
+ DEV_BUNDLE_ID => main_profile_name,
+ "#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
+ "#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index 5cdca97314..51999a69d5 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -3,6 +3,7 @@
import 'dart:async';
import 'dart:convert';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -192,17 +193,22 @@ class SyncStreamService {
final remoteSyncAssets = data.cast();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
- final hasPermission = await _localFilesManager.hasManageMediaPermission();
- if (hasPermission) {
- await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
- await _applyRemoteRestoreToLocal();
- } else {
- _logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
- }
+ await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
+ }
+ return;
+ case SyncEntityType.assetV2:
+ final remoteSyncAssets = data.cast();
+ await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
+ if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
+ await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetDeleteV1:
- return _syncStreamRepository.deleteAssetsV1(data.cast());
+ final remoteSyncAssets = data.cast();
+ if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
+ await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
+ }
+ return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetEditV1:
@@ -215,8 +221,12 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
+ case SyncEntityType.partnerAssetV2:
+ return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill');
+ case SyncEntityType.partnerAssetBackfillV2:
+ return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.partnerAssetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner");
case SyncEntityType.partnerAssetExifV1:
@@ -237,10 +247,16 @@ class SyncStreamService {
return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
case SyncEntityType.albumAssetCreateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create');
+ case SyncEntityType.albumAssetCreateV2:
+ return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset create');
case SyncEntityType.albumAssetUpdateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update');
+ case SyncEntityType.albumAssetUpdateV2:
+ return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset update');
case SyncEntityType.albumAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill');
+ case SyncEntityType.albumAssetBackfillV2:
+ return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset backfill');
case SyncEntityType.albumAssetExifCreateV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create');
case SyncEntityType.albumAssetExifUpdateV1:
@@ -346,6 +362,47 @@ class SyncStreamService {
}
}
+ Future handleWsAssetUploadReadyV2Batch(List batchData) async {
+ if (batchData.isEmpty) return;
+
+ _logger.info('Processing batch of ${batchData.length} AssetUploadReadyV2 events');
+
+ final List assets = [];
+ final List exifs = [];
+
+ try {
+ for (final data in batchData) {
+ if (data is! Map) {
+ continue;
+ }
+
+ final payload = data;
+ final assetData = payload['asset'];
+ final exifData = payload['exif'];
+
+ if (assetData == null || exifData == null) {
+ continue;
+ }
+
+ final asset = SyncAssetV2.fromJson(assetData);
+ final exif = SyncAssetExifV1.fromJson(exifData);
+
+ if (asset != null && exif != null) {
+ assets.add(asset);
+ exifs.add(exif);
+ }
+ }
+
+ if (assets.isNotEmpty && exifs.isNotEmpty) {
+ await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
+ await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
+ _logger.info('Successfully processed ${assets.length} assets in batch');
+ }
+ } catch (error, stackTrace) {
+ _logger.severe("Error processing AssetUploadReadyV2 websocket batch events", error, stackTrace);
+ }
+ }
+
Future handleWsAssetEditReadyV1(dynamic data) async {
_logger.info('Processing AssetEditReadyV1 event');
@@ -386,28 +443,67 @@ class SyncStreamService {
}
}
- Future _handleRemoteTrashed(Iterable checksums) async {
- if (checksums.isEmpty) {
+ Future handleWsAssetEditReadyV2(dynamic data) async {
+ _logger.info('Processing AssetEditReadyV2 event');
+
+ try {
+ if (data is! Map) {
+ throw ArgumentError("Invalid data format for AssetEditReadyV2 event");
+ }
+
+ final payload = data;
+
+ if (payload['asset'] == null) {
+ throw ArgumentError("Missing 'asset' field in AssetEditReadyV2 event data");
+ }
+
+ final asset = SyncAssetV2.fromJson(payload['asset']);
+ if (asset == null) {
+ throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV2 event data");
+ }
+
+ final assetEdits = (payload['edit'] as List)
+ .map((e) => SyncAssetEditV1.fromJson(e))
+ .whereType()
+ .toList();
+
+ await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
+ await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
+
+ _logger.info(
+ 'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits',
+ );
+ } catch (error, stackTrace) {
+ _logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
+ }
+ }
+
+ Future _handleRemoteDeleted(Iterable remoteIds) async {
+ if (remoteIds.isEmpty) {
return Future.value();
} else {
- final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
+ final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
if (localAssetsToTrash.isNotEmpty) {
- final mediaUrls = await Future.wait(
- localAssetsToTrash.values
- .expand((e) => e)
- .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
- );
- _logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
- final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
- if (result) {
- await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
- }
+ await _trashLocalAssets(localAssetsToTrash);
} else {
- _logger.info("No assets found in backup-enabled albums for assets: $checksums");
+ _logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
}
}
}
+ Future _trashLocalAssets(Map> localAssetsToTrash) async {
+ final mediaUrls = await Future.wait(
+ localAssetsToTrash.values
+ .expand((e) => e)
+ .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
+ );
+ _logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
+ final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
+ if (result) {
+ await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
+ }
+ }
+
Future _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
@@ -417,4 +513,23 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration");
}
}
+
+ Future _syncAssetTrashStatus(List remoteIds) async {
+ if (!(await _localFilesManager.hasManageMediaPermission())) {
+ _logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
+ return;
+ }
+
+ await _handleRemoteDeleted(remoteIds);
+ await _applyRemoteRestoreToLocal();
+ }
+
+ Future _syncAssetDeletion(List remoteIds) async {
+ if (!(await _localFilesManager.hasManageMediaPermission())) {
+ _logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
+ return;
+ }
+
+ await _handleRemoteDeleted(remoteIds);
+ }
}
diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart
index 7c9b6ae061..030e77cd54 100644
--- a/mobile/lib/domain/utils/background_sync.dart
+++ b/mobile/lib/domain/utils/background_sync.dart
@@ -186,7 +186,7 @@ class BackgroundSyncManager {
});
}
- Future syncWebsocketBatch(List batchData) {
+ Future syncWebsocketBatchV1(List batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
@@ -196,7 +196,17 @@ class BackgroundSyncManager {
});
}
- Future syncWebsocketEdit(dynamic data) {
+ Future syncWebsocketBatchV2(List batchData) {
+ if (_syncWebsocketTask != null) {
+ return _syncWebsocketTask!.future;
+ }
+ _syncWebsocketTask = _handleWsAssetUploadReadyV2Batch(batchData);
+ return _syncWebsocketTask!.whenComplete(() {
+ _syncWebsocketTask = null;
+ });
+ }
+
+ Future syncWebsocketEditV1(dynamic data) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
@@ -206,6 +216,16 @@ class BackgroundSyncManager {
});
}
+ Future syncWebsocketEditV2(dynamic data) {
+ if (_syncWebsocketTask != null) {
+ return _syncWebsocketTask!.future;
+ }
+ _syncWebsocketTask = _handleWsAssetEditReadyV2(data);
+ return _syncWebsocketTask!.whenComplete(() {
+ _syncWebsocketTask = null;
+ });
+ }
+
Future syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future;
@@ -242,7 +262,17 @@ Cancelable _handleWsAssetUploadReadyV1Batch(List batchData) => ru
debugLabel: 'websocket-batch',
);
+Cancelable _handleWsAssetUploadReadyV2Batch(List batchData) => runInIsolateGentle(
+ computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV2Batch(batchData),
+ debugLabel: 'websocket-batch',
+);
+
Cancelable _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
debugLabel: 'websocket-edit',
);
+
+Cancelable _handleWsAssetEditReadyV2(dynamic data) => runInIsolateGentle(
+ computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV2(data),
+ debugLabel: 'websocket-edit',
+);
diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart
index 6bcc11f18d..3a994f9cb8 100644
--- a/mobile/lib/extensions/asset_extensions.dart
+++ b/mobile/lib/extensions/asset_extensions.dart
@@ -1,6 +1,5 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
-import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:openapi/api.dart' as api;
@@ -14,7 +13,7 @@ extension DTOToAsset on api.AssetResponseDto {
updatedAt: updatedAt,
ownerId: ownerId,
visibility: visibility.toAssetVisibility(),
- durationMs: duration?.toDuration()?.inMilliseconds ?? 0,
+ durationMs: duration,
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
@@ -36,7 +35,7 @@ extension DTOToAsset on api.AssetResponseDto {
updatedAt: updatedAt,
ownerId: ownerId,
visibility: visibility.toAssetVisibility(),
- durationMs: duration?.toDuration()?.inMilliseconds ?? 0,
+ durationMs: duration,
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart
index 6f6ef20aeb..c34d2c4697 100644
--- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart
+++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart
@@ -109,31 +109,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
- Future