From 29e7ea5302bc3f6ed1eb845706cc77cb34f40048 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Tue, 4 Aug 2026 01:40:08 +0530
Subject: [PATCH 01/89] chore(web): use FUTO F-Droid repo in utilities (#30527)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
web/src/lib/modals/AppDownloadModal.svelte | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte
index 84d7630909..01a998bf5c 100644
--- a/web/src/lib/modals/AppDownloadModal.svelte
+++ b/web/src/lib/modals/AppDownloadModal.svelte
@@ -1,5 +1,5 @@
diff --git a/web/src/lib/modals/timezone-utils.ts b/web/src/lib/modals/timezone-utils.ts
index 2dfbb3fd5f..ae86e46321 100644
--- a/web/src/lib/modals/timezone-utils.ts
+++ b/web/src/lib/modals/timezone-utils.ts
@@ -95,7 +95,7 @@ function zoneOptionForDate(zone: string, date: string) {
function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
const offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes;
- if (offsetDifference != 0) {
+ if (offsetDifference !== 0) {
return offsetDifference;
}
return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
diff --git a/web/src/lib/stores/upload.ts b/web/src/lib/stores/upload.ts
index 04a8a45bee..21929ab1e4 100644
--- a/web/src/lib/stores/upload.ts
+++ b/web/src/lib/stores/upload.ts
@@ -67,7 +67,7 @@ function createUploadStore() {
const updateAssetMap = (id: string, mapper: (assets: UploadAsset) => UploadAsset) => {
uploadAssets.update((uploadingAssets) => {
return uploadingAssets.map((asset) => {
- if (asset.id == id) {
+ if (asset.id === id) {
return mapper(asset);
}
return asset;
@@ -111,7 +111,7 @@ function createUploadStore() {
});
}
- return uploadingAsset.filter((a) => a.id != id);
+ return uploadingAsset.filter((a) => a.id !== id);
});
};
diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts
index 3624b34f93..f5733fd5b2 100644
--- a/web/src/lib/utils/actions.ts
+++ b/web/src/lib/utils/actions.ts
@@ -68,7 +68,7 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
* @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete.
*/
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
- if (stack == undefined) {
+ if (stack === undefined) {
return;
}
diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts
index 86b221906f..53a52ddcf9 100644
--- a/web/src/lib/utils/duplicate-utils.ts
+++ b/web/src/lib/utils/duplicate-utils.ts
@@ -147,6 +147,7 @@ const metadataFields = [
titleKey: 'gps',
keys: ['latitude', 'longitude'],
render: (asset, $t) =>
+ // eslint-disable-next-line eqeqeq
asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null
? `${asset.exifInfo.latitude.toFixed(4)}, ${asset.exifInfo.longitude.toFixed(4)}`
: $t('unknown'),
@@ -173,18 +174,21 @@ const metadataFields = [
icon: mdiCameraIris,
titleKey: 'f_number',
keys: ['fNumber'],
+ // eslint-disable-next-line eqeqeq
render: (asset, $t) => (asset.exifInfo?.fNumber == null ? $t('unknown') : `f/${asset.exifInfo.fNumber.toFixed(1)}`),
},
{
icon: mdiRayStartArrow,
titleKey: 'focal_length',
keys: ['focalLength'],
+ // eslint-disable-next-line eqeqeq
render: (asset, $t) => (asset.exifInfo?.focalLength == null ? $t('unknown') : `${asset.exifInfo.focalLength} mm`),
},
{
icon: mdiBrightness6,
titleKey: 'iso',
keys: ['iso'],
+ // eslint-disable-next-line eqeqeq
render: (asset, $t) => (asset.exifInfo?.iso == null ? $t('unknown') : `ISO ${asset.exifInfo.iso}`),
},
{
@@ -203,6 +207,7 @@ const metadataFields = [
icon: mdiStarOutline,
titleKey: 'rating',
keys: ['rating'],
+ // eslint-disable-next-line eqeqeq
render: (asset, $t) => (asset.exifInfo?.rating == null ? $t('unknown') : `${asset.exifInfo.rating} stars`),
},
{
diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts
index 91513911f1..2f88da1f43 100644
--- a/web/src/lib/utils/navigation.ts
+++ b/web/src/lib/utils/navigation.ts
@@ -44,7 +44,7 @@ export function currentUrlReplaceAssetId(assetId: string) {
// always remove the assetGridScrollTargetParams
params.delete('at');
const paramsString = params.toString();
- const searchparams = paramsString == '' ? '' : '?' + params.toString();
+ const searchparams = paramsString === '' ? '' : '?' + params.toString();
// this contains special casing for the /photos/:assetId photos route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
return isPhotosRoute(page.route.id)
diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts
index 018d7d34ef..e1e717f45a 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -183,8 +183,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
isFavorite: assetResponse.isFavorite,
visibility: assetResponse.visibility,
isTrashed: assetResponse.isTrashed,
- isVideo: assetResponse.type == AssetTypeEnum.Video,
- isImage: assetResponse.type == AssetTypeEnum.Image,
+ isVideo: assetResponse.type === AssetTypeEnum.Video,
+ isImage: assetResponse.type === AssetTypeEnum.Image,
stack: assetResponse.stack || null,
duration: assetResponse.duration || null,
projectionType: assetResponse.exifInfo?.projectionType || null,
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte
index 733c1f556a..c1028ad658 100644
--- a/web/src/routes/(user)/people/+page.svelte
+++ b/web/src/routes/(user)/people/+page.svelte
@@ -222,7 +222,7 @@
const onNameChangeSubmit = async (name: string, targetPerson: PersonResponseDto) => {
try {
- if (name == targetPerson.name) {
+ if (name === targetPerson.name) {
return;
}
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 3fe707c6df..e4389f09dc 100644
--- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -98,7 +98,7 @@
} else if ($page.params.assetId) {
previousRoute = Route.viewPerson(data.person);
}
- if (action == 'merge') {
+ if (action === 'merge') {
viewMode = PersonPageViewMode.MERGE_PEOPLE;
}
@@ -180,7 +180,7 @@
const [, personToBeMergedInto] = result;
- if (personToBeMergedInto.name != personName && person.id === personToBeMergedInto.id) {
+ if (personToBeMergedInto.name !== personName && person.id === personToBeMergedInto.id) {
await updateAssetCount();
return { merged: true };
}
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
index e7a18f1d0f..6683dbfe2e 100644
--- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -201,7 +201,7 @@
personIds.map(async (personId) => {
const person = await getPerson({ id: personId });
- if (person.name == '') {
+ if (person.name === '') {
return $t('no_name');
}
diff --git a/web/src/routes/(user)/user-settings/AppSettings.svelte b/web/src/routes/(user)/user-settings/AppSettings.svelte
index ddfac02bfc..04c8b6af06 100644
--- a/web/src/routes/(user)/user-settings/AppSettings.svelte
+++ b/web/src/routes/(user)/user-settings/AppSettings.svelte
@@ -74,7 +74,7 @@
-
+
{selectedDate}
diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte
index f4c0a93655..338c463375 100644
--- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -38,7 +38,7 @@
};
const preAction = async (payload: Action) => {
- if (payload.type == 'trash') {
+ if (payload.type === 'trash') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
diff --git a/web/src/routes/admin/system-settings/JobSettings.svelte b/web/src/routes/admin/system-settings/JobSettings.svelte
index a43be5e9f4..c229a788b4 100644
--- a/web/src/routes/admin/system-settings/JobSettings.svelte
+++ b/web/src/routes/admin/system-settings/JobSettings.svelte
@@ -66,7 +66,7 @@
description=""
bind:value={configToEdit.job[queueName].concurrency}
required={true}
- isEdited={configToEdit.job[queueName].concurrency != config.job[queueName].concurrency}
+ isEdited={configToEdit.job[queueName].concurrency !== config.job[queueName].concurrency}
/>
{:else}
{
- if (nextStepIndex == -1) {
+ if (nextStepIndex === -1) {
if (authManager.user.isAdmin) {
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
await serverConfigManager.loadServerConfig();
diff --git a/web/src/routes/auth/onboarding/OnboardingHello.svelte b/web/src/routes/auth/onboarding/OnboardingHello.svelte
index 533de49f81..1b5b0d2e88 100644
--- a/web/src/routes/auth/onboarding/OnboardingHello.svelte
+++ b/web/src/routes/auth/onboarding/OnboardingHello.svelte
@@ -16,7 +16,7 @@
{$t('onboarding_welcome_user', { values: { user: authManager.user.name } })}
- {userRole == OnboardingRole.SERVER
+ {userRole === OnboardingRole.SERVER
? $t('onboarding_server_welcome_description')
: $t('onboarding_user_welcome_description')}
From c7d7889ed0101ff2f49e0365459e9943d1dcac08 Mon Sep 17 00:00:00 2001
From: bo0tzz
Date: Tue, 11 Aug 2026 20:41:40 +0200
Subject: [PATCH 59/89] fix: pass secrets to build-mobile (#29031)
---
.github/workflows/build-mobile.yml | 4 ++++
.github/workflows/prepare-release.yml | 2 ++
2 files changed, 6 insertions(+)
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index 602ffb3c18..b2ba244ab9 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -12,6 +12,10 @@ on:
default: 'development'
type: string
secrets:
+ PUSH_O_MATIC_APP_CLIENT_ID:
+ required: true
+ PUSH_O_MATIC_APP_KEY:
+ required: true
KEY_JKS:
required: true
ALIAS:
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index 98a3daa3b2..abc84c7cdb 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -102,6 +102,8 @@ jobs:
contents: read
pull-requests: write
secrets:
+ PUSH_O_MATIC_APP_CLIENT_ID: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
+ PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
From 199723261c6ffa897fec8ccdaea6359e39c37cc3 Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Tue, 11 Aug 2026 23:05:18 +0200
Subject: [PATCH 60/89] fix: owner cascade delete album (#30692)
---
server/src/schema/functions.ts | 14 +++++++
server/src/schema/index.ts | 2 +
.../1786385711807-AlbumOwnerDeleteTrigger.ts | 31 ++++++++++++++++
server/src/schema/tables/album-user.table.ts | 3 +-
.../specs/services/album.service.spec.ts | 37 +++++++++++++++++++
5 files changed, 86 insertions(+), 1 deletion(-)
create mode 100644 server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts
create mode 100644 server/test/medium/specs/services/album.service.spec.ts
diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts
index 230f22478d..2ee61d82dd 100644
--- a/server/src/schema/functions.ts
+++ b/server/src/schema/functions.ts
@@ -133,6 +133,20 @@ export const album_asset_delete_audit = registerFunction({
END`,
});
+export const album_user_delete = registerFunction({
+ name: 'album_user_delete',
+ returnType: 'TRIGGER',
+ language: 'PLPGSQL',
+ body: `
+ BEGIN
+ DELETE FROM "album"
+ WHERE "album"."id" = OLD."albumId"
+ AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');
+
+ RETURN NULL;
+ END`,
+});
+
export const album_user_delete_audit = registerFunction({
name: 'album_user_delete_audit',
returnType: 'TRIGGER',
diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts
index 4e55ff4cf6..171fefb50f 100644
--- a/server/src/schema/index.ts
+++ b/server/src/schema/index.ts
@@ -7,6 +7,7 @@ import {
} from 'src/schema/enums';
import {
album_user_after_insert,
+ album_user_delete,
album_user_delete_audit,
asset_delete_audit,
asset_face_audit,
@@ -174,6 +175,7 @@ export class ImmichDatabase {
asset_metadata_audit,
asset_face_audit,
asset_ocr_delete_audit,
+ album_user_delete,
];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
diff --git a/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts
new file mode 100644
index 0000000000..082e43ff23
--- /dev/null
+++ b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts
@@ -0,0 +1,31 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await sql`DELETE FROM "album" WHERE NOT EXISTS (SELECT * FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');`.execute(db);
+ await sql`CREATE OR REPLACE FUNCTION album_user_delete()
+ RETURNS TRIGGER
+ LANGUAGE PLPGSQL
+ AS $$
+ BEGIN
+ DELETE FROM "album"
+ WHERE "album"."id" = OLD."albumId"
+ AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');
+
+ RETURN NULL;
+ END
+ $$;`.execute(db);
+ await sql`CREATE OR REPLACE TRIGGER "album_user_delete"
+ AFTER DELETE ON "album_user"
+ REFERENCING OLD TABLE AS "old"
+ FOR EACH ROW
+ EXECUTE FUNCTION album_user_delete();`.execute(db);
+ await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_delete', '{"type":"function","name":"album_user_delete","sql":"CREATE OR REPLACE FUNCTION album_user_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n DELETE FROM \\"album\\"\\n WHERE \\"album\\".\\"id\\" = OLD.\\"albumId\\"\\n AND NOT EXISTS (SELECT \\"albumId\\" FROM \\"album_user\\" WHERE \\"album_user\\".\\"albumId\\" = \\"album\\".\\"id\\" AND \\"album_user\\".\\"role\\" = ''owner'');\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
+ await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_delete', '{"type":"trigger","name":"album_user_delete","sql":"CREATE OR REPLACE TRIGGER \\"album_user_delete\\"\\n AFTER DELETE ON \\"album_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION album_user_delete();"}'::jsonb);`.execute(db);
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP TRIGGER "album_user_delete" ON "album_user";`.execute(db);
+ await sql`DROP FUNCTION album_user_delete;`.execute(db);
+ await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_delete';`.execute(db);
+ await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_delete';`.execute(db);
+}
diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts
index 677d6ca2f2..c015c1354d 100644
--- a/server/src/schema/tables/album-user.table.ts
+++ b/server/src/schema/tables/album-user.table.ts
@@ -13,7 +13,7 @@ import {
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
-import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
+import { album_user_after_insert, album_user_delete, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -38,6 +38,7 @@ import { UserTable } from 'src/schema/tables/user.table';
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
+@AfterDeleteTrigger({ scope: 'row', function: album_user_delete, referencingOldTableAs: 'old' })
export class AlbumUserTable {
@ForeignKeyColumn(() => AlbumTable, {
onDelete: 'CASCADE',
diff --git a/server/test/medium/specs/services/album.service.spec.ts b/server/test/medium/specs/services/album.service.spec.ts
new file mode 100644
index 0000000000..bc73007593
--- /dev/null
+++ b/server/test/medium/specs/services/album.service.spec.ts
@@ -0,0 +1,37 @@
+import { Kysely } from 'kysely';
+import { AlbumRepository } from 'src/repositories/album.repository';
+import { LoggingRepository } from 'src/repositories/logging.repository';
+import { UserRepository } from 'src/repositories/user.repository';
+import { DB } from 'src/schema';
+import { AlbumService } from 'src/services/album.service';
+import { newMediumService } from 'test/medium.factory';
+import { getKyselyDB } from 'test/utils';
+
+let defaultDatabase: Kysely;
+
+const setup = (db?: Kysely) => {
+ return newMediumService(AlbumService, {
+ database: db || defaultDatabase,
+ real: [AlbumRepository, UserRepository],
+ mock: [LoggingRepository],
+ });
+};
+
+beforeAll(async () => {
+ defaultDatabase = await getKyselyDB();
+});
+
+describe(AlbumService.name, () => {
+ describe('database triggers', () => {
+ it('should cascade delete an album when the owner is deleted', async () => {
+ const { ctx } = setup();
+ const { user } = await ctx.newUser();
+ await ctx.newAlbum({ ownerId: user.id });
+
+ await ctx.get(UserRepository).delete({ id: user.id }, true);
+
+ await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]);
+ await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]);
+ });
+ });
+});
From 0344c61e4cee9cb2e8ee3ce2292177cd2b0c4129 Mon Sep 17 00:00:00 2001
From: bo0tzz
Date: Wed, 12 Aug 2026 13:36:33 +0200
Subject: [PATCH 61/89] feat: use version service for docs archive switcher
(#30675)
---
docs/src/components/version-switcher.tsx | 9 +-
docs/static/_redirects | 1 +
docs/static/archived-versions.json | 265 ------------------
packages/scripts/src/commands/release.spec.ts | 78 +-----
packages/scripts/src/commands/release.ts | 52 ----
5 files changed, 6 insertions(+), 399 deletions(-)
delete mode 100644 docs/static/archived-versions.json
diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx
index 739d7bd001..967c85c780 100644
--- a/docs/src/components/version-switcher.tsx
+++ b/docs/src/components/version-switcher.tsx
@@ -2,6 +2,8 @@ import { useWindowSize } from '@docusaurus/theme-common';
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
import React, { useEffect, useState } from 'react';
+const ARCHIVED_VERSIONS_URL = 'https://version.immich.cloud/v1/docs/versions';
+
export default function VersionSwitcher(): JSX.Element {
const [versions, setVersions] = useState([]);
const [activeLabel, setLabel] = useState('Versions');
@@ -11,12 +13,7 @@ export default function VersionSwitcher(): JSX.Element {
useEffect(() => {
async function getVersions() {
try {
- let baseUrl = 'https://docs.immich.app';
- if (window.location.origin === 'http://localhost:3005') {
- baseUrl = window.location.origin;
- }
-
- const response = await fetch(`${baseUrl}/archived-versions.json`);
+ const response = await fetch(ARCHIVED_VERSIONS_URL);
const archiveVersions = await response.json();
diff --git a/docs/static/_redirects b/docs/static/_redirects
index 633025ac17..e218124e59 100644
--- a/docs/static/_redirects
+++ b/docs/static/_redirects
@@ -1,3 +1,4 @@
+/archived-versions.json https://version.immich.cloud/v1/docs/versions 307
/ /overview/quick-start 307
/mobile-app-beta-program /features/mobile-app 307
/contribution-guidelines /overview/support-the-project#contributing 307
diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json
deleted file mode 100644
index dbeed76b73..0000000000
--- a/docs/static/archived-versions.json
+++ /dev/null
@@ -1,265 +0,0 @@
-[
- {
- "label": "v3.1.0",
- "url": "https://docs.v3.1.0.archive.immich.app"
- },
- {
- "label": "v3.0.3",
- "url": "https://docs.v3.0.3.archive.immich.app"
- },
- {
- "label": "v2.7.5",
- "url": "https://docs.v2.7.5.archive.immich.app"
- },
- {
- "label": "v2.6.3",
- "url": "https://docs.v2.6.3.archive.immich.app"
- },
- {
- "label": "v2.5.6",
- "url": "https://docs.v2.5.6.archive.immich.app"
- },
- {
- "label": "v2.4.1",
- "url": "https://docs.v2.4.1.archive.immich.app"
- },
- {
- "label": "v2.3.1",
- "url": "https://docs.v2.3.1.archive.immich.app"
- },
- {
- "label": "v2.2.3",
- "url": "https://docs.v2.2.3.archive.immich.app"
- },
- {
- "label": "v2.1.0",
- "url": "https://docs.v2.1.0.archive.immich.app"
- },
- {
- "label": "v2.0.1",
- "url": "https://docs.v2.0.1.archive.immich.app"
- },
- {
- "label": "v1.144.1",
- "url": "https://docs.v1.144.1.archive.immich.app"
- },
- {
- "label": "v1.143.1",
- "url": "https://docs.v1.143.1.archive.immich.app"
- },
- {
- "label": "v1.142.1",
- "url": "https://v1.142.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.141.1",
- "url": "https://v1.141.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.140.1",
- "url": "https://v1.140.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.139.4",
- "url": "https://v1.139.4.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.138.1",
- "url": "https://v1.138.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.137.3",
- "url": "https://v1.137.3.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.136.0",
- "url": "https://v1.136.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.135.3",
- "url": "https://v1.135.3.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.134.0",
- "url": "https://v1.134.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.133.1",
- "url": "https://v1.133.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.132.3",
- "url": "https://v1.132.3.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.131.3",
- "url": "https://v1.131.3.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.130.3",
- "url": "https://v1.130.3.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.129.0",
- "url": "https://v1.129.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.128.0",
- "url": "https://v1.128.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.127.0",
- "url": "https://v1.127.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.126.1",
- "url": "https://v1.126.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.125.7",
- "url": "https://v1.125.7.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.124.2",
- "url": "https://v1.124.2.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.123.0",
- "url": "https://v1.123.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.122.3",
- "url": "https://v1.122.3.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.121.0",
- "url": "https://v1.121.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.120.2",
- "url": "https://v1.120.2.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.119.1",
- "url": "https://v1.119.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.118.2",
- "url": "https://v1.118.2.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.117.0",
- "url": "https://v1.117.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.116.2",
- "url": "https://v1.116.2.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.115.0",
- "url": "https://v1.115.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.114.0",
- "url": "https://v1.114.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.113.1",
- "url": "https://v1.113.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.112.1",
- "url": "https://v1.112.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.111.0",
- "url": "https://v1.111.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.110.0",
- "url": "https://v1.110.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.109.2",
- "url": "https://v1.109.2.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.108.0",
- "url": "https://v1.108.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.107.2",
- "url": "https://v1.107.2.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.106.4",
- "url": "https://v1.106.4.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.105.1",
- "url": "https://v1.105.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.104.0",
- "url": "https://v1.104.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.103.1",
- "url": "https://v1.103.1.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.102.3",
- "url": "https://v1.102.3.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.101.0",
- "url": "https://v1.101.0.archive.immich.app",
- "rootPath": "/docs"
- },
- {
- "label": "v1.100.0",
- "url": "https://v1.100.0.archive.immich.app",
- "rootPath": "/docs"
- }
-]
diff --git a/packages/scripts/src/commands/release.spec.ts b/packages/scripts/src/commands/release.spec.ts
index 0b1be22302..dc3f82517d 100644
--- a/packages/scripts/src/commands/release.spec.ts
+++ b/packages/scripts/src/commands/release.spec.ts
@@ -1,23 +1,10 @@
import semver from 'semver';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import {
- resolveArchivedVersions,
- ArchivedVersion,
- getMobileBuild,
- getNewVersion,
- ReleaseError,
-} from './release';
+import { describe, expect, it } from 'vitest';
+import { getMobileBuild, getNewVersion, ReleaseError } from './release';
const mobileBuild = (version: string) =>
getMobileBuild(semver.parse(version) as semver.SemVer);
-const archived = (label: string): ArchivedVersion => ({
- label,
- url: `https://docs.${label}.archive.immich.app`,
-});
-
-const labels = (versions: ArchivedVersion[]) => versions.map((v) => v.label);
-
describe(getNewVersion.name, () => {
describe('transitions', () => {
const valid = [
@@ -82,67 +69,6 @@ describe(getNewVersion.name, () => {
});
});
-describe(resolveArchivedVersions.name, () => {
- beforeEach(() => {
- // silence the "Removed ..." progress logging
- vi.spyOn(console, 'log').mockImplementation(() => {});
- });
-
- it('should prepend the new version to the front', () => {
- const result = resolveArchivedVersions([archived('v2.9.0')], '3.0.0');
-
- expect(result[0]).toEqual({
- label: 'v3.0.0',
- url: 'https://docs.v3.0.0.archive.immich.app',
- });
- expect(labels(result)).toEqual(['v3.0.0', 'v2.9.0']);
- });
-
- it('should handle an empty list', () => {
- expect(labels(resolveArchivedVersions([], '3.0.0'))).toEqual(['v3.0.0']);
- });
-
- it('should drop older patch releases of the new version minor', () => {
- const versions = ['v3.0.4', 'v3.0.3', 'v3.0.2'].map(archived);
-
- expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([
- 'v3.0.5',
- ]);
- });
-
- it('should keep the latest patch of each older minor', () => {
- const versions = ['v3.1.2', 'v3.1.1', 'v3.0.9', 'v2.0.0'].map(archived);
-
- expect(labels(resolveArchivedVersions(versions, '3.2.0'))).toEqual([
- 'v3.2.0',
- 'v3.1.2',
- 'v3.0.9',
- 'v2.0.0',
- ]);
- });
-
- it('should keep an older minor when bumping a patch', () => {
- const versions = ['v3.0.4', 'v3.0.3', 'v2.9.1', 'v2.9.0', 'v1.5.0'].map(
- archived,
- );
-
- expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([
- 'v3.0.5',
- 'v2.9.1',
- 'v1.5.0',
- ]);
- });
-
- it('should replace a prerelease with its release', () => {
- const versions = ['v3.0.0-rc.2', 'v3.0.0-rc.1', 'v2.9.0'].map(archived);
-
- expect(labels(resolveArchivedVersions(versions, '3.0.0'))).toEqual([
- 'v3.0.0',
- 'v2.9.0',
- ]);
- });
-});
-
describe(getMobileBuild.name, () => {
it('should encode the version', () => {
expect(mobileBuild('3.1.0')).toBe(3_010_099);
diff --git a/packages/scripts/src/commands/release.ts b/packages/scripts/src/commands/release.ts
index 307b66165a..2c0d82d5f6 100644
--- a/packages/scripts/src/commands/release.ts
+++ b/packages/scripts/src/commands/release.ts
@@ -26,7 +26,6 @@ const Files = {
Docs: {
Env: join(root, 'docs/docs/install/environment-variables.md'),
Upgrading: join(root, 'docs/docs/install/upgrading.md'),
- ArchivedVersions: join(root, 'docs/static/archived-versions.json'),
},
Mobile: {
Pubspec: join(root, 'mobile/pubspec.yaml'),
@@ -92,13 +91,6 @@ export const handleRelease = ({ type }: ReleaseOptions) => {
pump(Files.Docs.Upgrading, /:v\d+/, `:${major}`);
}
- // update archived versions list
- const archivedFile = new JsonFile(
- Files.Docs.ArchivedVersions,
- );
- const versions = archivedFile.read();
- archivedFile.write(resolveArchivedVersions(versions, newVersionRaw));
-
if (process.env.GITHUB_ENV) {
// make available for following steps
appendFileSync(
@@ -222,50 +214,6 @@ const pump = (path: string, pattern: RegExp, replacement: string) => {
file.write(update);
};
-export interface ArchivedVersion {
- label: string;
- url: string;
-}
-
-export const resolveArchivedVersions = (
- versions: ArchivedVersion[],
- nextVersion: string,
-): ArchivedVersion[] => {
- const newVersion: ArchivedVersion = {
- label: `v${nextVersion}`,
- url: `https://docs.v${nextVersion}.archive.immich.app`,
- };
-
- let result = versions;
- let lastVersion = asVersion(newVersion);
- for (const item of versions) {
- const version = asVersion(item);
- // only keep the latest patch version for each minor release
- if (
- lastVersion.major === version.major &&
- lastVersion.minor === version.minor &&
- lastVersion.patch >= version.patch
- ) {
- result = result.filter((item) => item.label !== version.label);
- console.log(
- `Removed ${version.label} (replaced with ${lastVersion.label})`,
- );
- continue;
- }
-
- lastVersion = version;
- }
-
- return [newVersion, ...result];
-};
-
-const asVersion = (item: ArchivedVersion) => {
- const { label, url } = item;
- const [version] = label.substring(1).split('-');
- const [major, minor, patch] = version.split('.').map(Number);
- return { major, minor, patch, label, url };
-};
-
const isPrerelease = (version: SemVer) => version.prerelease.length > 0;
/**
From 610bcfa6d09e6bfdb2bbaf16df8e77b83fc34356 Mon Sep 17 00:00:00 2001
From: Adam Gastineau
Date: Wed, 12 Aug 2026 04:53:14 -0700
Subject: [PATCH 62/89] chore(mobile): add Cocoapods to mise (#30722)
* chore(mobile): add Cocoapods to mise
* Use new mise setup in CI
---
.github/workflows/build-mobile.yml | 19 +++++++------------
mobile/mise.toml | 21 +++++++++++++++++++--
2 files changed, 26 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index b2ba244ab9..6a9a01d41d 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -225,6 +225,13 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
+ with:
+ ruby-version: '3.3'
+ bundler-cache: true
+ working-directory: ./mobile/ios
+
- name: Install dependencies
run: mise //mobile:install:ci
@@ -235,18 +242,6 @@ jobs:
working-directory: ./mobile
run: flutter build ios --config-only --no-codesign
- - name: Setup Ruby
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
- with:
- ruby-version: '3.3'
- bundler-cache: true
- working-directory: ./mobile/ios
-
- - name: Install CocoaPods dependencies
- working-directory: ./mobile/ios
- run: |
- pod install
-
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
diff --git a/mobile/mise.toml b/mobile/mise.toml
index 08a26c0c3b..f021cd8747 100644
--- a/mobile/mise.toml
+++ b/mobile/mise.toml
@@ -29,7 +29,7 @@ sources = [
]
outputs = { auto = true }
run = [
- "dart run build_runner build",
+ "dart run build_runner build",
"dart format lib/routing/router.gr.dart",
]
depends = ["//:open-api-dart"]
@@ -99,7 +99,11 @@ run = "dart run drift_dev make-migrations"
alias = "install"
description = "Install flutter dependencies"
depends = ["//:open-api-dart"]
-run = "flutter pub get"
+run = [
+ # This receives passed `--enforce-lockfile` args
+ "flutter pub get",
+ { task = "install:ios" },
+]
[tasks."install:ci"]
description = "Install flutter dependencies in CI"
@@ -132,6 +136,19 @@ run = [
]
wait_for = ["//:i18n:format-fix"]
+[tasks."install:ios"]
+description = "Install CocoaPods dependencies"
+hide = true
+sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"]
+outputs = ["ios/Pods/Manifest.lock"]
+run = '''
+echo "Running pod install"
+if [ "$(uname)" != "Darwin" ]; then
+ exit 0
+fi
+cd ios && pod install
+'''
+
[tasks."analyze:dart"]
description = "Run Dart analysis"
hide = true
From ce705dcc068146fca2c6ecb050ab94ec6b54cc85 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Wed, 12 Aug 2026 19:30:06 +0530
Subject: [PATCH 63/89] fix: riverpod reactivity regression from lint fixes
(#30734)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
.../lib/pages/backup/drift_backup.page.dart | 12 +--
.../pages/drift_album_options.page.dart | 10 +-
.../pages/drift_slideshow.page.dart | 97 +++++++++++++------
.../pages/drift_user_selection.page.dart | 4 +-
.../pages/search/drift_search.page.dart | 9 +-
.../widgets/common/immich_sliver_app_bar.dart | 20 +++-
6 files changed, 100 insertions(+), 52 deletions(-)
diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart
index ad2674b81b..a0e55e58de 100644
--- a/mobile/lib/pages/backup/drift_backup.page.dart
+++ b/mobile/lib/pages/backup/drift_backup.page.dart
@@ -316,12 +316,11 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final backupAlbums = ref.watch(backupAlbumProvider);
+
Widget buildSelectedAlbumName() {
String text = context.t.backup_controller_page_backup_selected;
- final albums = ref
- .read(backupAlbumProvider)
- .where((album) => album.backupSelection == BackupSelection.selected)
- .toList();
+ final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.selected).toList();
if (albums.isNotEmpty) {
for (var album in albums) {
@@ -352,10 +351,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
Widget buildExcludedAlbumName() {
String text = context.t.backup_controller_page_excluded;
- final albums = ref
- .read(backupAlbumProvider)
- .where((album) => album.backupSelection == BackupSelection.excluded)
- .toList();
+ final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
if (albums.isNotEmpty) {
for (var album in albums) {
diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart
index 59cabf8daf..078b9cc325 100644
--- a/mobile/lib/presentation/pages/drift_album_options.page.dart
+++ b/mobile/lib/presentation/pages/drift_album_options.page.dart
@@ -32,6 +32,8 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
final userId = ref.watch(authProvider).userId;
final activityEnabled = useState(album.isActivityEnabled);
final isOwner = album.ownerId == userId;
+ final owner = isOwner ? ref.watch(currentUserProvider) : null;
+ final allUsers = isOwner ? null : ref.watch(driftUsersProvider);
void showErrorMessage() {
ContextHelper(context).pop();
@@ -141,7 +143,6 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
Widget buildOwnerInfo() {
if (isOwner) {
- final owner = ref.read(currentUserProvider);
return ListTile(
leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(),
title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)),
@@ -149,8 +150,11 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
trailing: Text(context.t.owner, style: context.textTheme.labelLarge),
);
} else {
- final usersProvider = ref.read(driftUsersProvider);
- return usersProvider.maybeWhen(
+ if (allUsers == null) {
+ return const SizedBox();
+ }
+
+ return allUsers.maybeWhen(
data: (users) {
final user = users.firstWhereOrNull((u) => u.id == album.ownerId);
diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart
index 824d7d7c6b..2662c60a30 100644
--- a/mobile/lib/presentation/pages/drift_slideshow.page.dart
+++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si
color: context.colorScheme.primary,
);
} else {
- return LinearProgressIndicator(
- color: context.colorScheme.primary,
- borderRadius: BorderRadius.zero,
- minHeight: 5,
- value:
- ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds /
- asset.duration.inMilliseconds,
- );
+ return _VideoProgressBar(asset: asset);
}
}
@@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState with Si
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
);
} else {
- final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status));
- final position = ref.read(videoPlayerProvider(asset.id)).position;
-
- if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
- unawaited(_nextPage());
- } else if (status == VideoPlaybackStatus.playing) {
- unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false));
- }
-
- return PhotoView.customChild(
- onTapUp: (_, _, _) => _onTapUp(),
- disableScaleGestures: true,
- filterQuality: FilterQuality.high,
- initialScale: scale,
- child: NativeVideoViewer(
- asset: asset,
- isCurrent: isCurrent,
- image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
- ),
+ return _VideoChild(
+ asset: asset,
+ isCurrent: isCurrent,
+ scale: scale,
+ imageProvider: imageProvider,
+ onTapUp: _onTapUp,
+ onCompleted: _nextPage,
);
}
}
@@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState with Si
}
}
+class _VideoChild extends ConsumerWidget {
+ final BaseAsset asset;
+ final bool isCurrent;
+ final PhotoViewComputedScale scale;
+ final ImageProvider imageProvider;
+ final VoidCallback onTapUp;
+ final VoidCallback onCompleted;
+
+ const _VideoChild({
+ required this.asset,
+ required this.isCurrent,
+ required this.scale,
+ required this.imageProvider,
+ required this.onTapUp,
+ required this.onCompleted,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) {
+ if (status == VideoPlaybackStatus.completed) {
+ if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) {
+ onCompleted();
+ }
+ } else if (status == VideoPlaybackStatus.playing) {
+ unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false));
+ }
+ });
+
+ return PhotoView.customChild(
+ onTapUp: (_, _, _) => onTapUp(),
+ disableScaleGestures: true,
+ filterQuality: FilterQuality.high,
+ initialScale: scale,
+ child: NativeVideoViewer(
+ asset: asset,
+ isCurrent: isCurrent,
+ image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
+ ),
+ );
+ }
+}
+
+class _VideoProgressBar extends ConsumerWidget {
+ final BaseAsset asset;
+
+ const _VideoProgressBar({required this.asset});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position));
+
+ return LinearProgressIndicator(
+ color: context.colorScheme.primary,
+ borderRadius: BorderRadius.zero,
+ minHeight: 5,
+ value: position.inMilliseconds / asset.duration.inMilliseconds,
+ );
+ }
+}
+
/// Progress bar for image slides, driven by an explicit [AnimationController].
///
/// [TweenAnimationBuilder] creates its controller internally with the default
diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart
index 215a5d1071..c11d5a1fd6 100644
--- a/mobile/lib/presentation/pages/drift_user_selection.page.dart
+++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart
@@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue> suggestedShareUsers = ref.watch(driftUsersProvider);
+ final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id));
final sharedUsersList = useState>({});
void addNewUsersHandler() {
@@ -136,9 +137,6 @@ class DriftUserSelectionPage extends HookConsumerWidget {
),
body: suggestedShareUsers.widgetWhen(
onData: (users) {
- // Get shared users for this album from the database
- final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id));
-
return sharedUsers.when(
data: (albumSharedUsers) {
// Filter out users that are already shared with this album and the owner
diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart
index 0c6b0d8ae4..dfcb5abd4d 100644
--- a/mobile/lib/presentation/pages/search/drift_search.page.dart
+++ b/mobile/lib/presentation/pages/search/drift_search.page.dart
@@ -742,9 +742,7 @@ class _SearchResultGrid extends ConsumerWidget {
return false;
}
- Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
- final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading));
-
+ Widget? _bottomWidget(BuildContext context, {required bool isLoading, required bool hasMore}) {
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
@@ -755,8 +753,6 @@ class _SearchResultGrid extends ConsumerWidget {
);
}
- final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null));
-
if (hasMore) {
return null;
}
@@ -778,6 +774,7 @@ class _SearchResultGrid extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty));
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
+ final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
if (!hasAssets && !isLoading) {
return const _SearchNoResults();
@@ -807,7 +804,7 @@ class _SearchResultGrid extends ConsumerWidget {
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false,
loadingWidget: const SizedBox.shrink(),
- bottomSliverWidget: _bottomWidget(context, ref),
+ bottomSliverWidget: _bottomWidget(context, isLoading: isLoading, hasMore: hasMore),
),
),
),
diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
index 4c6b2a7211..6a119f8ca3 100644
--- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart
+++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
@@ -177,7 +177,15 @@ class _BackupIndicator extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final indicatorIcon = _getBackupBadgeIcon(context, ref);
+ final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled));
+ final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
+ final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
+ final indicatorIcon = _getBackupBadgeIcon(
+ context,
+ backupEnabled: backupEnabled,
+ hasError: hasError,
+ isUploading: isUploading,
+ );
return IconButton(
onPressed: () => context.pushRoute(const DriftBackupRoute()),
@@ -192,12 +200,14 @@ class _BackupIndicator extends ConsumerWidget {
);
}
- Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
- final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled));
- final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none));
+ Widget? _getBackupBadgeIcon(
+ BuildContext context, {
+ required bool backupEnabled,
+ required bool hasError,
+ required bool isUploading,
+ }) {
final isDarkTheme = context.isDarkTheme;
final iconColor = isDarkTheme ? Colors.white : Colors.black;
- final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
if (!backupEnabled) {
return _BadgeLabel(
From 943c11c0196e7424e5e488131a2fb7f7522037ed Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Wed, 12 Aug 2026 19:32:08 +0530
Subject: [PATCH 64/89] chore: label local only delete as trash (#30726)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
mobile/lib/presentation/actions/delete.action.dart | 3 ++-
.../unit/presentation/actions/delete_action_test.dart | 11 +++++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart
index ea7423354a..23a2ef2a2f 100644
--- a/mobile/lib/presentation/actions/delete.action.dart
+++ b/mobile/lib/presentation/actions/delete.action.dart
@@ -37,7 +37,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
// Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting.
- final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
+ final trash =
+ ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked));
return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash);
}, dependencies: [assetsActionProvider]);
diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart
index 29d81a5c2c..4c1bf9306c 100644
--- a/mobile/test/unit/presentation/actions/delete_action_test.dart
+++ b/mobile/test/unit/presentation/actions/delete_action_test.dart
@@ -170,6 +170,17 @@ void main() {
verifyNever(() => assetService.trash(any()));
verifyNever(() => assetService.delete(any()));
});
+
+ testWidgets('is labelled trash', (tester) async {
+ await tester.pumpTestWidget(
+ context,
+ const ActionButton(action: DeleteAction(source: .timeline)),
+ overrides: context.selected({LocalAssetFactory.create()}),
+ );
+
+ expect(find.text(StaticTranslations.instance.trash), findsOneWidget);
+ expect(find.text(StaticTranslations.instance.delete), findsNothing);
+ });
});
group('prompt handling', () {
From 52edcc0c74c2430418f88fe94700b549dac84e90 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Wed, 12 Aug 2026 21:12:11 +0530
Subject: [PATCH 65/89] refactor: unify person model (#30659)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
mobile/lib/domain/models/person.model.dart | 29 ++-----------------
.../lib/domain/services/people.service.dart | 6 ++--
.../repositories/people.repository.dart | 21 +++-----------
.../models/search/search_filter.model.dart | 4 +--
.../presentation/pages/drift_person.page.dart | 4 +--
.../pages/search/drift_search.page.dart | 2 +-
.../asset_details/people_details.widget.dart | 4 +--
.../person_edit_birthday_modal.widget.dart | 2 +-
.../people/person_edit_name_modal.widget.dart | 2 +-
.../infrastructure/people.provider.dart | 4 +--
.../lib/providers/search/people.provider.dart | 2 +-
.../repositories/person_api.repository.dart | 14 +++------
mobile/lib/services/person.service.dart | 4 +--
mobile/lib/utils/people.utils.dart | 4 +--
.../widgets/common/person_sliver_app_bar.dart | 4 +--
.../search/search_filter/people_picker.dart | 6 ++--
16 files changed, 34 insertions(+), 78 deletions(-)
diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart
index 56e6d3990d..94ead6a2e9 100644
--- a/mobile/lib/domain/models/person.model.dart
+++ b/mobile/lib/domain/models/person.model.dart
@@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.model.freezed.dart';
-// TODO: Remove PersonDto once Isar is removed
@freezed
-abstract class PersonDto with _$PersonDto {
- const factory PersonDto({
- required String id,
- DateTime? birthDate,
- required bool isHidden,
- required String name,
- required String thumbnailPath,
- DateTime? updatedAt,
- }) = _PersonDto;
-}
-
-// Model for a person stored in the server
-@freezed
-abstract class DriftPerson with _$DriftPerson {
- const factory DriftPerson({
- required String id,
- required DateTime createdAt,
- required DateTime updatedAt,
- required String ownerId,
- required String name,
- String? faceAssetId,
- required bool isFavorite,
- required bool isHidden,
- required String? color,
- DateTime? birthDate,
- }) = _DriftPerson;
+abstract class Person with _$Person {
+ const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person;
}
diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart
index 73972dbdc0..e86d43b283 100644
--- a/mobile/lib/domain/services/people.service.dart
+++ b/mobile/lib/domain/services/people.service.dart
@@ -10,15 +10,15 @@ class DriftPeopleService {
const DriftPeopleService(this._repository, this._personApiRepository);
- Future get(String personId) {
+ Future get(String personId) {
return _repository.get(personId);
}
- Future> getAssetPeople(String assetId) {
+ Future> getAssetPeople(String assetId) {
return _repository.getAssetPeople(assetId);
}
- Future> getAllPeople({int minFaces = 3}) {
+ Future> getAllPeople({int minFaces = 3}) {
return _repository.getAllPeople(minFaces: minFaces);
}
diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart
index 0dd73cc81d..b539c6b2fd 100644
--- a/mobile/lib/infrastructure/repositories/people.repository.dart
+++ b/mobile/lib/infrastructure/repositories/people.repository.dart
@@ -8,14 +8,14 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPeopleRepository(this._db) : super(_db);
- Future get(String personId) async {
+ Future get(String personId) async {
final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId));
final result = await query.getSingleOrNull();
return result?.toDto();
}
- Future> getAssetPeople(String assetId) async {
+ Future> getAssetPeople(String assetId) async {
// An asset can have multiple face records for the same person (e.g., metadata
// imports alongside ML detections). Use a subquery instead of a join so each
// person is returned once, regardless of how many of their faces are on the asset
@@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
- Future> getAllPeople({int minFaces = 3}) async {
+ Future> getAllPeople({int minFaces = 3}) async {
final people = _db.personEntity;
final faces = _db.assetFaceEntity;
final assets = _db.remoteAssetEntity;
@@ -76,18 +76,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
}
extension on PersonEntityData {
- DriftPerson toDto() {
- return DriftPerson(
- id: id,
- createdAt: createdAt,
- updatedAt: updatedAt,
- ownerId: ownerId,
- name: name,
- faceAssetId: faceAssetId,
- isFavorite: isFavorite,
- isHidden: isHidden,
- color: color,
- birthDate: birthDate,
- );
- }
+ Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate);
}
diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart
index 7e5e1b819e..a9cc13ba6a 100644
--- a/mobile/lib/models/search/search_filter.model.dart
+++ b/mobile/lib/models/search/search_filter.model.dart
@@ -194,7 +194,7 @@ class SearchFilter {
String? language;
String? assetId;
List? tagIds;
- Set people;
+ Set people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@@ -250,7 +250,7 @@ class SearchFilter {
String? language,
String? ocr,
String? assetId,
- Set? people,
+ Set? people,
List? tagIds,
SearchLocationFilter? location,
SearchCameraFilter? camera,
diff --git a/mobile/lib/presentation/pages/drift_person.page.dart b/mobile/lib/presentation/pages/drift_person.page.dart
index 7389f506d5..cad3b7b6a6 100644
--- a/mobile/lib/presentation/pages/drift_person.page.dart
+++ b/mobile/lib/presentation/pages/drift_person.page.dart
@@ -14,7 +14,7 @@ import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart';
@RoutePage()
class DriftPersonPage extends ConsumerStatefulWidget {
- final DriftPerson person;
+ final Person person;
const DriftPersonPage({super.key, required this.person});
@@ -23,7 +23,7 @@ class DriftPersonPage extends ConsumerStatefulWidget {
}
class _DriftPersonPageState extends ConsumerState {
- late DriftPerson _person;
+ late Person _person;
@override
void initState() {
diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart
index dfcb5abd4d..e364e0d2f8 100644
--- a/mobile/lib/presentation/pages/search/drift_search.page.dart
+++ b/mobile/lib/presentation/pages/search/drift_search.page.dart
@@ -129,7 +129,7 @@ class DriftSearchPage extends HookConsumerWidget {
void showPeoplePicker() {
var people = filter.value.people;
- void handleOnSelect(Set value) {
+ void handleOnSelect(Set value) {
people = value;
}
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart
index 739fe0a0d9..00c20f35bd 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart
@@ -30,7 +30,7 @@ class PeopleDetails extends ConsumerWidget {
final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id));
- Future showNameEditModal(DriftPerson person) async {
+ Future showNameEditModal(Person person) async {
await showDialog(
context: context,
useRootNavigator: false,
@@ -96,7 +96,7 @@ class PeopleDetails extends ConsumerWidget {
}
class _Avatar extends StatelessWidget {
- final DriftPerson person;
+ final Person person;
final DateTime assetFileCreatedAt;
final VoidCallback? onTap;
final VoidCallback? onNameTap;
diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart
index 0b8a08a5d8..a4903c7a29 100644
--- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart
+++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart
@@ -11,7 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scroll_date_picker/scroll_date_picker.dart';
class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget {
- final DriftPerson person;
+ final Person person;
const DriftPersonBirthdayEditForm({super.key, required this.person});
diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart
index 3b0e62ab1a..5e8c634181 100644
--- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart
+++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart
@@ -9,7 +9,7 @@ import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DriftPersonNameEditForm extends ConsumerStatefulWidget {
- final DriftPerson person;
+ final Person person;
const DriftPersonNameEditForm({super.key, required this.person});
diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart
index 1939940975..73a35958bd 100644
--- a/mobile/lib/providers/infrastructure/people.provider.dart
+++ b/mobile/lib/providers/infrastructure/people.provider.dart
@@ -14,12 +14,12 @@ final driftPeopleServiceProvider = Provider(
(ref) => DriftPeopleService(ref.watch(driftPeopleRepositoryProvider), ref.watch(personApiRepositoryProvider)),
);
-final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async {
+final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async {
final service = ref.watch(driftPeopleServiceProvider);
return service.getAssetPeople(assetId);
});
-final driftGetAllPeopleProvider = FutureProvider>((ref) async {
+final driftGetAllPeopleProvider = FutureProvider>((ref) async {
final service = ref.watch(driftPeopleServiceProvider);
final prefs = await ref.watch(userMetadataPreferencesProvider.future);
return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3);
diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart
index 1bd58509f5..f9fc1ec133 100644
--- a/mobile/lib/providers/search/people.provider.dart
+++ b/mobile/lib/providers/search/people.provider.dart
@@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/services/person.service.dart';
-final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async {
+final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async {
final PersonService personService = ref.read(personServiceProvider);
final people = await personService.getAllPeople();
diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart
index 158e3ac333..5804e1da3b 100644
--- a/mobile/lib/repositories/person_api.repository.dart
+++ b/mobile/lib/repositories/person_api.repository.dart
@@ -11,12 +11,12 @@ class PersonApiRepository extends ApiRepository {
PersonApiRepository(this._api);
- Future> getAll() async {
+ Future> getAll() async {
final dto = await checkNull(_api.getAllPeople());
return dto.people.map(_toPerson).toList();
}
- Future update(String id, {String? name, DateTime? birthday}) async {
+ Future update(String id, {String? name, DateTime? birthday}) async {
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
final dto = PersonUpdateDto(
name: name == null ? const Optional.absent() : Optional.present(name),
@@ -26,12 +26,6 @@ class PersonApiRepository extends ApiRepository {
return _toPerson(response);
}
- static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
- birthDate: dto.birthDate,
- id: dto.id,
- isHidden: dto.isHidden,
- name: dto.name,
- thumbnailPath: dto.thumbnailPath,
- updatedAt: dto.updatedAt.orElse(null),
- );
+ static Person _toPerson(PersonResponseDto dto) =>
+ .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.value);
}
diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart
index 0d589ea71d..6a9226a893 100644
--- a/mobile/lib/services/person.service.dart
+++ b/mobile/lib/services/person.service.dart
@@ -12,7 +12,7 @@ class PersonService {
final PersonApiRepository _personApiRepository;
PersonService(this._personApiRepository);
- Future> getAllPeople() async {
+ Future> getAllPeople() async {
try {
return await _personApiRepository.getAll();
} catch (error, stack) {
@@ -21,7 +21,7 @@ class PersonService {
}
}
- Future updateName(String id, String name) async {
+ Future updateName(String id, String name) async {
try {
return await _personApiRepository.update(id, name: name);
} catch (error, stack) {
diff --git a/mobile/lib/utils/people.utils.dart b/mobile/lib/utils/people.utils.dart
index 8da5a5a3eb..bd2b607e0b 100644
--- a/mobile/lib/utils/people.utils.dart
+++ b/mobile/lib/utils/people.utils.dart
@@ -34,7 +34,7 @@ int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) {
(referenceDate.day < birthDate.day ? 1 : 0);
}
-Future showNameEditModal(BuildContext context, DriftPerson person) {
+Future showNameEditModal(BuildContext context, Person person) {
return showDialog(
context: context,
useRootNavigator: false,
@@ -44,7 +44,7 @@ Future showNameEditModal(BuildContext context, DriftPerson person) {
);
}
-Future showBirthdayEditModal(BuildContext context, DriftPerson person) {
+Future showBirthdayEditModal(BuildContext context, Person person) {
return showDialog(
context: context,
useRootNavigator: false,
diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart
index 4b159447e6..56eb3c6094 100644
--- a/mobile/lib/widgets/common/person_sliver_app_bar.dart
+++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart
@@ -28,7 +28,7 @@ class PersonSliverAppBar extends ConsumerStatefulWidget {
required this.onBirthdayTap,
});
- final DriftPerson person;
+ final Person person;
final VoidCallback onNameTap;
final VoidCallback onBirthdayTap;
final VoidCallback onShowOptions;
@@ -137,7 +137,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState {
class _ExpandedBackground extends ConsumerStatefulWidget {
final double scrollProgress;
- final DriftPerson person;
+ final Person person;
final VoidCallback onNameTap;
final VoidCallback onBirthdayTap;
diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart
index b4e73e97f9..1995851904 100644
--- a/mobile/lib/widgets/search/search_filter/people_picker.dart
+++ b/mobile/lib/widgets/search/search_filter/people_picker.dart
@@ -15,8 +15,8 @@ import 'package:immich_mobile/widgets/common/search_field.dart';
class PeoplePicker extends HookConsumerWidget {
const PeoplePicker({super.key, required this.onSelect, this.filter});
- final Function(Set) onSelect;
- final Set? filter;
+ final Function(Set) onSelect;
+ final Set? filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget {
const imageSize = 60.0;
final searchQuery = useState('');
final people = ref.watch(getAllPeopleProvider);
- final selectedPeople = useState>(filter ?? {});
+ final selectedPeople = useState>(filter ?? {});
return Column(
children: [
From 303a9f15b1a78e2aeb3c4195f6a97608152eb799 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Wed, 12 Aug 2026 21:12:12 +0530
Subject: [PATCH 66/89] refactor: reactive driftGetAllPeopleProvider (#30660)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
mobile/lib/domain/services/people.service.dart | 6 +++---
.../infrastructure/repositories/people.repository.dart | 4 ++--
mobile/lib/pages/common/tab_shell.page.dart | 2 --
.../people/person_edit_birthday_modal.widget.dart | 9 ++-------
.../widgets/people/person_edit_name_modal.widget.dart | 7 +------
mobile/lib/providers/infrastructure/people.provider.dart | 4 ++--
6 files changed, 10 insertions(+), 22 deletions(-)
diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart
index e86d43b283..196cf1edbb 100644
--- a/mobile/lib/domain/services/people.service.dart
+++ b/mobile/lib/domain/services/people.service.dart
@@ -18,8 +18,8 @@ class DriftPeopleService {
return _repository.getAssetPeople(assetId);
}
- Future> getAllPeople({int minFaces = 3}) {
- return _repository.getAllPeople(minFaces: minFaces);
+ Stream> watch({int minFaces = 3}) {
+ return _repository.watch(minFaces: minFaces);
}
Future updateName(String personId, String name) async {
@@ -27,7 +27,7 @@ class DriftPeopleService {
return _repository.updateName(personId, name);
}
- Future updateBrithday(String personId, DateTime birthday) async {
+ Future updateBirthday(String personId, DateTime birthday) async {
await _personApiRepository.update(personId, birthday: birthday);
return _repository.updateBirthday(personId, birthday);
}
diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart
index b539c6b2fd..c5c7bfdb9c 100644
--- a/mobile/lib/infrastructure/repositories/people.repository.dart
+++ b/mobile/lib/infrastructure/repositories/people.repository.dart
@@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
- Future> getAllPeople({int minFaces = 3}) async {
+ Stream> watch({int minFaces = 3}) {
final people = _db.personEntity;
final faces = _db.assetFaceEntity;
final assets = _db.remoteAssetEntity;
@@ -59,7 +59,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
return query.map((row) {
final person = row.readTable(people);
return person.toDto();
- }).get();
+ }).watch();
}
Future updateName(String personId, String name) {
diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart
index 5b620e9c91..240c4abe7a 100644
--- a/mobile/lib/pages/common/tab_shell.page.dart
+++ b/mobile/lib/pages/common/tab_shell.page.dart
@@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/pages/search/paginated_search.provide
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
-import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
@@ -132,7 +131,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
// Library page
if (index == kLibraryTabIndex) {
ref.invalidate(localAlbumProvider);
- ref.invalidate(driftGetAllPeopleProvider);
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();
diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart
index a4903c7a29..e56a21e021 100644
--- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart
+++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart
@@ -30,14 +30,9 @@ class _DriftPersonNameEditFormState extends ConsumerState saveBirthday() async {
try {
- final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate);
-
- if (result != 0) {
- ref.invalidate(driftGetAllPeopleProvider);
- if (!mounted) {
- return;
- }
+ final result = await ref.read(driftPeopleServiceProvider).updateBirthday(widget.person.id, _selectedDate);
+ if (result != 0 && mounted) {
context.pop(_selectedDate);
}
} catch (error) {
diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart
index 5e8c634181..4ae41bc640 100644
--- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart
+++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart
@@ -29,12 +29,7 @@ class _DriftPersonNameEditFormState extends ConsumerState onEdit(String personId, String newName) async {
try {
final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName);
- if (result != 0) {
- ref.invalidate(driftGetAllPeopleProvider);
- if (!mounted) {
- return;
- }
-
+ if (result != 0 && mounted) {
context.pop(newName);
}
} catch (error) {
diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart
index 73a35958bd..26b145255c 100644
--- a/mobile/lib/providers/infrastructure/people.provider.dart
+++ b/mobile/lib/providers/infrastructure/people.provider.dart
@@ -19,8 +19,8 @@ final driftPeopleAssetProvider = FutureProvider.family, String>((re
return service.getAssetPeople(assetId);
});
-final driftGetAllPeopleProvider = FutureProvider>((ref) async {
+final driftGetAllPeopleProvider = StreamProvider>((ref) async* {
final service = ref.watch(driftPeopleServiceProvider);
final prefs = await ref.watch(userMetadataPreferencesProvider.future);
- return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3);
+ yield* service.watch(minFaces: prefs?.minimumFaces ?? 3);
});
From 1c3a5cf5087182a929b16d71460e093af5b844a6 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Wed, 12 Aug 2026 21:12:12 +0530
Subject: [PATCH 67/89] chore: remove old people provider (#30662)
* chore: remove old people provider
* refactor: rename driftGetAllPeopleProvider
* minor fixes
---------
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
.../pages/drift_library.page.dart | 2 +-
.../pages/drift_people_collection.page.dart | 2 +-
.../infrastructure/people.provider.dart | 2 +-
.../lib/providers/search/people.provider.dart | 24 --------------
.../repositories/person_api.repository.dart | 7 +---
mobile/lib/services/person.service.dart | 32 -------------------
.../search/search_filter/people_picker.dart | 4 +--
7 files changed, 6 insertions(+), 67 deletions(-)
delete mode 100644 mobile/lib/providers/search/people.provider.dart
delete mode 100644 mobile/lib/services/person.service.dart
diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart
index 3118f2c4f0..137f46659f 100644
--- a/mobile/lib/presentation/pages/drift_library.page.dart
+++ b/mobile/lib/presentation/pages/drift_library.page.dart
@@ -145,7 +145,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final people = ref.watch(driftGetAllPeopleProvider);
+ final people = ref.watch(getAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart
index cb305395cc..569cb99b01 100644
--- a/mobile/lib/presentation/pages/drift_people_collection.page.dart
+++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart
@@ -33,7 +33,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState, String>((re
return service.getAssetPeople(assetId);
});
-final driftGetAllPeopleProvider = StreamProvider>((ref) async* {
+final getAllPeopleProvider = StreamProvider>((ref) async* {
final service = ref.watch(driftPeopleServiceProvider);
final prefs = await ref.watch(userMetadataPreferencesProvider.future);
yield* service.watch(minFaces: prefs?.minimumFaces ?? 3);
diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart
deleted file mode 100644
index f9fc1ec133..0000000000
--- a/mobile/lib/providers/search/people.provider.dart
+++ /dev/null
@@ -1,24 +0,0 @@
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/domain/models/person.model.dart';
-import 'package:immich_mobile/services/person.service.dart';
-
-final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async {
- final PersonService personService = ref.read(personServiceProvider);
-
- final people = await personService.getAllPeople();
-
- return people;
-});
-
-final updatePersonNameProvider = FutureProvider.autoDispose(
- (ref) => (String personId, String updatedName) async {
- final PersonService personService = ref.read(personServiceProvider);
- final person = await personService.updateName(personId, updatedName);
-
- if (person != null && person.name == updatedName) {
- ref.invalidate(getAllPeopleProvider);
- return true;
- }
- return false;
- },
-);
diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart
index 5804e1da3b..262868eea1 100644
--- a/mobile/lib/repositories/person_api.repository.dart
+++ b/mobile/lib/repositories/person_api.repository.dart
@@ -11,11 +11,6 @@ class PersonApiRepository extends ApiRepository {
PersonApiRepository(this._api);
- Future> getAll() async {
- final dto = await checkNull(_api.getAllPeople());
- return dto.people.map(_toPerson).toList();
- }
-
Future update(String id, {String? name, DateTime? birthday}) async {
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
final dto = PersonUpdateDto(
@@ -27,5 +22,5 @@ class PersonApiRepository extends ApiRepository {
}
static Person _toPerson(PersonResponseDto dto) =>
- .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.value);
+ .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.orElse(null));
}
diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart
deleted file mode 100644
index 6a9226a893..0000000000
--- a/mobile/lib/services/person.service.dart
+++ /dev/null
@@ -1,32 +0,0 @@
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/domain/models/person.model.dart';
-import 'package:immich_mobile/repositories/person_api.repository.dart';
-import 'package:logging/logging.dart';
-
-final personServiceProvider = Provider.autoDispose(
- (ref) => PersonService(ref.watch(personApiRepositoryProvider)),
-);
-
-class PersonService {
- final Logger _log = Logger("PersonService");
- final PersonApiRepository _personApiRepository;
- PersonService(this._personApiRepository);
-
- Future> getAllPeople() async {
- try {
- return await _personApiRepository.getAll();
- } catch (error, stack) {
- _log.severe("Error while fetching curated people", error, stack);
- return [];
- }
- }
-
- Future updateName(String id, String name) async {
- try {
- return await _personApiRepository.update(id, name: name);
- } catch (error, stack) {
- _log.severe("Error while updating person name", error, stack);
- }
- return null;
- }
-}
diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart
index 1995851904..ee738265b2 100644
--- a/mobile/lib/widgets/search/search_filter/people_picker.dart
+++ b/mobile/lib/widgets/search/search_filter/people_picker.dart
@@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
-import 'package:immich_mobile/providers/search/people.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@@ -65,7 +65,7 @@ class PeoplePicker extends HookConsumerWidget {
padding: const EdgeInsets.only(bottom: 2.0),
child: LargeLeadingTile(
title: Text(
- person.name,
+ person.name.nullIfEmpty ?? context.t.no_name,
style: context.textTheme.bodyLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
From 2a1691868e71aec6bd66eee82d1a6eb2e7aec636 Mon Sep 17 00:00:00 2001
From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com>
Date: Wed, 12 Aug 2026 18:01:30 +0200
Subject: [PATCH 68/89] chore: use import.meta.dirname instead of __dirname
(#30738)
---
e2e/eslint.config.mjs | 7 +------
packages/cli/eslint.config.mjs | 7 +------
server/eslint.config.mjs | 7 +------
web/eslint.config.js | 10 +++-------
web/vite.config.ts | 4 ++--
5 files changed, 8 insertions(+), 27 deletions(-)
diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs
index d5da8f8445..3d69d1f503 100644
--- a/e2e/eslint.config.mjs
+++ b/e2e/eslint.config.mjs
@@ -2,13 +2,8 @@ import js from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
import typescriptEslint from 'typescript-eslint';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
export default typescriptEslint.config([
eslintPluginUnicorn.configs.recommended,
eslintPluginPrettierRecommended,
@@ -29,7 +24,7 @@ export default typescriptEslint.config([
parserOptions: {
project: 'tsconfig.json',
- tsconfigRootDir: __dirname,
+ tsconfigRootDir: import.meta.dirname,
},
},
diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs
index bcc74c654d..cd32bf6023 100644
--- a/packages/cli/eslint.config.mjs
+++ b/packages/cli/eslint.config.mjs
@@ -2,13 +2,8 @@ import js from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
import typescriptEslint from 'typescript-eslint';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
export default typescriptEslint.config([
eslintPluginUnicorn.configs.recommended,
eslintPluginPrettierRecommended,
@@ -29,7 +24,7 @@ export default typescriptEslint.config([
parserOptions: {
project: 'tsconfig.json',
- tsconfigRootDir: __dirname,
+ tsconfigRootDir: import.meta.dirname,
},
},
diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs
index 2c11b7d3cb..949acaf139 100644
--- a/server/eslint.config.mjs
+++ b/server/eslint.config.mjs
@@ -2,13 +2,8 @@ import js from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
import typescriptEslint from 'typescript-eslint';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
export default typescriptEslint.config([
eslintPluginUnicorn.configs.recommended,
eslintPluginPrettierRecommended,
@@ -29,7 +24,7 @@ export default typescriptEslint.config([
parserOptions: {
project: 'tsconfig.json',
- tsconfigRootDir: __dirname,
+ tsconfigRootDir: import.meta.dirname,
},
},
diff --git a/web/eslint.config.js b/web/eslint.config.js
index 5974eaf68f..fc69385c93 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -10,10 +10,6 @@ import parser from 'svelte-eslint-parser';
import typescriptEslint from 'typescript-eslint';
import fs from 'node:fs';
import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
export default typescriptEslint.config(
...eslintPluginSvelte.configs.recommended,
@@ -29,7 +25,7 @@ export default typescriptEslint.config(
'error',
{
browserslist: fs
- .readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8')
+ .readFileSync(path.join(import.meta.dirname, '.browserslistrc'), 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#')),
@@ -40,7 +36,7 @@ export default typescriptEslint.config(
parser,
parserOptions: {
project: ['./tsconfig.json'],
- tsconfigRootDir: __dirname,
+ tsconfigRootDir: import.meta.dirname,
},
},
// ignores: ['**/service-worker/**'],
@@ -96,7 +92,7 @@ export default typescriptEslint.config(
parserOptions: {
extraFileExtensions: ['.svelte'],
- tsconfigRootDir: __dirname,
+ tsconfigRootDir: import.meta.dirname,
project: ['./tsconfig.json'],
},
},
diff --git a/web/vite.config.ts b/web/vite.config.ts
index c2b2b5bf25..07e7efaf2a 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -28,8 +28,8 @@ export default defineConfig({
alias: {
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
// eslint-disable-next-line unicorn/prefer-module
- '@test-data': path.resolve(__dirname, './src/test-data'),
- // '@immich/ui': path.resolve(__dirname, '../../ui/packages/ui'),
+ '@test-data': path.resolve(import.meta.dirname, './src/test-data'),
+ // '@immich/ui': path.resolve(import.meta.dirname, '../../ui/packages/ui'),
},
},
server: {
From ff5da0f84fc79d3167fbb6a6a193ed53dc36c987 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Wed, 12 Aug 2026 23:00:08 +0530
Subject: [PATCH 69/89] chore: immich_mobile path in openapi pubspec (#30643)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
open-api/patch/pubspec_immich_mobile.yaml.patch | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/open-api/patch/pubspec_immich_mobile.yaml.patch b/open-api/patch/pubspec_immich_mobile.yaml.patch
index ad3bbc2edd..be210f864f 100644
--- a/open-api/patch/pubspec_immich_mobile.yaml.patch
+++ b/open-api/patch/pubspec_immich_mobile.yaml.patch
@@ -6,4 +6,4 @@
-dev_dependencies:
- test: '>=1.21.6 <1.22.0'
+ immich_mobile:
-+ path: ../
++ path: ../../
From db9e7c20d71043a5a5a438781e3d9bebcf35bf97 Mon Sep 17 00:00:00 2001
From: Adam Gastineau
Date: Wed, 12 Aug 2026 11:30:05 -0700
Subject: [PATCH 70/89] chore(mobile): add a single mise checkout command for
reoccuring tasks (#30737)
* chore(mobile): add a single mise checkout command for reoccuring tasks
* Fix .flutter-plugins-dependencies path
Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
---------
Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
---
mobile/mise.toml | 14 ++++++++-
mobile/scripts/checkout_ios.sh | 52 ++++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 1 deletion(-)
create mode 100644 mobile/scripts/checkout_ios.sh
diff --git a/mobile/mise.toml b/mobile/mise.toml
index f021cd8747..d8ddc8ab21 100644
--- a/mobile/mise.toml
+++ b/mobile/mise.toml
@@ -111,6 +111,18 @@ run = [
{ task = "install", args = ["--enforce-lockfile"] },
]
+[tasks.checkout]
+description = "Bring a checkout up to date after switching branches"
+run = [
+ { task = "install" },
+ { task = "checkout:ios" },
+ { task = "codegen" },
+]
+
+[tasks."checkout:ios"]
+description = "Bring the iOS project up to date after switching branches"
+run = "bash ./scripts/checkout_ios.sh"
+
[tasks.start]
alias = "start"
description = "Start flutter app"
@@ -139,7 +151,7 @@ wait_for = ["//:i18n:format-fix"]
[tasks."install:ios"]
description = "Install CocoaPods dependencies"
hide = true
-sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"]
+sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugins-dependencies"]
outputs = ["ios/Pods/Manifest.lock"]
run = '''
echo "Running pod install"
diff --git a/mobile/scripts/checkout_ios.sh b/mobile/scripts/checkout_ios.sh
new file mode 100644
index 0000000000..74706e0d23
--- /dev/null
+++ b/mobile/scripts/checkout_ios.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Reconciles the iOS project with the current Flutter setup
+#
+# `flutter build ios --config-only` is relatively expensive, so we attempt to avoid running it when possible
+
+set -euo pipefail
+
+stamp="build/ios_checkout.stamp"
+
+# All tracked files that can invalidate the Xcode + Flutter build state
+inputs_digest() {
+ git ls-files -z ios pubspec.yaml pubspec.lock mise.toml mise.lock \
+ | xargs -0 shasum \
+ | shasum \
+ | cut -d ' ' -f 1
+}
+
+# Echoes why the iOS project needs reconciling, or returns 1 if it does not.
+# The first three cases can happen with no tracked file having changed at all,
+# so comparing the digest alone is not enough.
+staleness() {
+ local flutter_root
+ flutter_root="$(sed -n 's/^FLUTTER_ROOT=//p' ios/Flutter/Generated.xcconfig 2>/dev/null || true)"
+
+ if [[ -z $flutter_root ]]; then
+ echo "misconfigured FLUTTER_ROOT"
+ elif [[ ! -d $flutter_root ]]; then
+ echo "the Flutter SDK has changed"
+ elif ! cmp -s ios/Podfile.lock ios/Pods/Manifest.lock; then
+ # This is also done by Xcode's "Check Pods Manifest.lock" build phase
+ echo "Cocoapods out of sync"
+ elif [[ ! -f $stamp ]]; then
+ echo "first checkout"
+ elif [[ $(<"$stamp") != "$(inputs_digest)" ]]; then
+ echo "tracked build state has changed"
+ else
+ return 1
+ fi
+}
+
+[[ $(uname) == Darwin ]] || exit 0
+
+if ! reason="$(staleness)"; then
+ exit 0
+fi
+
+echo "Reconciling iOS project: $reason"
+flutter build ios --config-only --debug
+
+# Save tracked files manifest
+mkdir -p "$(dirname "$stamp")"
+inputs_digest > "$stamp"
From b82d4805525f68dbb07e160163e2a582e9dfab91 Mon Sep 17 00:00:00 2001
From: Adam Gastineau
Date: Wed, 12 Aug 2026 12:57:45 -0700
Subject: [PATCH 71/89] fix(mobile): prevent iOS status bar scroll to top
during transitions (#30717)
---
mobile/lib/main.dart | 2 +-
.../widgets/timeline/timeline.widget.dart | 10 +++++++++-
.../lib/routing/app_navigation_observer.dart | 19 +++++++++++++++++++
.../timeline/timeline_scroll_to_top_test.dart | 14 +++++++++++---
4 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index d2fd945cbf..2f1cfdaf6a 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -277,7 +277,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve
),
routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder,
- navigatorObservers: () => [AppNavigationObserver(ref: ref)],
+ navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()],
),
),
);
diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
index 9e65dcb72c..c41f88fcc0 100644
--- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
+++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
@@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
+import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
@@ -190,7 +191,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
// may be in a background tab. In either case, `handleStatusBarTap()` still fires
// Make sure the timeline is the primary route before scrolling to the top
final routeData = context.findAncestorWidgetOfExactType()?.routeData;
- if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) {
+ // The tap is generated async, so it can arrive after a route pop has started (due to a back button or similar)
+ // Check if route is alive and not exiting before taking action
+ final observers = Navigator.maybeOf(context)?.widget.observers ?? const [];
+ final isRouteTransitioning = observers.whereType().any(
+ (observer) => observer.hasTransitioningRoute,
+ );
+
+ if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true && !isRouteTransitioning) {
_scrollToTop();
}
}
diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart
index a7f6d532e9..90381cf49c 100644
--- a/mobile/lib/routing/app_navigation_observer.dart
+++ b/mobile/lib/routing/app_navigation_observer.dart
@@ -30,3 +30,22 @@ class AppNavigationObserver extends AutoRouterObserver {
ref.invalidate(isAssetViewerOpenProvider);
}
}
+
+/// Tracks routes that are undergoing a pop transition
+class TransitioningRouteObserver extends NavigatorObserver {
+ int _transitioningRoutes = 0;
+
+ /// Whether a "popping" route is still on screen
+ bool get hasTransitioningRoute => _transitioningRoutes > 0;
+
+ @override
+ void didPop(Route route, Route? previousRoute) {
+ if (route is! TransitionRoute) {
+ return;
+ }
+
+ _transitioningRoutes += 1;
+ // Transition completed and route disposed
+ unawaited(route.completed.whenComplete(() => _transitioningRoutes -= 1));
+ }
+}
diff --git a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart
index 28644d6702..f72af61a0a 100644
--- a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart
+++ b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart
@@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
+import 'package:immich_mobile/routing/app_navigation_observer.dart';
import '../../../fixtures/asset.stub.dart';
@@ -62,7 +63,9 @@ void main() {
timelineServiceProvider.overrideWithValue(service),
appConfigProvider.overrideWithValue(const AppConfig()),
],
- child: MaterialApp.router(routerConfig: router.config()),
+ child: MaterialApp.router(
+ routerConfig: router.config(navigatorObservers: () => [TransitioningRouteObserver()]),
+ ),
),
);
// Segment stream resolves
@@ -96,9 +99,14 @@ void main() {
await tester.pumpAndSettle();
expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route');
- // Once the timeline is visible, taps on the status bar should scroll to the top
- await router.maybePop();
+ // The tap is async, so it can arrive while a pushed route is transitioning to popped
+ unawaited(router.maybePop());
+ await tester.pump();
+ await tapStatusBar();
await tester.pumpAndSettle();
+ expect(position.pixels, initialScrolledPosition, reason: 'ignored while the pushed route pops');
+
+ // Once the timeline is visible, taps on the status bar should scroll to the top
await tapStatusBar();
await tester.pumpAndSettle();
expect(position.pixels, 0, reason: 'scrolls the foreground timeline');
From a939561e70f437dc7ab3ee2d8b859cc654d6cb3e Mon Sep 17 00:00:00 2001
From: Ben Beckford
Date: Wed, 12 Aug 2026 16:26:03 -0700
Subject: [PATCH 72/89] feat: workflow asset tag trigger/filter/action (#29043)
* feat: workflow asset tag trigger and filter
* feat(web): show tag names in workflow editor
* fix(web): tag picker in schema config editor
* fix: invalid plugin manifest
* chore: update workflow method wrapper type
* chore: update tag filter method declaration
* feat: workflow action to add tags to assets
---
i18n/en.json | 2 +
open-api/immich-openapi-specs.json | 3 +-
packages/plugin-core/manifest.json | 50 +++++++++++++++++++
packages/plugin-core/src/index.ts | 27 ++++++++++
packages/plugin-sdk/src/host-functions.ts | 9 ++++
packages/plugin-sdk/src/types.ts | 9 +++-
packages/sdk/src/fetch-client.ts | 3 +-
server/src/repositories/event.repository.ts | 2 +-
.../src/repositories/workflow.repository.ts | 2 +
server/src/services/tag.service.ts | 4 +-
.../services/workflow-execution.service.ts | 13 +++++
server/src/utils/workflow.ts | 1 +
.../lib/components/SchemaConfiguration.svelte | 3 ++
web/src/lib/components/SchemaTagPicker.svelte | 32 ++++++++++++
web/src/lib/types.ts | 2 +-
web/src/lib/utils/workflow.ts | 9 ++++
.../[workflowId]/WorkflowStepCard.svelte | 12 ++++-
17 files changed, 175 insertions(+), 8 deletions(-)
create mode 100644 web/src/lib/components/SchemaTagPicker.svelte
diff --git a/i18n/en.json b/i18n/en.json
index 536b7677ac..1022a6f851 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -2139,6 +2139,8 @@
"trigger": "Trigger",
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
+ "trigger_asset_tagged": "Asset Tagged",
+ "trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
"trigger_asset_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_person_recognized": "Person Recognized",
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index bc3bf82094..3596eaf4b1 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -28066,7 +28066,8 @@
"description": "Plugin trigger type",
"enum": [
"AssetCreate",
- "AssetMetadataExtraction"
+ "AssetMetadataExtraction",
+ "AssetTagged"
],
"type": "string"
},
diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json
index a72d60c04d..d7f7830a0e 100644
--- a/packages/plugin-core/manifest.json
+++ b/packages/plugin-core/manifest.json
@@ -308,6 +308,56 @@
},
"uiHints": ["Filter"]
},
+ {
+ "name": "assetAddTags",
+ "title": "Add Tags",
+ "description": "Add tags to an asset",
+ "types": ["AssetV1"],
+ "hostFunctions": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "tags": {
+ "title": "Tags",
+ "description": "Tags to add to the asset",
+ "type": "string",
+ "array": true,
+ "uiHint": {
+ "type": "TagId"
+ }
+ }
+ },
+ "required": ["tags"]
+ }
+ },
+ {
+ "name": "assetTagFilter",
+ "title": "Filter by tags",
+ "description": "Filter assets by which tags they have",
+ "types": ["AssetV1"],
+ "schema": {
+ "type": "object",
+ "properties": {
+ "tags": {
+ "title": "Tags",
+ "description": "Which tags the asset must have",
+ "type": "string",
+ "array": true,
+ "uiHint": {
+ "type": "TagId"
+ }
+ },
+ "matching": {
+ "title": "Matching",
+ "description": "Whether assets must have all, any, or none of the listed tags",
+ "type": "string",
+ "enum": ["all", "any", "none"]
+ }
+ },
+ "required": ["tags", "matching"]
+ },
+ "uiHints": ["Filter"]
+ },
{
"name": "assetExifFilter",
"title": "Filter by EXIF metadata",
diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts
index 7b91ed7111..251393ace5 100644
--- a/packages/plugin-core/src/index.ts
+++ b/packages/plugin-core/src/index.ts
@@ -39,6 +39,11 @@ const matchValueResult = (value: string, config: MatchValueConfig) => {
};
const methods = wrapper({
+ assetAddTags: ({ config, data, functions }) => {
+ functions.bulkTagAssets({ assetIds: [data.asset.id], tagIds: config.tags });
+ return {};
+ },
+
assetAddToAlbums: ({ config, data, functions }) => {
const assetId = data.asset.id;
@@ -176,6 +181,24 @@ const methods = wrapper({
return { workflow: { continue: hasTimeZone === needsTimeZone } };
},
+ assetTagFilter: ({ config, data }) => {
+ const assetTags = data.asset.tags.map((tag) => tag.id);
+
+ for (const tag of config.tags) {
+ if (assetTags.includes(tag)) {
+ if (config.matching === 'any') {
+ break;
+ } else if (config.matching === 'none') {
+ return { workflow: { continue: false } };
+ }
+ } else if (config.matching === 'all') {
+ return { workflow: { continue: false } };
+ }
+ }
+
+ return { workflow: { continue: true } };
+ },
+
assetTypeFilter: ({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
},
@@ -208,6 +231,7 @@ const methods = wrapper({
});
const {
+ assetAddTags,
assetAddToAlbums,
assetArchive,
assetFavorite,
@@ -217,6 +241,7 @@ const {
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
+ assetTagFilter,
assetTypeFilter,
assetVisibility,
webhook,
@@ -226,6 +251,7 @@ const {
} = methods;
export {
+ assetAddTags,
assetAddToAlbums,
assetArchive,
assetFavorite,
@@ -235,6 +261,7 @@ export {
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
+ assetTagFilter,
assetTypeFilter,
assetVisibility,
webhook,
diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts
index 9d52630438..e0b6e34da9 100644
--- a/packages/plugin-sdk/src/host-functions.ts
+++ b/packages/plugin-sdk/src/host-functions.ts
@@ -4,6 +4,8 @@ import {
type BulkIdResponseDto,
type BulkIdsDto,
type CreateAlbumDto,
+ type TagBulkAssetsDto,
+ type TagBulkAssetsResponseDto,
} from '@immich/sdk';
declare module 'extism:host' {
@@ -47,6 +49,7 @@ export const availableFunctions = [
'addAssetsToAlbum',
'addAssetsToAlbums',
'httpRequest',
+ 'bulkTagAssets',
] as const;
export const hostFunctions = (authToken: string) => {
@@ -96,5 +99,11 @@ export const hostFunctions = (authToken: string) => {
authToken,
[url, options],
),
+ bulkTagAssets: (dto: TagBulkAssetsDto) =>
+ call<[TagBulkAssetsDto], TagBulkAssetsResponseDto>(
+ 'bulkTagAssets',
+ authToken,
+ [dto],
+ ),
} satisfies Record<(typeof availableFunctions)[number], unknown>;
};
diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts
index 9756d652c3..94c968978d 100644
--- a/packages/plugin-sdk/src/types.ts
+++ b/packages/plugin-sdk/src/types.ts
@@ -1,4 +1,9 @@
-import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
+import type {
+ AssetTypeEnum,
+ AssetVisibility,
+ TagResponseDto,
+ WorkflowType,
+} from '@immich/sdk';
type DeepPartial = T extends Date
? T
@@ -18,6 +23,7 @@ export type WorkflowEventData = WorkflowEventMap[T];
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
+ AssetTagged = 'AssetTagged',
// PersonRecognized = 'PersonRecognized',
}
@@ -88,6 +94,7 @@ export type AssetV1 = {
duplicateId: string | null;
visibility: AssetVisibility;
isEdited: boolean;
+ tags: TagResponseDto[];
exifInfo: {
make: string | null;
model: string | null;
diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts
index 3ac958ce2d..af24b2d21b 100644
--- a/packages/sdk/src/fetch-client.ts
+++ b/packages/sdk/src/fetch-client.ts
@@ -7423,7 +7423,8 @@ export enum WorkflowType {
}
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
- AssetMetadataExtraction = "AssetMetadataExtraction"
+ AssetMetadataExtraction = "AssetMetadataExtraction",
+ AssetTagged = "AssetTagged"
}
export enum QueueJobStatus {
Active = "active",
diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts
index 416f823952..7fedc4eb3a 100644
--- a/server/src/repositories/event.repository.ts
+++ b/server/src/repositories/event.repository.ts
@@ -43,7 +43,7 @@ type EventMap = {
// asset events
AssetCreate: [{ asset: Pick; file?: UploadFile }];
- AssetTag: [{ assetId: string }];
+ AssetTag: [{ assetId: string; userId: string }];
AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }];
AssetShow: [{ assetId: string; userId: string }];
diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts
index 9c946fe583..888ca94440 100644
--- a/server/src/repositories/workflow.repository.ts
+++ b/server/src/repositories/workflow.repository.ts
@@ -8,6 +8,7 @@ import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { DB } from 'src/schema';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
+import { withTags } from 'src/utils/database';
export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>;
@@ -143,6 +144,7 @@ export class WorkflowRepository {
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.select((eb) => [
...columns.workflowAssetV1,
+ withTags,
jsonObjectFrom(
eb
.selectFrom('asset_exif')
diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts
index 3cd442b6a7..08a9b00104 100644
--- a/server/src/services/tag.service.ts
+++ b/server/src/services/tag.service.ts
@@ -93,7 +93,7 @@ export class TagService extends BaseService {
const results = await this.tagRepository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.updateTags(assetId);
- await this.eventRepository.emit('AssetTag', { assetId });
+ await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id });
}
return { count: results.length };
@@ -114,7 +114,7 @@ export class TagService extends BaseService {
}
await this.updateTags(assetId);
- await this.eventRepository.emit('AssetTag', { assetId });
+ await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id });
}
return results;
diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts
index 318f36a4b6..4995eada9c 100644
--- a/server/src/services/workflow-execution.service.ts
+++ b/server/src/services/workflow-execution.service.ts
@@ -13,6 +13,7 @@ import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
+import { TagBulkAssetsDto } from 'src/dtos/tag.dto';
import {
BootstrapEventPriority,
DatabaseLock,
@@ -27,6 +28,7 @@ import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service';
+import { TagService } from 'src/services/tag.service';
import { JobOf } from 'src/types';
const dummy = () => {
@@ -69,6 +71,7 @@ export class WorkflowExecutionService extends BaseService {
this.jwtSecret = this.cryptoRepository.randomBytesAsText(32);
const albumService = BaseService.create(AlbumService, this);
+ const tagService = BaseService.create(TagService, this);
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args));
@@ -106,6 +109,9 @@ export class WorkflowExecutionService extends BaseService {
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
});
+ const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) =>
+ tagService.bulkTagAssets(authDto, ...args),
+ );
const functions = {
searchAlbums,
@@ -113,6 +119,7 @@ export class WorkflowExecutionService extends BaseService {
addAssetsToAlbum,
addAssetsToAlbums,
httpRequest,
+ bulkTagAssets,
};
const stubs: typeof functions = {
@@ -121,6 +128,7 @@ export class WorkflowExecutionService extends BaseService {
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
httpRequest: dummy,
+ bulkTagAssets: dummy,
};
const plugins = await this.pluginRepository.getForLoad();
@@ -309,6 +317,11 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
+ @OnEvent({ name: 'AssetTag' })
+ onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) {
+ return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged });
+ }
+
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts
index c892239c80..a3a9d56c42 100644
--- a/server/src/utils/workflow.ts
+++ b/server/src/utils/workflow.ts
@@ -6,6 +6,7 @@ export const triggerMap: Record = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
// [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
+ [WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1],
};
export const getWorkflowTriggers = () =>
diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte
index 65d115179e..c4e20081dd 100644
--- a/web/src/lib/components/SchemaConfiguration.svelte
+++ b/web/src/lib/components/SchemaConfiguration.svelte
@@ -1,6 +1,7 @@
+
+ {
+ if (option && !tagIds.includes(option.value)) {
+ tagIds.push(option.value);
+ }
+ }}
+ label={$t('tags')}
+ defaultFirstOption
+ options={tags.map((tag) => ({ label: tag.value, value: tag.id }))}
+ placeholder={$t('search_tags')}
+/>
+
+
+ {#each tagIds as id, index (id)}
+ t.id === id)?.name ?? id} onRemove={() => tagIds.splice(index, 1)} />
+ {/each}
+
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 09d882dd7d..f2451b1bc5 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -100,7 +100,7 @@ export type JSONSchemaProperty = {
properties?: Record;
required?: string[];
uiHint?: {
- type?: 'AlbumId' | 'AssetId' | 'PersonId';
+ type?: 'AlbumId' | 'AssetId' | 'PersonId' | 'TagId';
order?: number;
};
};
diff --git a/web/src/lib/utils/workflow.ts b/web/src/lib/utils/workflow.ts
index b8dc44cc67..3a2100e3a4 100644
--- a/web/src/lib/utils/workflow.ts
+++ b/web/src/lib/utils/workflow.ts
@@ -13,6 +13,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction');
}
+ case WorkflowTrigger.AssetTagged: {
+ return $t('trigger_asset_tagged');
+ }
default: {
return type;
}
@@ -30,6 +33,12 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction_description');
}
+ case WorkflowTrigger.AssetTagged: {
+ return $t('trigger_asset_tagged_description');
+ }
+ default: {
+ return type;
+ }
}
};
diff --git a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte
index d3dec41f48..2b8c0d7ffe 100644
--- a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte
+++ b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte
@@ -1,6 +1,6 @@
+
+
+
+ {#if workflow.logging}
+
+
+ {$t('date')}
+ {$t('result')}
+
+
+ {#each entries as entry (entry.id)}
+
+
+
+
+ {DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, {
+ locale: $locale,
+ })}
+
+ {#if entry.triggerDataId}
+
+
+
+ {/if}
+
+
+
+
+ {#if entry.result === WorkflowResult.Completed}
+
+ {$t('workflow_logging_completed')}
+
+ {:else if entry.result === WorkflowResult.Halted}
+
+ {#if entry.lastStep}
+ {$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })}
+ {:else}
+ {$t('workflow_logging_halted')}
+ {/if}
+
+ {:else}
+
+ {#if entry.lastStep}
+ {$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })}
+ {:else}
+ {$t('error')}
+ {/if}
+
+ {/if}
+
+
+
+ {/each}
+ {#if hasNext}
+ ...
+ {/if}
+
+
+
+
+
+
+ {:else}
+
+ {$t('workflow_logging_disabled_description')}
+
+
+ {/if}
+
+
diff --git a/web/src/lib/services/workflow.service.ts b/web/src/lib/services/workflow.service.ts
index 1fba144604..e149775c8d 100644
--- a/web/src/lib/services/workflow.service.ts
+++ b/web/src/lib/services/workflow.service.ts
@@ -15,6 +15,7 @@ import {
mdiDeleteOutline,
mdiDownload,
mdiFileDocumentMultipleOutline,
+ mdiHistory,
mdiPause,
mdiPencil,
mdiPlay,
@@ -24,6 +25,7 @@ import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
+import WorkflowLogsModal from '$lib/modals/WorkflowLogsModal.svelte';
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils';
@@ -112,7 +114,13 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
onAction: () => handleDeleteWorkflow(workflow),
};
- return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
+ const Logs: ActionItem = {
+ title: $t('check_logs'),
+ icon: mdiHistory,
+ onAction: () => modalManager.show(WorkflowLogsModal, { workflow }),
+ };
+
+ return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete, Logs };
};
export const getWorkflowShowSchemaAction = (
diff --git a/web/src/routes/(user)/workflows/+page.svelte b/web/src/routes/(user)/workflows/+page.svelte
index ffb48e9909..91bea47bc0 100644
--- a/web/src/routes/(user)/workflows/+page.svelte
+++ b/web/src/routes/(user)/workflows/+page.svelte
@@ -75,7 +75,7 @@
{:else}
{#each workflows as workflow (workflow.id)}
- {@const { ToggleEnabled, Duplicate, Edit, Delete } = getWorkflowActions($t, workflow)}
+ {@const { ToggleEnabled, Duplicate, Logs, Edit, Delete } = getWorkflowActions($t, workflow)}
@@ -120,6 +120,7 @@
ToggleEnabled,
Edit,
Duplicate,
+ Logs,
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => onToggleExpand(workflow.id)),
MenuItemType.Divider,
Delete,
diff --git a/web/src/routes/(user)/workflows/[workflowId]/+page.svelte b/web/src/routes/(user)/workflows/[workflowId]/+page.svelte
index 13bc645979..ff5b3d4ca8 100644
--- a/web/src/routes/(user)/workflows/[workflowId]/+page.svelte
+++ b/web/src/routes/(user)/workflows/[workflowId]/+page.svelte
@@ -265,7 +265,7 @@
$effect(() => console.log(steps));
- const { Download, Duplicate, CopyJson, Delete } = $derived(
+ const { Download, Duplicate, CopyJson, Delete, Logs } = $derived(
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
);
@@ -280,7 +280,7 @@
{onClose}
translations={{ close: $t('back') }}
closeIcon={mdiArrowLeft}
- actions={[Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
+ actions={[Logs, Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
>
{data.workflow.name}
From 652ef8a427bffe1777fc9733a0b6b425ad4e9ac5 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Fri, 14 Aug 2026 18:23:12 +0530
Subject: [PATCH 75/89] refactor: server capabilities (#30663)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
.../models/server_capability.model.dart | 23 ++++++++++++++++
.../domain/services/sync_stream.service.dart | 5 ++--
.../lib/domain/utils/migrate_cloud_ids.dart | 5 ++--
.../repositories/sync_api.repository.dart | 26 ++++++-------------
.../server_info/server_version.model.dart | 4 ---
.../actions/edit_asset.action.dart | 6 ++---
.../sync_status_and_actions.dart | 3 ++-
7 files changed, 41 insertions(+), 31 deletions(-)
create mode 100644 mobile/lib/domain/models/server_capability.model.dart
diff --git a/mobile/lib/domain/models/server_capability.model.dart b/mobile/lib/domain/models/server_capability.model.dart
new file mode 100644
index 0000000000..ece9850594
--- /dev/null
+++ b/mobile/lib/domain/models/server_capability.model.dart
@@ -0,0 +1,23 @@
+import 'package:immich_mobile/utils/semver.dart';
+
+enum ServerCapability {
+ // Feature Support
+ cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)),
+ bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)),
+ assetEdits(SemVer(major: 2, minor: 6, patch: 0)),
+ assetFacesV2(SemVer(major: 2, minor: 6, patch: 0)),
+ syncV2(SemVer(major: 3, minor: 0, patch: 0)),
+ assetOcr(SemVer(major: 3, minor: 0, patch: 0)),
+
+ // Migrations
+ assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)),
+ assetPayloadChange20260597(SemVer(major: 2, minor: 7, patch: 6));
+
+ const ServerCapability(this.minVersion);
+
+ final SemVer minVersion;
+}
+
+extension ServerCapabilitySupport on SemVer {
+ bool supports(ServerCapability capability) => this >= capability.minVersion;
+}
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index 0eb3ff2f99..71228ba2ba 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -4,6 +4,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/models/server_capability.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';
@@ -122,7 +123,7 @@ class SyncStreamService {
}
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
- semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
+ semVer.supports(.assetPayloadChange20260128)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
@@ -139,7 +140,7 @@ class SyncStreamService {
}
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
- semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
+ semVer.supports(.assetPayloadChange20260597)) {
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);
diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart
index efef6e8327..e1d5fe451e 100644
--- a/mobile/lib/domain/utils/migrate_cloud_ids.dart
+++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -31,12 +32,12 @@ Future syncCloudIds(ProviderContainer ref) async {
await _populateCloudIds(db);
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
- final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
+ final canUpdateMetadata = serverInfo.serverVersion.supports(.cloudIdMetadata);
if (!canUpdateMetadata) {
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
return;
}
- final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
+ final canBulkUpdateMetadata = serverInfo.serverVersion.supports(.bulkCloudIdMetadata);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart
index 303859da2f..4e27da1a3d 100644
--- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart
+++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/constants/constants.dart';
+import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -44,25 +45,16 @@ class SyncApiRepository {
types: [
SyncRequestType.authUsersV1,
SyncRequestType.usersV1,
- serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
- ? SyncRequestType.assetsV2
- : SyncRequestType.assetsV1,
+ serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
- if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
+ if (serverVersion.supports(.assetEdits)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
- serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
- ? SyncRequestType.partnerAssetsV2
- : SyncRequestType.partnerAssetsV1,
+ serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1,
- if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
- SyncRequestType.albumsV1
- else
- SyncRequestType.albumsV2,
+ serverVersion.supports(.syncV2) ? SyncRequestType.albumsV2 : SyncRequestType.albumsV1,
SyncRequestType.albumUsersV1,
- serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
- ? SyncRequestType.albumAssetsV2
- : SyncRequestType.albumAssetsV1,
+ serverVersion.supports(.syncV2) ? SyncRequestType.albumAssetsV2 : SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1,
SyncRequestType.albumToAssetsV1,
SyncRequestType.memoriesV1,
@@ -71,10 +63,8 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1,
- serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
- ? SyncRequestType.assetFacesV2
- : SyncRequestType.assetFacesV1,
- if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
+ serverVersion.supports(.assetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1,
+ if (serverVersion.supports(.assetOcr)) SyncRequestType.assetOcrV1,
],
).toJson(),
);
diff --git a/mobile/lib/models/server_info/server_version.model.dart b/mobile/lib/models/server_info/server_version.model.dart
index 40f35a3cd0..cf16d4db0b 100644
--- a/mobile/lib/models/server_info/server_version.model.dart
+++ b/mobile/lib/models/server_info/server_version.model.dart
@@ -6,8 +6,4 @@ class ServerVersion extends SemVer {
ServerVersion.fromDto(ServerVersionResponseDto dto)
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
-
- bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
- return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
- }
}
diff --git a/mobile/lib/presentation/actions/edit_asset.action.dart b/mobile/lib/presentation/actions/edit_asset.action.dart
index 9b11032df9..ca0fa0bad0 100644
--- a/mobile/lib/presentation/actions/edit_asset.action.dart
+++ b/mobile/lib/presentation/actions/edit_asset.action.dart
@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
+import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
@@ -16,12 +17,9 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/error_handler.dart';
-import 'package:immich_mobile/utils/semver.dart';
-
-const _minimumServerVersion = SemVer(major: 2, minor: 6, patch: 0);
final _stateProvider = Provider.family.autoDispose((ref, source) {
- final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion >= _minimumServerVersion));
+ final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion.supports(.assetEdits)));
if (!isSupported) {
return null;
}
diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
index d2edef832e..69d86b4c36 100644
--- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
+++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -150,7 +151,7 @@ class SyncStatusAndActions extends HookConsumerWidget {
unawaited(ref.read(backgroundSyncProvider).syncRemote());
},
),
- if (CurrentPlatform.isIOS && serverVersion.isAtLeast(major: 2, minor: 5))
+ if (CurrentPlatform.isIOS && serverVersion.supports(.cloudIdMetadata))
SettingListTile(
title: 'Sync Cloud Ids',
leading: const Icon(Icons.cloud_circle_rounded),
From 190a939af7c79a4d1ffbcaeee93dc6da0c17d6b6 Mon Sep 17 00:00:00 2001
From: Adam Gastineau
Date: Fri, 14 Aug 2026 12:21:39 -0700
Subject: [PATCH 76/89] fix(mobile): disable iOS smart quotes/dashes in
email/password inputs (#30767)
* fix(mobile): disable iOS smart quotes/dashes in email/password inputs
* Remove enum prefixes
---
.../pages/common/headers_settings.page.dart | 4 +++
.../lib/widgets/forms/login/login_form.dart | 4 +--
mobile/packages/ui/lib/immich_ui.dart | 1 +
.../ui/lib/src/components/email_input.dart | 26 +++++++++++++++
.../ui/lib/src/components/password_input.dart | 3 ++
.../ui/lib/src/previews/email_input.dart | 28 ++++++++++++++++
.../ui/test/smart_punctuation_test.dart | 32 +++++++++++++++++++
7 files changed, 95 insertions(+), 3 deletions(-)
create mode 100644 mobile/packages/ui/lib/src/components/email_input.dart
create mode 100644 mobile/packages/ui/lib/src/previews/email_input.dart
create mode 100644 mobile/packages/ui/test/smart_punctuation_test.dart
diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart
index 24c5da1c10..c934ca6a46 100644
--- a/mobile/lib/pages/common/headers_settings.page.dart
+++ b/mobile/lib/pages/common/headers_settings.page.dart
@@ -132,6 +132,8 @@ class HeaderKeyValueSettings extends StatelessWidget {
border: const OutlineInputBorder(),
),
autocorrect: false,
+ smartDashesType: .disabled,
+ smartQuotesType: .disabled,
onChanged: (headerKey) {
header.key = headerKey;
},
@@ -160,6 +162,8 @@ class HeaderKeyValueSettings extends StatelessWidget {
border: const OutlineInputBorder(),
),
autocorrect: false,
+ smartDashesType: .disabled,
+ smartQuotesType: .disabled,
onChanged: (headerValue) {
header.value = headerValue;
},
diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart
index 5185c5c318..aa9750a2a5 100644
--- a/mobile/lib/widgets/forms/login/login_form.dart
+++ b/mobile/lib/widgets/forms/login/login_form.dart
@@ -493,14 +493,12 @@ class LoginForm extends HookConsumerWidget {
builder: (context, form) => Column(
spacing: ImmichSpacing.md,
children: [
- ImmichTextInput(
+ ImmichEmailInput(
controller: emailController,
label: context.t.email,
hintText: context.t.login_form_email_hint,
validator: _validateEmail,
keyboardAction: TextInputAction.next,
- keyboardType: TextInputType.emailAddress,
- autofillHints: const [AutofillHints.email],
onSubmit: (_) => passwordFocusNode.requestFocus(),
),
ImmichPasswordInput(
diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart
index 8ea88135e5..85875717a3 100644
--- a/mobile/packages/ui/lib/immich_ui.dart
+++ b/mobile/packages/ui/lib/immich_ui.dart
@@ -1,6 +1,7 @@
export 'src/color_override.dart';
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
+export 'src/components/email_input.dart';
export 'src/components/form.dart';
export 'src/components/formatted_text.dart';
export 'src/components/icon_button.dart';
diff --git a/mobile/packages/ui/lib/src/components/email_input.dart b/mobile/packages/ui/lib/src/components/email_input.dart
new file mode 100644
index 0000000000..27d79e9f0e
--- /dev/null
+++ b/mobile/packages/ui/lib/src/components/email_input.dart
@@ -0,0 +1,26 @@
+import 'package:flutter/services.dart';
+import 'package:immich_ui/src/components/text_input.dart';
+
+/// An ImmichTextInput customized for receiving email addresses
+class ImmichEmailInput extends ImmichTextInput {
+ const ImmichEmailInput({
+ super.key,
+ super.controller,
+ super.focusNode,
+ super.label,
+ super.hintText,
+ super.validator,
+ super.onSubmit,
+ super.keyboardAction,
+ super.suffixIcon,
+ super.enabled,
+ super.autofocus,
+ super.autovalidateMode,
+ }) : super(
+ keyboardType: .emailAddress,
+ autofillHints: const [AutofillHints.email],
+ autocorrect: false,
+ smartDashesType: .disabled,
+ smartQuotesType: .disabled,
+ );
+}
diff --git a/mobile/packages/ui/lib/src/components/password_input.dart b/mobile/packages/ui/lib/src/components/password_input.dart
index d08b3de99d..aa66d43b4d 100644
--- a/mobile/packages/ui/lib/src/components/password_input.dart
+++ b/mobile/packages/ui/lib/src/components/password_input.dart
@@ -47,6 +47,9 @@ class _ImmichPasswordInputState extends State {
onSubmit: widget.onSubmit,
keyboardAction: widget.keyboardAction,
obscureText: !_visible,
+ autocorrect: false,
+ smartDashesType: .disabled,
+ smartQuotesType: .disabled,
suffixIcon: IconButton(
onPressed: _toggleVisibility,
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
diff --git a/mobile/packages/ui/lib/src/previews/email_input.dart b/mobile/packages/ui/lib/src/previews/email_input.dart
new file mode 100644
index 0000000000..b9d1d64ea0
--- /dev/null
+++ b/mobile/packages/ui/lib/src/previews/email_input.dart
@@ -0,0 +1,28 @@
+import 'package:flutter/material.dart';
+import 'package:immich_ui/src/components/email_input.dart';
+import 'package:immich_ui/src/previews.dart';
+
+@ImmichPreview(group: 'EmailInput', name: 'Basic')
+Widget previewEmailInput() => const _PreviewEmailInput();
+
+class _PreviewEmailInput extends StatefulWidget {
+ const _PreviewEmailInput();
+
+ @override
+ State<_PreviewEmailInput> createState() => _PreviewEmailInputState();
+}
+
+class _PreviewEmailInputState extends State<_PreviewEmailInput> {
+ final _controller = TextEditingController();
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ImmichEmailInput(label: 'Email', hintText: 'user@immich.app', controller: _controller);
+ }
+}
diff --git a/mobile/packages/ui/test/smart_punctuation_test.dart b/mobile/packages/ui/test/smart_punctuation_test.dart
new file mode 100644
index 0000000000..93f1f7a92d
--- /dev/null
+++ b/mobile/packages/ui/test/smart_punctuation_test.dart
@@ -0,0 +1,32 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_ui/src/components/email_input.dart';
+import 'package:immich_ui/src/components/password_input.dart';
+
+import 'test_utils.dart';
+
+void main() {
+ EditableText editable(WidgetTester tester) => tester.widget(find.byType(EditableText));
+
+ testWidgets('ImmichEmailInput disables smart punctuation', (tester) async {
+ await tester.pumpTestWidget(const ImmichEmailInput());
+
+ expect(editable(tester).smartDashesType, SmartDashesType.disabled);
+ expect(editable(tester).smartQuotesType, SmartQuotesType.disabled);
+ });
+
+ testWidgets('ImmichPasswordInput disables smart punctuation', (tester) async {
+ await tester.pumpTestWidget(const ImmichPasswordInput());
+
+ expect(editable(tester).obscureText, isTrue);
+ expect(editable(tester).smartDashesType, SmartDashesType.disabled);
+ expect(editable(tester).smartQuotesType, SmartQuotesType.disabled);
+
+ await tester.tap(find.byIcon(Icons.visibility_rounded));
+ await tester.pump();
+
+ expect(editable(tester).obscureText, isFalse);
+ expect(editable(tester).smartDashesType, SmartDashesType.disabled);
+ expect(editable(tester).smartQuotesType, SmartQuotesType.disabled);
+ });
+}
From 66d010381e32563833be4a0cfa72f08bdd572be0 Mon Sep 17 00:00:00 2001
From: cmdPromptCritical <7504765+cmdPromptCritical@users.noreply.github.com>
Date: Fri, 14 Aug 2026 15:30:28 -0400
Subject: [PATCH 77/89] fix(mobile): prevent snapping to center on
pinch-to-zoom release (#29207) (#29343)
Co-authored-by: cmdpromptcritical
---
mobile/lib/widgets/photo_view/src/core/photo_view_core.dart | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart
index c230a65690..a8249b48f9 100644
--- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart
+++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart
@@ -203,7 +203,7 @@ class PhotoViewCoreState extends State
} else if (scaleState == PhotoViewScaleState.zoomedIn) {
animateRotation(controller.rotation, 0);
if (_shouldAllowPanRotate()) {
- animatePosition(controller.position, Offset.zero);
+ animatePosition(controller.position, clampPosition());
}
}
From b19c4a591e4a8d1dcf79e0d4f25edebee98b45da Mon Sep 17 00:00:00 2001
From: Jason Rasmussen
Date: Fri, 14 Aug 2026 16:04:12 -0400
Subject: [PATCH 78/89] chore(web): remove unused code (#30768)
---
web/src/lib/__mocks__/jsdom-url.mock.ts | 9 --
.../lib/attachments/drag-and-drop.svelte.ts | 105 ------------------
.../editor/transform-tool/CropPreset.svelte | 43 -------
.../managers/edit/transform-manager.svelte.ts | 3 -
.../lib/managers/timeline-manager/types.ts | 11 --
.../managers/timeline-manager/utils.svelte.ts | 1 -
web/src/lib/route.ts | 6 -
web/src/lib/services/album.service.ts | 13 ---
web/src/lib/services/user-admin.service.ts | 4 -
web/src/lib/stores/preferences.store.ts | 5 -
web/src/lib/stores/slideshow.store.ts | 6 -
web/src/lib/types.ts | 8 --
web/src/lib/utils/actions.ts | 1 -
web/src/lib/utils/date-time.ts | 9 --
web/src/lib/utils/file-uploader.ts | 21 ----
web/src/lib/utils/layout-utils.ts | 9 --
web/src/lib/utils/navigation.ts | 7 --
web/src/lib/utils/thumbnail-util.ts | 34 ------
web/src/lib/utils/timeline-util.ts | 24 ----
web/src/test-data/factories/user-factory.ts | 11 +-
20 files changed, 1 insertion(+), 329 deletions(-)
delete mode 100644 web/src/lib/__mocks__/jsdom-url.mock.ts
delete mode 100644 web/src/lib/attachments/drag-and-drop.svelte.ts
delete mode 100644 web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte
diff --git a/web/src/lib/__mocks__/jsdom-url.mock.ts b/web/src/lib/__mocks__/jsdom-url.mock.ts
deleted file mode 100644
index d65da98141..0000000000
--- a/web/src/lib/__mocks__/jsdom-url.mock.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-const createObjectURLMock = vi.fn();
-
-// eslint-disable-next-line unicorn/no-top-level-side-effects
-Object.defineProperty(URL, 'createObjectURL', {
- writable: true,
- value: createObjectURLMock,
-});
-
-export { createObjectURLMock };
diff --git a/web/src/lib/attachments/drag-and-drop.svelte.ts b/web/src/lib/attachments/drag-and-drop.svelte.ts
deleted file mode 100644
index 28afd25615..0000000000
--- a/web/src/lib/attachments/drag-and-drop.svelte.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import type { Attachment } from 'svelte/attachments';
-
-export interface DragAndDropOptions {
- index: number;
- onDragStart?: (index: number) => void;
- onDragEnter?: (index: number) => void;
- onDrop?: (e: DragEvent, index: number) => void;
- onDragEnd?: () => void;
- isDragging?: boolean;
- isDragOver?: boolean;
-}
-
-export function dragAndDrop(options: DragAndDropOptions): Attachment {
- return (node: Element) => {
- const element = node as HTMLElement;
- const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
-
- const isFormElement = (el: HTMLElement) => {
- return ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName);
- };
-
- const handleDragStart = (e: DragEvent) => {
- // Prevent drag if it originated from an input, textarea, or select element
- const target = e.target as HTMLElement;
- if (isFormElement(target)) {
- e.preventDefault();
- return;
- }
- onDragStart?.(index);
- };
-
- const handleDragEnter = () => {
- onDragEnter?.(index);
- };
-
- const handleDragOver = (e: DragEvent) => {
- e.preventDefault();
- };
-
- const handleDrop = (e: DragEvent) => {
- onDrop?.(e, index);
- };
-
- const handleDragEnd = () => {
- onDragEnd?.();
- };
-
- // Disable draggable when focusing on form elements (fixes Firefox input interaction)
- const handleFocusIn = (e: FocusEvent) => {
- const target = e.target as HTMLElement;
- if (isFormElement(target)) {
- element.setAttribute('draggable', 'false');
- }
- };
-
- const handleFocusOut = (e: FocusEvent) => {
- const target = e.target as HTMLElement;
- if (isFormElement(target)) {
- element.setAttribute('draggable', 'true');
- }
- };
-
- // Update classes based on drag state
- const updateClasses = (dragging: boolean, dragOver: boolean) => {
- // Remove all drag-related classes first
- element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
-
- // Add back only the active ones
- if (dragging) {
- element.classList.add('opacity-50');
- }
-
- if (dragOver) {
- element.classList.add('border-light-500', 'border-solid');
- element.classList.remove('border-transparent');
- } else {
- element.classList.add('border-transparent');
- }
- };
-
- element.setAttribute('draggable', 'true');
- element.setAttribute('role', 'button');
- element.setAttribute('tabindex', '0');
-
- element.addEventListener('dragstart', handleDragStart);
- element.addEventListener('dragenter', handleDragEnter);
- element.addEventListener('dragover', handleDragOver);
- element.addEventListener('drop', handleDrop);
- element.addEventListener('dragend', handleDragEnd);
- element.addEventListener('focusin', handleFocusIn);
- element.addEventListener('focusout', handleFocusOut);
-
- updateClasses(isDragging || false, isDragOver || false);
-
- return () => {
- element.removeEventListener('dragstart', handleDragStart);
- element.removeEventListener('dragenter', handleDragEnter);
- element.removeEventListener('dragover', handleDragOver);
- element.removeEventListener('drop', handleDrop);
- element.removeEventListener('dragend', handleDragEnd);
- element.removeEventListener('focusin', handleFocusIn);
- element.removeEventListener('focusout', handleFocusOut);
- };
- };
-}
diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte
deleted file mode 100644
index 9817870d93..0000000000
--- a/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts
index 9704af420b..23ddc1f3ea 100644
--- a/web/src/lib/managers/edit/transform-manager.svelte.ts
+++ b/web/src/lib/managers/edit/transform-manager.svelte.ts
@@ -7,9 +7,6 @@ import { getDimensions } from '$lib/utils/asset-utils';
import { normalizeTransformEdits } from '$lib/utils/editor';
import { handleError } from '$lib/utils/handle-error';
-export type CropAspectRatio =
- '1:1' | '16:9' | '4:3' | '3:2' | '7:5' | '9:16' | '3:4' | '2:3' | '5:7' | 'free' | 'reset';
-
type Region = {
x: number;
y: number;
diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts
index 3bd921a657..70a53368fd 100644
--- a/web/src/lib/managers/timeline-manager/types.ts
+++ b/web/src/lib/managers/timeline-manager/types.ts
@@ -47,11 +47,6 @@ export interface Viewport {
height: number;
}
-export type ViewportXY = Viewport & {
- x: number;
- y: number;
-};
-
export interface AddAsset {
type: 'add';
values: TimelineAsset[];
@@ -82,12 +77,6 @@ export type ScrubberMonth = {
title: string;
};
-export type TimelineManagerLayoutOptions = {
- rowHeight?: number;
- headerHeight?: number;
- gap?: number;
-};
-
export interface UpdateGeometryOptions {
invalidateHeight: boolean;
noDefer?: boolean;
diff --git a/web/src/lib/managers/timeline-manager/utils.svelte.ts b/web/src/lib/managers/timeline-manager/utils.svelte.ts
index efc94206ea..377f841f98 100644
--- a/web/src/lib/managers/timeline-manager/utils.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/utils.svelte.ts
@@ -1,6 +1,5 @@
import type { TimelineAsset } from './types';
-export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
export function filterIsInOrNearViewport(items: T[]) {
diff --git a/web/src/lib/route.ts b/web/src/lib/route.ts
index d47340a34b..d0a1a3593d 100644
--- a/web/src/lib/route.ts
+++ b/web/src/lib/route.ts
@@ -38,12 +38,6 @@ const asQueryString = (
return items.length === 0 ? '' : `?${items.join('&')}`;
};
-const DOCS_BASE = 'https://docs.immich.app';
-
-export const Docs = {
- duplicates: () => `${DOCS_BASE}/features/duplicates-utility`,
-};
-
export const Route = {
// auth
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts
index fc09a11215..08b1245e66 100644
--- a/web/src/lib/services/album.service.ts
+++ b/web/src/lib/services/album.service.ts
@@ -286,16 +286,3 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
export const handleDownloadAlbum = async (album: AlbumResponseDto) => {
await downloadArchive(`${album.albumName}.zip`, { albumId: album.id });
};
-
-export const handleConfirmAlbumDelete = async (album: AlbumResponseDto) => {
- const $t = await getFormatter();
- const confirmation =
- album.albumName.length > 0
- ? $t('album_delete_confirmation', { values: { album: album.albumName } })
- : $t('unnamed_album_delete_confirmation');
-
- const description = $t('album_delete_confirmation_description');
- const prompt = `${confirmation} ${description}`;
-
- return modalManager.showDialog({ prompt });
-};
diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts
index 51b39b9d22..00807c84bd 100644
--- a/web/src/lib/services/user-admin.service.ts
+++ b/web/src/lib/services/user-admin.service.ts
@@ -151,10 +151,6 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => {
}
};
-export const handleNavigateUserAdmin = async (user: UserAdminResponseDto) => {
- await goto(`/admin/users/${user.id}`);
-};
-
// TODO move password reset server-side
const generatePassword = (length: number = 16) => {
let generatedPassword = '';
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts
index 2584312220..ccd07d529c 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -70,11 +70,6 @@ export interface PlacesViewSettings {
};
}
-export interface SidebarSettings {
- people: boolean;
- sharing: boolean;
-}
-
export enum SortOrder {
Asc = 'asc',
Desc = 'desc',
diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts
index 17ba266ee1..eb1ec6720e 100644
--- a/web/src/lib/stores/slideshow.store.ts
+++ b/web/src/lib/stores/slideshow.store.ts
@@ -24,12 +24,6 @@ export enum SlideshowMetadataOverlayMode {
Full = 'full',
}
-export const slideshowLookCssMapping: Record = {
- [SlideshowLook.Contain]: 'object-contain',
- [SlideshowLook.Cover]: 'object-cover',
- [SlideshowLook.BlurredBackground]: 'object-contain',
-};
-
function createSlideshowStore() {
const restartState = writable(false);
const stopState = writable(false);
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index f2451b1bc5..0d42b3cdc6 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -3,7 +3,6 @@ import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity';
import { MediaType } from '$lib/constants';
-import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
export type LatLng = { lng: number; lat: number };
@@ -39,13 +38,6 @@ export enum OnboardingRole {
USER = 'user',
}
-export type AssetControlContext = {
- // Wrap assets in a function, because context isn't reactive.
- getAssets: () => TimelineAsset[]; // All assets includes partners' assets
- getOwnedAssets: () => TimelineAsset[]; // Only assets owned by the user
- clearSelect: () => void;
-};
-
export type SearchCameraFilter = {
make?: string;
model?: string;
diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts
index f5733fd5b2..ed5011319c 100644
--- a/web/src/lib/utils/actions.ts
+++ b/web/src/lib/utils/actions.ts
@@ -12,7 +12,6 @@ export type OnUndoDelete = (assets: TimelineAsset[]) => void;
export type OnRestore = (ids: string[]) => void;
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
-export type OnAddToAlbum = (ids: string[], albumId: string) => void;
export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (result: StackResponse) => void;
diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts
index 7cfeadb9dc..dd37776739 100644
--- a/web/src/lib/utils/date-time.ts
+++ b/web/src/lib/utils/date-time.ts
@@ -78,12 +78,3 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string
*/
export const asLocalTimeISO = (date: DateTime) =>
(date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO();
-
-const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
-
-type DayOfWeek = (typeof days)[number];
-export const dayOfWeek = (day: DayOfWeek, options?: { locale?: string; style?: 'long' | 'short' | 'narrow' }) => {
- const fmt = new Intl.DateTimeFormat(options?.locale, { weekday: options?.style ?? 'long', timeZone: 'UTC' });
- // 2021-08-01 is a Sunday
- return fmt.format(new Date(Date.UTC(2021, 7, 1 + days.indexOf(day))));
-};
diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts
index 10ed7b08d3..c926fd9cb6 100644
--- a/web/src/lib/utils/file-uploader.ts
+++ b/web/src/lib/utils/file-uploader.ts
@@ -20,27 +20,6 @@ import { ExecutorQueue } from '$lib/utils/executor-queue';
import { asQueryString } from '$lib/utils/shared-links';
import { handleError } from './handle-error';
-export const addDummyItems = () => {
- uploadAssetsStore.addItem({ id: 'asset-0', file: { name: 'asset0.jpg', size: 123_456 } as File });
- uploadAssetsStore.updateItem('asset-0', { state: UploadState.PENDING });
- uploadAssetsStore.addItem({ id: 'asset-1', file: { name: 'asset1.jpg', size: 123_456 } as File });
- uploadAssetsStore.updateItem('asset-1', { state: UploadState.STARTED });
- uploadAssetsStore.updateProgress('asset-1', 75, 100);
- uploadAssetsStore.addItem({ id: 'asset-2', file: { name: 'asset2.jpg', size: 123_456 } as File });
- uploadAssetsStore.updateItem('asset-2', { state: UploadState.ERROR, error: new Error('Internal server error') });
- uploadAssetsStore.addItem({ id: 'asset-3', file: { name: 'asset3.jpg', size: 123_456 } as File });
- uploadAssetsStore.updateItem('asset-3', { state: UploadState.DUPLICATED, assetId: 'asset-2' });
- uploadAssetsStore.addItem({ id: 'asset-4', file: { name: 'asset3.jpg', size: 123_456 } as File });
- uploadAssetsStore.updateItem('asset-4', { state: UploadState.DUPLICATED, assetId: 'asset-2', isTrashed: true });
- uploadAssetsStore.addItem({ id: 'asset-10', file: { name: 'asset3.jpg', size: 123_456 } as File });
- uploadAssetsStore.updateItem('asset-10', { state: UploadState.DONE });
- uploadAssetsStore.track('error');
- uploadAssetsStore.track('success');
- uploadAssetsStore.track('duplicate');
-};
-
-// addDummyItems();
-
export const uploadExecutionQueue = new ExecutorQueue({ concurrency: 2 });
type FilePickerParam = { multiple?: boolean; extensions?: string[] };
diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts
index 141d201356..f781bde3f0 100644
--- a/web/src/lib/utils/layout-utils.ts
+++ b/web/src/lib/utils/layout-utils.ts
@@ -6,8 +6,6 @@ import { getAssetRatio } from '$lib/utils/asset-utils';
import { isTimelineAsset, isTimelineAssets } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
-export type getJustifiedLayoutFromAssetsFunction = typeof getJustifiedLayoutFromAssets;
-
const useWasm = TUNABLES.LAYOUT.WASM;
export type CommonJustifiedLayout = {
@@ -115,13 +113,6 @@ export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], op
return new Adapter(result);
}
-export const emptyGeometry = () =>
- new Adapter({
- containerHeight: 0,
- widowCount: 0,
- boxes: [],
- });
-
export type CommonPosition = {
top: number;
left: number;
diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts
index 2f88da1f43..9bba61d915 100644
--- a/web/src/lib/utils/navigation.ts
+++ b/web/src/lib/utils/navigation.ts
@@ -15,7 +15,6 @@ export const isPhotosRoute = (route?: string | null) => !!route?.startsWith('/(u
const isSharedLinkSlugRoute = (route?: string | null) => !!route?.startsWith('/(user)/s/[slug]');
export const isSharedLinkRoute = (route?: string | null) =>
!!route?.startsWith('/(user)/share/[key]') || isSharedLinkSlugRoute(route);
-export const isSearchRoute = (route?: string | null) => !!route?.startsWith('/(user)/search');
export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(user)/albums/[albumId=id]');
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');
export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWith('/(user)/locked');
@@ -149,12 +148,6 @@ export const clearQueryParam = async (queryParam: string, url: URL) => {
await goto(url, { keepFocus: true });
};
-export const getQueryValue = (queryKey: string) => {
- const url = location.href;
- const urlObject = new URL(url);
- return urlObject.searchParams.get(queryKey);
-};
-
export const setQueryValue = async (queryKey: string, queryValue: string) => {
const url = location.href;
const urlObject = new URL(url);
diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts
index 24bf377647..dc892c82cb 100644
--- a/web/src/lib/utils/thumbnail-util.ts
+++ b/web/src/lib/utils/thumbnail-util.ts
@@ -4,40 +4,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { locale } from '$lib/stores/preferences.store';
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
-/**
- * Calculate thumbnail size based on number of assets and viewport width
- * @param assetCount Number of assets in the view
- * @param viewWidth viewport width
- * @returns thumbnail size
- */
-export function getThumbnailSize(assetCount: number, viewWidth: number): number {
- if (assetCount < 6) {
- return Math.min(320, Math.floor(viewWidth / assetCount - assetCount));
- }
-
- if (viewWidth > 600) {
- return viewWidth / 7 - 7;
- }
-
- if (viewWidth > 400) {
- return viewWidth / 4 - 6;
- }
-
- if (viewWidth > 300) {
- return viewWidth / 2 - 6;
- }
-
- if (viewWidth > 200) {
- return viewWidth / 2 - 6;
- }
-
- if (viewWidth > 100) {
- return viewWidth / 1 - 6;
- }
-
- return 300;
-}
-
export const getAltText = derived(t, ($t) => {
return (asset: TimelineAsset) => {
const date = fromTimelinePlainDateTime(asset.localDateTime).toJSDate().toLocaleString(get(locale), {
diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts
index e1e717f45a..2d7af5bb60 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -33,36 +33,12 @@ export type ScrubberListener = (scrubberData: {
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime =>
DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime;
-export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelineDateTime =>
- (fromISODateTime(isoDateTime, timeZone) as DateTime).toObject();
-
// used for AssetResponseDto.localDateTime, amongst others
export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC');
export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelineDateTime =>
(fromISODateTimeUTC(isoDateTimeUtc) as DateTime).toObject();
-// used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information
-export const fromISODateTimeTruncateTZToObject = (
- isoDateTimeUtc: string,
- timeZone: string | undefined,
-): TimelineDateTime =>
- (
- fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime
- ).toObject();
-
-// Used to derive a local date time from an ISO string and a UTC offset in hours
-export const fromISODateTimeWithOffsetToObject = (isoDateTimeUtc: string, utcOffsetHours: number): TimelineDateTime => {
- const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
-
- // Apply the offset to get the local time
- // Note: offset is in hours (may be fractional), positive for east of UTC, negative for west
- const localDateTime = utcDateTime.plus({ hours: utcOffsetHours });
-
- // Return as plain object (keeping the local time but in UTC zone context)
- return (localDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime).toObject();
-};
-
export const getTimes = (isoDateTimeUtc: string, localUtcOffsetHours: number) => {
const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
const fileCreatedAt = (utcDateTime as DateTime).toObject();
diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts
index 92d1510d40..7b56c275e2 100644
--- a/web/src/test-data/factories/user-factory.ts
+++ b/web/src/test-data/factories/user-factory.ts
@@ -1,16 +1,7 @@
import { faker } from '@faker-js/faker';
-import { UserAvatarColor, UserStatus, type UserAdminResponseDto, type UserResponseDto } from '@immich/sdk';
+import { UserAvatarColor, UserStatus, type UserAdminResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
-export const userFactory = Sync.makeFactory({
- id: Sync.each(() => faker.string.uuid()),
- email: Sync.each(() => faker.internet.email()),
- name: Sync.each(() => faker.person.fullName()),
- profileImagePath: '',
- avatarColor: UserAvatarColor.Primary,
- profileChangedAt: Sync.each(() => faker.date.recent().toISOString()),
-});
-
export const userAdminFactory = Sync.makeFactory({
id: Sync.each(() => faker.string.uuid()),
email: Sync.each(() => faker.internet.email()),
From 6a61901e79972bbfe5690636a995e156219b9a8f Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Fri, 14 Aug 2026 22:04:32 +0200
Subject: [PATCH 79/89] fix: shared link create validation (#30762)
---
.../specs/server/api/shared-link.e2e-spec.ts | 6 ++---
.../shared-link.controller.spec.ts | 26 +++++++++++++++++--
server/src/dtos/shared-link.dto.ts | 25 +++++++++++++++++-
3 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts
index b9f695b48e..af189f49ac 100644
--- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts
+++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts
@@ -313,17 +313,17 @@ describe('/shared-links', () => {
.send({ type: SharedLinkType.Album });
expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
+ expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }]));
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
+ .send({ type: SharedLinkType.Individual, assetIds: [uuidDto.notFound] });
expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
+ expect(body).toEqual(expect.objectContaining({ message: 'Not found or no asset.share access' }));
});
it('should create a shared link', async () => {
diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts
index dc069ad267..731baa673f 100644
--- a/server/src/controllers/shared-link.controller.spec.ts
+++ b/server/src/controllers/shared-link.controller.spec.ts
@@ -3,7 +3,7 @@ import { Permission, SharedLinkType } from 'src/enum';
import { SharedLinkService } from 'src/services/shared-link.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
-import { factory } from 'test/small.factory';
+import { factory, newUuid } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(SharedLinkController.name, () => {
@@ -43,9 +43,31 @@ describe(SharedLinkController.name, () => {
it('should allow an null expiresAt', async () => {
await request(ctx.getHttpServer())
.post('/shared-links')
- .send({ expiresAt: null, type: SharedLinkType.Individual });
+ .send({ expiresAt: null, type: SharedLinkType.Individual, assetIds: [newUuid()] });
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
});
+
+ it('should not allow an albumId for share type Individual', async () => {
+ const { status, body } = await request(ctx.getHttpServer())
+ .post('/shared-links')
+ .send({ type: SharedLinkType.Individual, assetIds: [newUuid()], albumId: newUuid() });
+ expect(status).toBe(400);
+ expect(body).toEqual(
+ errorDto.validationError([{ path: [], message: 'albumId can only be used with type ALBUM' }]),
+ );
+ expect(service.create).not.toHaveBeenCalled();
+ });
+
+ it('should not allow assetIds for share type Album', async () => {
+ const { status, body } = await request(ctx.getHttpServer())
+ .post('/shared-links')
+ .send({ type: SharedLinkType.Album, assetIds: [newUuid()], albumId: newUuid() });
+ expect(status).toBe(400);
+ expect(body).toEqual(
+ errorDto.validationError([{ path: [], message: 'assetIds can only be used with type INDIVIDUAL' }]),
+ );
+ expect(service.create).not.toHaveBeenCalled();
+ });
});
describe('DELETE /shared-links/:id/assets', () => {
diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts
index 6763f6ab74..d22875f286 100644
--- a/server/src/dtos/shared-link.dto.ts
+++ b/server/src/dtos/shared-link.dto.ts
@@ -3,7 +3,7 @@ import { SharedLink } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
-import { SharedLinkTypeSchema } from 'src/enum';
+import { SharedLinkType, SharedLinkTypeSchema } from 'src/enum';
import { isoDatetimeToDate } from 'src/validation';
import z from 'zod';
@@ -31,6 +31,29 @@ const SharedLinkCreateSchema = z
allowDownload: z.boolean().default(true).optional().describe('Allow downloads'),
showMetadata: z.boolean().default(true).optional().describe('Show metadata'),
})
+ .superRefine(({ type, albumId, assetIds }, ctx) => {
+ switch (type) {
+ case SharedLinkType.Album: {
+ if (!albumId) {
+ ctx.addIssue(`albumId is required for type ${SharedLinkType.Album}`);
+ }
+ if (assetIds) {
+ ctx.addIssue(`assetIds can only be used with type ${SharedLinkType.Individual}`);
+ }
+ return;
+ }
+ case SharedLinkType.Individual: {
+ if (!assetIds || assetIds.length === 0) {
+ ctx.addIssue(`assetIds are required for type ${SharedLinkType.Individual}`);
+ }
+
+ if (albumId) {
+ ctx.addIssue(`albumId can only be used with type ${SharedLinkType.Album}`);
+ }
+ return;
+ }
+ }
+ })
.meta({ id: 'SharedLinkCreateDto' });
const SharedLinkEditSchema = z
From 14241ac7bc17c2b1af222f94e12355247bd5d4bb Mon Sep 17 00:00:00 2001
From: Jason Rasmussen
Date: Fri, 14 Aug 2026 16:58:14 -0400
Subject: [PATCH 80/89] chore(server): remove unused code (#30772)
chore(server): remove unnused code
---
e2e/src/fixtures.ts | 18 ---------
e2e/src/responses.ts | 35 ------------------
.../ui/generators/timeline/timeline-config.ts | 7 ----
e2e/src/ui/specs/timeline/utils.ts | 14 +------
server/src/cores/storage.core.ts | 5 ---
server/src/enum.ts | 8 ----
.../src/maintenance/maintenance-auth.guard.ts | 17 +--------
.../src/repositories/database.repository.ts | 29 ---------------
.../machine-learning.repository.ts | 1 -
server/src/repositories/person.repository.ts | 4 +-
server/src/repositories/search.repository.ts | 2 -
.../repositories/server-info.repository.ts | 10 -----
server/src/repositories/session.repository.ts | 2 -
server/src/services/library.service.ts | 4 --
server/src/types.ts | 33 -----------------
server/src/utils/database.ts | 2 -
server/src/utils/media.ts | 4 --
server/test/fixtures/auth.stub.ts | 13 -------
server/test/fixtures/file.stub.ts | 7 ----
server/test/fixtures/media.stub.ts | 22 -----------
server/test/medium/responses.ts | 27 --------------
server/test/small.factory.ts | 32 +---------------
server/test/utils.ts | 37 -------------------
23 files changed, 5 insertions(+), 328 deletions(-)
diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts
index 1e03ad6d24..a43e2c3e69 100644
--- a/e2e/src/fixtures.ts
+++ b/e2e/src/fixtures.ts
@@ -1,9 +1,7 @@
export const uuidDto = {
- invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
dummy: '00000000-0000-4000-a000-000000000001',
- dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {
@@ -57,22 +55,6 @@ export const createUserDto = {
};
export const userDto = {
- admin: {
- name: signupDto.admin.name,
- email: signupDto.admin.email,
- password: signupDto.admin.password,
- storageLabel: 'admin',
- oauthId: '',
- shouldChangePassword: false,
- profileImagePath: '',
- createdAt: new Date('2021-01-01'),
- deletedAt: null,
- updatedAt: new Date('2021-01-01'),
- tags: [],
- assets: [],
- quotaSizeInBytes: null,
- quotaUsageInBytes: 0,
- },
user1: {
name: createUserDto.user1.name,
email: createUserDto.user1.email,
diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts
index 1b3447767e..337d101cb4 100644
--- a/e2e/src/responses.ts
+++ b/e2e/src/responses.ts
@@ -40,41 +40,6 @@ export const errorDto = {
},
};
-export const signupResponseDto = {
- admin: {
- avatarColor: expect.any(String),
- id: expect.any(String),
- name: 'Immich Admin',
- email: 'admin@immich.cloud',
- storageLabel: 'admin',
- profileImagePath: '',
- // why? lol
- shouldChangePassword: true,
- isAdmin: true,
- createdAt: expect.any(String),
- updatedAt: expect.any(String),
- deletedAt: null,
- oauthId: '',
- quotaUsageInBytes: 0,
- quotaSizeInBytes: null,
- status: 'active',
- license: null,
- profileChangedAt: expect.any(String),
- },
-};
-
-export const loginResponseDto = {
- admin: {
- accessToken: expect.any(String),
- name: 'Immich Admin',
- isAdmin: true,
- isOnboarded: false,
- profileImagePath: '',
- shouldChangePassword: true,
- userEmail: 'admin@immich.cloud',
- userId: expect.any(String),
- },
-};
export const deviceDto = {
current: {
id: expect.any(String),
diff --git a/e2e/src/ui/generators/timeline/timeline-config.ts b/e2e/src/ui/generators/timeline/timeline-config.ts
index a3b59bb8c8..5bfb2c1800 100644
--- a/e2e/src/ui/generators/timeline/timeline-config.ts
+++ b/e2e/src/ui/generators/timeline/timeline-config.ts
@@ -24,13 +24,6 @@ export const ASPECT_RATIO_WEIGHTS = {
'3:1': 0.01, // 1% 3:1 panorama
} as const;
-export type AspectRatio = {
- width: number;
- height: number;
- ratio: number;
- name: string;
-};
-
// Mock configuration for asset generation - will be transformed to API response formats
export type MockTimelineAsset = {
id: string;
diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts
index dad60266b9..986f794fb6 100644
--- a/e2e/src/ui/specs/timeline/utils.ts
+++ b/e2e/src/ui/specs/timeline/utils.ts
@@ -1,4 +1,4 @@
-import { BrowserContext, expect, Page } from '@playwright/test';
+import { expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/ui/generators/timeline';
@@ -11,18 +11,6 @@ export const padYearMonth = (yearMonth: string) => {
return `${year}-${month.padStart(2, '0')}`;
};
-export async function throttlePage(context: BrowserContext, page: Page) {
- const session = await context.newCDPSession(page);
- await session.send('Network.emulateNetworkConditions', {
- offline: false,
- downloadThroughput: (1.5 * 1024 * 1024) / 8,
- uploadThroughput: (750 * 1024) / 8,
- latency: 40,
- connectionType: 'cellular3g',
- });
- await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
-}
-
export const poll = async (
page: Page,
query: () => Promise,
diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts
index 185ab95e62..07b753f820 100644
--- a/server/src/cores/storage.core.ts
+++ b/server/src/cores/storage.core.ts
@@ -1,4 +1,3 @@
-import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { StorageAsset } from 'src/database';
import {
@@ -352,10 +351,6 @@ export class StorageCore {
return join(StorageCore.getNestedFolder(folder, ownerId, filename), filename);
}
- static getTempPathInDir(dir: string): string {
- return join(dir, `${randomUUID()}.tmp`);
- }
-
private async getDevices() {
try {
return await this.storageRepository.readdir('/dev/dri');
diff --git a/server/src/enum.ts b/server/src/enum.ts
index 8882b554f3..e88a9a667c 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -1211,14 +1211,6 @@ export enum ApiTag {
Workflows = 'Workflows',
}
-export enum PluginContext {
- Asset = 'asset',
- Album = 'album',
- Person = 'person',
-}
-
-export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
-
export const WorkflowTriggerSchema = z
.enum(WorkflowTrigger)
.describe('Plugin trigger type')
diff --git a/server/src/maintenance/maintenance-auth.guard.ts b/server/src/maintenance/maintenance-auth.guard.ts
index 08aaad516b..e2c472ff71 100644
--- a/server/src/maintenance/maintenance-auth.guard.ts
+++ b/server/src/maintenance/maintenance-auth.guard.ts
@@ -1,11 +1,4 @@
-import {
- CanActivate,
- ExecutionContext,
- Injectable,
- SetMetadata,
- applyDecorators,
- createParamDecorator,
-} from '@nestjs/common';
+import { CanActivate, ExecutionContext, Injectable, SetMetadata, applyDecorators } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
@@ -22,14 +15,6 @@ export interface MaintenanceAuthRequest extends Request {
auth?: MaintenanceAuthDto;
}
-export interface MaintenanceAuthenticatedRequest extends Request {
- auth: MaintenanceAuthDto;
-}
-
-export const MaintenanceAuth = createParamDecorator((data, context: ExecutionContext): MaintenanceAuthDto => {
- return context.switchToHttp().getRequest().auth;
-});
-
@Injectable()
export class MaintenanceAuthGuard implements CanActivate {
constructor(
diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts
index 1b5592d356..165752783a 100644
--- a/server/src/repositories/database.repository.ts
+++ b/server/src/repositories/database.repository.ts
@@ -483,35 +483,6 @@ export class DatabaseRepository {
await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection);
}
- async revertLastMigration(): Promise {
- this.logger.debug('Reverting last migration');
-
- const migrator = this.createMigrator();
- const { error, results } = await migrator.migrateDown();
-
- for (const result of results ?? []) {
- if (result.status === 'Success') {
- this.logger.log(`Reverted migration "${result.migrationName}"`);
- } else if (result.status === 'Error') {
- this.logger.warn(`Failed to revert migration "${result.migrationName}"`);
- }
- }
-
- if (error) {
- this.logger.error(`Failed to revert migrations: ${error}`);
- throw error;
- }
-
- const reverted = results?.find((result) => result.direction === 'Down' && result.status === 'Success');
- if (!reverted) {
- this.logger.debug('No migrations to revert');
- return undefined;
- }
-
- this.logger.debug('Finished reverting migration');
- return reverted.migrationName;
- }
-
private createMigrator(): Migrator {
return new Migrator({
db: this.db,
diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts
index c9825f31c1..a05ddbc866 100644
--- a/server/src/repositories/machine-learning.repository.ts
+++ b/server/src/repositories/machine-learning.repository.ts
@@ -73,7 +73,6 @@ export interface Face {
}
export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse;
-export type DetectedFaces = { faces: Face[] } & VisualResponse;
export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest | OcrRequest;
export type TextEncodingOptions = ModelOptions & { language?: string };
diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts
index 0db03a18c7..8ea6ed50fb 100644
--- a/server/src/repositories/person.repository.ts
+++ b/server/src/repositories/person.repository.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely';
+import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database';
@@ -60,8 +60,6 @@ export interface GetAllFacesOptions {
export type UnassignFacesOptions = DeleteFacesOptions;
-export type SelectFaceOptions = (keyof Selectable)[];
-
const withPerson = (eb: ExpressionBuilder) => {
return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts
index 70ba09ef2c..4bde10f165 100644
--- a/server/src/repositories/search.repository.ts
+++ b/server/src/repositories/search.repository.ts
@@ -158,8 +158,6 @@ export type SmartSearchOptions = SearchDateOptions &
SearchTagOptions &
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
-export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
-
export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number };
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts
index 285ab4ee80..5cfded148d 100644
--- a/server/src/repositories/server-info.repository.ts
+++ b/server/src/repositories/server-info.repository.ts
@@ -8,16 +8,6 @@ import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
-export interface GitHubRelease {
- id: number;
- url: string;
- tag_name: string;
- name: string;
- created_at: string;
- published_at: string;
- body: string;
-}
-
export interface VersionResponse {
version: string;
published_at: string;
diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts
index f1cb541b39..58a5e721c0 100644
--- a/server/src/repositories/session.repository.ts
+++ b/server/src/repositories/session.repository.ts
@@ -9,8 +9,6 @@ import { DB } from 'src/schema';
import { SessionTable } from 'src/schema/tables/session.table';
import { asUuid } from 'src/utils/database';
-export type SessionSearchOptions = { updatedBefore: Date };
-
@Injectable()
export class SessionRepository {
constructor(@InjectKysely() private db: Kysely) {}
diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts
index ac6112dcd7..fa405b9d22 100644
--- a/server/src/services/library.service.ts
+++ b/server/src/services/library.service.ts
@@ -444,10 +444,6 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LibrarySyncAssetsQueueAll, data: { id } });
}
- async queueScanAll() {
- await this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: {} });
- }
-
@OnJob({ name: JobName.LibraryScanQueueAll, queue: QueueName.Library })
async handleQueueScanAll(): Promise {
this.logger.log(`Initiating scan of all external libraries...`);
diff --git a/server/src/types.ts b/server/src/types.ts
index 27995e841f..d31841ffd3 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -1,4 +1,3 @@
-import { WorkflowTrigger } from '@immich/plugin-sdk';
import { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
@@ -30,7 +29,6 @@ import {
SystemMetadataKey,
TranscodeTarget,
UserMetadataKey,
- WorkflowType,
} from 'src/enum';
import { Mocked } from 'vitest';
@@ -79,14 +77,6 @@ export interface DecodeToBufferOptions extends DecodeImageOptions {
export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions;
export type GenerateThumbhashOptions = DecodeImageOptions;
-export interface GenerateThumbnailsOptions {
- colorspace: string;
- preview?: ImageOptions;
- processInvalidImages: boolean;
- thumbhash?: boolean;
- thumbnail?: ImageOptions;
-}
-
export interface VideoStreamInfo {
index: number;
height: number;
@@ -143,10 +133,6 @@ export interface ImageDimensions {
height: number;
}
-export interface InputDimensions extends ImageDimensions {
- inputPath: string;
-}
-
export interface VideoInfo {
format: VideoFormat;
videoStreams: VideoStreamInfo[];
@@ -189,11 +175,6 @@ export interface BitrateDistribution {
unit: string;
}
-export interface ImageBuffer {
- data: Buffer;
- info: RawImageInfo;
-}
-
export interface VideoCodecSWConfig {
getCommand(
target: TranscodeTarget,
@@ -260,18 +241,10 @@ export interface ILibraryBulkIdsJob {
totalAssets: number;
}
-export interface IBulkEntityJob {
- ids: string[];
-}
-
export interface IDeleteFilesJob extends IBaseJob {
files: Array;
}
-export interface ISidecarWriteJob extends IEntityJob {
- tags?: true;
-}
-
export interface IDeferrableJob extends IEntityJob {
deferred?: boolean;
}
@@ -307,12 +280,6 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
recipientId: string;
}
-export type IWorkflowJob = {
- id: string;
- trigger: WorkflowTrigger;
- type: T;
-};
-
export interface IIntegrityJob {
refreshOnly?: boolean;
}
diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts
index 0f4d8775b6..5122b0a9d4 100644
--- a/server/src/utils/database.ts
+++ b/server/src/utils/database.ts
@@ -81,8 +81,6 @@ export const asUuid = (id: string | Expression) => sql`${id}::uu
export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`;
-export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`;
-
export const unnest = (array: string[]) => sql>`unnest(array[${sql.join(array)}]::text[])`;
export const removeUndefinedKeys = (update: T, template: unknown) => {
diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts
index aea821d6d5..656e5bf441 100644
--- a/server/src/utils/media.ts
+++ b/server/src/utils/media.ts
@@ -712,10 +712,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
: ['-cq:v', String(this.config.crf)];
}
- getThreadOptions() {
- return [];
- }
-
getEncoderOptions(): string[] {
const out = this.getOutputThreadOptions();
if (this.tune.strictGop) {
diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts
index 88a2278898..7ac7c14978 100644
--- a/server/test/fixtures/auth.stub.ts
+++ b/server/test/fixtures/auth.stub.ts
@@ -28,19 +28,6 @@ export const authStub = {
id: 'token-id',
} as AuthSession,
}),
- user2: Object.freeze({
- user: {
- id: 'user-2',
- name: 'User 2',
- email: 'user2@immich.cloud',
- isAdmin: false,
- quotaSizeInBytes: null,
- quotaUsageInBytes: 0,
- },
- session: {
- id: 'token-id',
- } as AuthSession,
- }),
adminWithElevatedPermission: Object.freeze({
user: authUser.admin,
session: {
diff --git a/server/test/fixtures/file.stub.ts b/server/test/fixtures/file.stub.ts
index f63d2d10fb..e64b769496 100644
--- a/server/test/fixtures/file.stub.ts
+++ b/server/test/fixtures/file.stub.ts
@@ -6,13 +6,6 @@ export const fileStub = {
originalName: 'asset_1.jpeg',
size: 42,
}),
- livePhotoMotion: Object.freeze({
- uuid: 'live-photo-motion-asset',
- originalPath: 'fake_path/asset_1.mp4',
- checksum: Buffer.from('live photo file hash', 'utf8'),
- originalName: 'asset_1.mp4',
- size: 69,
- }),
photo: Object.freeze({
uuid: 'photo',
originalPath: 'fake_path/photo1.jpeg',
diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts
index 77534fd6cb..4421a09801 100644
--- a/server/test/fixtures/media.stub.ts
+++ b/server/test/fixtures/media.stub.ts
@@ -451,28 +451,6 @@ export const videoInfoStub = {
},
],
}),
- videoStreamWithProfileLevel: Object.freeze({
- ...probeStubDefault,
- videoStreams: [
- {
- ...probeStubDefaultVideoStream[0],
- codecName: 'h264',
- profile: 100,
- level: 40,
- },
- ],
- }),
- audioStreamAAC: Object.freeze({
- ...probeStubDefault,
- audioStreams: [
- {
- index: 1,
- codecName: 'aac',
- profile: 2,
- bitrate: 128_000,
- },
- ],
- }),
};
interface SelectedStreams {
diff --git a/server/test/medium/responses.ts b/server/test/medium/responses.ts
index 9b75bc85ec..e8509e339b 100644
--- a/server/test/medium/responses.ts
+++ b/server/test/medium/responses.ts
@@ -1,27 +1,6 @@
import { expect } from 'vitest';
export const errorDto = {
- unauthorized: {
- message: 'Authentication required',
- },
- forbidden: {
- message: expect.any(String),
- },
- missingPermission: (permission: string) => ({
- message: `Missing required permission: ${permission}`,
- }),
- wrongPassword: {
- message: 'Wrong password',
- },
- invalidToken: {
- message: 'Invalid user token',
- },
- invalidShareKey: {
- message: 'Invalid share key',
- },
- invalidSharePassword: {
- message: 'Invalid password',
- },
badRequest: (message: any = null) => ({
message: message ?? expect.anything(),
}),
@@ -29,10 +8,4 @@ export const errorDto = {
message: 'Validation failed',
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
}),
- noPermission: {
- message: expect.stringContaining('Not found or no'),
- },
- incorrectLogin: {
- message: 'Incorrect email or password',
- },
};
diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts
index ae83727d70..545c05e881 100644
--- a/server/test/small.factory.ts
+++ b/server/test/small.factory.ts
@@ -1,7 +1,7 @@
-import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database';
+import { AuthApiKey, AuthSharedLink, AuthUser, Library, UserAdmin } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
-import { AssetFileType, Permission, UserStatus } from 'src/enum';
+import { Permission, UserStatus } from 'src/enum';
import { v4, v7 } from 'uuid';
import { expect } from 'vitest';
@@ -168,31 +168,6 @@ const versionHistoryFactory = () => ({
version: '1.123.45',
});
-const assetSidecarWriteFactory = () => {
- const id = newUuid();
- return {
- id,
- originalPath: '/path/to/original-path.jpg.xmp',
- tags: [],
- files: [
- {
- id: newUuid(),
- path: '/path/to/original-path.jpg.xmp',
- type: AssetFileType.Sidecar,
- isEdited: false,
- },
- ],
- exifInfo: {
- assetId: id,
- description: 'this is a description',
- latitude: 12,
- longitude: 12,
- dateTimeOriginal: '2023-11-22T04:56:12.196Z',
- timeZone: 'UTC-6',
- } as unknown as Exif,
- };
-};
-
const assetOcrFactory = (
ocr: {
id?: string;
@@ -236,9 +211,6 @@ export const factory = {
library: libraryFactory,
queueStatistics: queueStatisticsFactory,
versionHistory: versionHistoryFactory,
- jobAssets: {
- sidecarWrite: assetSidecarWriteFactory,
- },
uuid: newUuid,
buffer: () => Buffer.from('this is a fake buffer'),
date: newDate,
diff --git a/server/test/utils.ts b/server/test/utils.ts
index 2f32f3b412..e1920a4a79 100644
--- a/server/test/utils.ts
+++ b/server/test/utils.ts
@@ -553,43 +553,6 @@ export const mockDuplex =
return duplex;
};
-export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => {
- const stdoutStream = new Readable({
- read() {
- this.push(stdout); // write mock data to stdout
- this.push(null); // end stream
- },
- });
-
- return {
- stdout: stdoutStream,
- stderr: new Readable({
- read() {
- this.push(stderr); // write mock data to stderr
- this.push(null); // end stream
- },
- }),
- stdin: new Writable({
- write(chunk, encoding, callback) {
- callback();
- },
- }),
- exitCode,
- on: vitest.fn((event, callback: any) => {
- if (event === 'close') {
- stdoutStream.once('end', () => callback(0));
- }
- if (event === 'error' && error) {
- stdoutStream.once('end', () => callback(error));
- }
- if (event === 'exit') {
- stdoutStream.once('end', () => callback(exitCode));
- }
- }),
- kill: vitest.fn(),
- } as unknown as ChildProcessWithoutNullStreams;
-});
-
export async function* makeStream(items: T[] = []): AsyncGenerator {
for (const item of items) {
await Promise.resolve();
From 342a9472c4aab26697b1b8bcdfca66ee806e348c Mon Sep 17 00:00:00 2001
From: Jason Rasmussen
Date: Fri, 14 Aug 2026 17:00:13 -0400
Subject: [PATCH 81/89] refactor: duplicate e2e (#30773)
---
e2e/src/api/specs/duplicate.e2e-spec.ts | 651 ------------------
server/test/medium.factory.ts | 2 +
.../specs/services/duplicate.service.spec.ts | 622 +++++++++++++++++
3 files changed, 624 insertions(+), 651 deletions(-)
delete mode 100644 e2e/src/api/specs/duplicate.e2e-spec.ts
create mode 100644 server/test/medium/specs/services/duplicate.service.spec.ts
diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts
deleted file mode 100644
index d6d0ec1394..0000000000
--- a/e2e/src/api/specs/duplicate.e2e-spec.ts
+++ /dev/null
@@ -1,651 +0,0 @@
-import { LoginResponseDto } from '@immich/sdk';
-import { createUserDto, uuidDto } from 'src/fixtures';
-import { errorDto } from 'src/responses';
-import { app, utils } from 'src/utils';
-import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
-
-describe('/duplicates', () => {
- let admin: LoginResponseDto;
- let user1: LoginResponseDto;
- let user2: LoginResponseDto;
-
- beforeAll(async () => {
- await utils.resetDatabase();
-
- admin = await utils.adminSetup();
-
- [user1, user2] = await Promise.all([
- utils.userSetup(admin.accessToken, createUserDto.user1),
- utils.userSetup(admin.accessToken, createUserDto.user2),
- ]);
- });
-
- beforeEach(async () => {
- // Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
- // Note: We don't reset users since they're set up once in beforeAll
- // Stack must be reset before asset due to foreign key constraint
- await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
- });
-
- describe('GET /duplicates', () => {
- it('should return empty array when no duplicates', async () => {
- const { status, body } = await request(app)
- .get('/duplicates')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual([]);
- });
-
- it('should return duplicate groups with suggestedKeepAssetIds', async () => {
- // Create assets with different file sizes for duplicate detection
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Manually set duplicateId on both assets to create a duplicate group
- const duplicateId = '00000000-0000-4000-8000-000000000001';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .get('/duplicates')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual([
- {
- duplicateId,
- assets: expect.arrayContaining([
- expect.objectContaining({ id: asset1.id }),
- expect.objectContaining({ id: asset2.id }),
- ]),
- suggestedKeepAssetIds: expect.any(Array),
- },
- ]);
- expect(body[0].suggestedKeepAssetIds.length).toBe(1);
- });
- });
-
- describe('POST /duplicates/resolve', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .send({
- groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
- });
-
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
- it('should return failure for non-existent duplicate group', async () => {
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
- });
-
- expect(status).toBe(200);
- expect(body).toEqual({
- status: 'COMPLETED',
- results: [
- {
- duplicateId: uuidDto.dummy,
- status: 'FAILED',
- reason: expect.stringContaining('not found or access denied'),
- },
- ],
- });
- });
-
- it('should resolve duplicate group with keepers', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000002';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body).toEqual({
- status: 'COMPLETED',
- results: [
- {
- duplicateId,
- status: 'SUCCESS',
- },
- ],
- });
-
- // Verify side effects: duplicateId cleared on kept asset
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.duplicateId).toBeNull();
-
- // Verify side effects: trashed asset is trashed and duplicateId cleared
- const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
- expect(trashedAsset.isTrashed).toBe(true);
- expect(trashedAsset.duplicateId).toBeNull();
- });
-
- it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000003';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('FAILED');
- expect(body.results[0].reason).toContain('disjoint');
- });
-
- it('should require keepAssetIds when partially trashing', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000004';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('FAILED');
- expect(body.results[0].reason).toContain('must cover all assets');
- });
-
- it('should reject partial resolution (not all assets covered)', async () => {
- const [asset1, asset2, asset3] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000010';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('FAILED');
- expect(body.results[0].reason).toContain('must cover all assets');
- });
-
- it('should reject asset not in duplicate group', async () => {
- const [asset1, asset2, outsideAsset] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000011';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('FAILED');
- expect(body.results[0].reason).toContain('not a member of duplicate group');
- });
-
- it('should allow trash-all without keepers', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000012';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body).toEqual({
- status: 'COMPLETED',
- results: [
- {
- duplicateId,
- status: 'SUCCESS',
- },
- ],
- });
-
- // Verify both assets are trashed
- const [asset1Info, asset2Info] = await Promise.all([
- utils.getAssetInfo(user1.accessToken, asset1.id),
- utils.getAssetInfo(user1.accessToken, asset2.id),
- ]);
-
- expect(asset1Info.isTrashed).toBe(true);
- expect(asset1Info.duplicateId).toBeNull();
- expect(asset2Info.isTrashed).toBe(true);
- expect(asset2Info.duplicateId).toBeNull();
- });
-
- it('should reject cross-user duplicate group access', async () => {
- const asset1 = await utils.createAsset(user1.accessToken);
- const asset2 = await utils.createAsset(user2.accessToken);
-
- const duplicateId = '00000000-0000-4000-8000-000000000013';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
-
- // User1 tries to resolve a group containing user2's asset
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('FAILED');
- expect(body.results[0].reason).toContain('not a member of duplicate group');
- });
-
- it('should synchronize favorites when enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Mark one asset as favorite
- await request(app)
- .put('/assets')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [asset2.id], isFavorite: true });
-
- const duplicateId = '00000000-0000-4000-8000-000000000020';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify favorite was synchronized to keeper
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.isFavorite).toBe(true);
- expect(keptAsset.duplicateId).toBeNull();
- });
-
- it('should synchronize visibility when enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Archive one asset
- await utils.archiveAssets(user1.accessToken, [asset2.id]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000021';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify visibility was synchronized to keeper
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.visibility).toBe('archive');
- expect(keptAsset.duplicateId).toBeNull();
- });
-
- it('should synchronize rating when enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Set rating on one asset
- await request(app)
- .put('/assets')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [asset2.id], rating: 5 });
-
- const duplicateId = '00000000-0000-4000-8000-000000000022';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify rating was synchronized to keeper
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.exifInfo?.rating).toBe(5);
- expect(keptAsset.duplicateId).toBeNull();
- });
-
- it('should synchronize description when enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Set description on one asset
- await request(app)
- .put('/assets')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [asset2.id], description: 'Test description for duplicate' });
-
- const duplicateId = '00000000-0000-4000-8000-000000000023';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify description was synchronized to keeper
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
- expect(keptAsset.duplicateId).toBeNull();
- });
-
- it('should synchronize location when enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Set location on one asset
- await request(app)
- .put('/assets')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
-
- const duplicateId = '00000000-0000-4000-8000-000000000024';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify location was synchronized to keeper
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
- expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
- expect(keptAsset.duplicateId).toBeNull();
- });
-
- it('should synchronize albums when enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Create albums and add assets to different albums
- const album1 = await utils.createAlbum(user1.accessToken, {
- albumName: 'Album 1',
- assetIds: [asset1.id],
- });
- const album2 = await utils.createAlbum(user1.accessToken, {
- albumName: 'Album 2',
- assetIds: [asset2.id],
- });
-
- const duplicateId = '00000000-0000-4000-8000-000000000025';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify keeper is now in both albums
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.duplicateId).toBeNull();
-
- // Check albums directly
- const { status: album1Status, body: album1Body } = await request(app)
- .get(`/albums/${album1.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
- const { status: album2Status, body: album2Body } = await request(app)
- .get(`/albums/${album2.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(album1Status).toBe(200);
- expect(album2Status).toBe(200);
- expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
- expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
- });
-
- it('should synchronize tags when enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- // Wait for metadata extraction to complete before adding tags
- // Otherwise, metadata jobs will race and overwrite our tags
- await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-
- // Create tags and tag assets differently
- const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
- await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
- await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000026';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify keeper has both tags
- const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(keptAsset.duplicateId).toBeNull();
- expect(keptAsset.tags).toBeDefined();
- const tagIds = keptAsset.tags?.map((t) => t.id) || [];
- expect(tagIds).toContain(tags[0].id);
- expect(tagIds).toContain(tags[1].id);
- });
-
- it('should handle batch resolve with mixed success and failure', async () => {
- // Create first group that will succeed
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
- const duplicateId1 = '00000000-0000-4000-8000-000000000027';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
-
- // Create second group with non-existent duplicate ID (will fail)
- const fakeId = '00000000-0000-4000-8000-000000000099';
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [
- { duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
- { duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
- ],
- });
-
- expect(status).toBe(200);
- expect(body.status).toBe('COMPLETED');
- expect(body.results).toHaveLength(2);
-
- // First group should succeed
- expect(body.results[0].duplicateId).toBe(duplicateId1);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Second group should fail
- expect(body.results[1].duplicateId).toBe(fakeId);
- expect(body.results[1].status).toBe('FAILED');
- expect(body.results[1].reason).toContain('not found or access denied');
-
- // Verify first group was actually resolved despite second failure
- const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
- expect(asset1Info.duplicateId).toBeNull();
- const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
- expect(asset2Info.isTrashed).toBe(true);
- });
-
- it('should trash assets when trash is enabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000028';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- // Ensure trash is enabled (default)
- const config = await utils.getSystemConfig(admin.accessToken);
- expect(config.trash.enabled).toBe(true);
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Verify asset is trashed (not deleted)
- const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
- expect(trashedAsset.isTrashed).toBe(true);
- });
-
- it('should delete assets when trash is disabled', async () => {
- const [asset1, asset2] = await Promise.all([
- utils.createAsset(user1.accessToken),
- utils.createAsset(user1.accessToken),
- ]);
-
- const duplicateId = '00000000-0000-4000-8000-000000000029';
- await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
- await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
-
- // Disable trash
- await request(app)
- .put('/system-config')
- .set('Authorization', `Bearer ${admin.accessToken}`)
- .send({
- trash: { enabled: false, days: 30 },
- });
-
- const { status, body } = await request(app)
- .post('/duplicates/resolve')
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({
- groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
- });
-
- expect(status).toBe(200);
- expect(body.results[0].status).toBe('SUCCESS');
-
- // Asset should be marked as deleted (force delete)
- const { status: getStatus } = await request(app)
- .get(`/assets/${asset2.id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- // Asset should still be accessible (soft deleted) but marked as deleted
- expect(getStatus).toBe(200);
-
- // Re-enable trash for other tests
- await utils.resetAdminConfig(admin.accessToken);
- });
- });
-});
diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts
index f0f14757b2..860189d9a9 100644
--- a/server/test/medium.factory.ts
+++ b/server/test/medium.factory.ts
@@ -29,6 +29,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
+import { DuplicateRepository } from 'src/repositories/duplicate.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { IntegrityRepository } from 'src/repositories/integrity.repository';
@@ -445,6 +446,7 @@ const newRealRepository = (key: T, db: Kysely
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
+ case DuplicateRepository:
case IntegrityRepository:
case MemoryRepository:
case NotificationRepository:
diff --git a/server/test/medium/specs/services/duplicate.service.spec.ts b/server/test/medium/specs/services/duplicate.service.spec.ts
new file mode 100644
index 0000000000..6afae277cb
--- /dev/null
+++ b/server/test/medium/specs/services/duplicate.service.spec.ts
@@ -0,0 +1,622 @@
+import { Kysely } from 'kysely';
+import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
+import { AssetStatus, AssetVisibility } from 'src/enum';
+import { AccessRepository } from 'src/repositories/access.repository';
+import { AlbumRepository } from 'src/repositories/album.repository';
+import { AssetRepository } from 'src/repositories/asset.repository';
+import { ConfigRepository } from 'src/repositories/config.repository';
+import { DuplicateRepository } from 'src/repositories/duplicate.repository';
+import { EventRepository } from 'src/repositories/event.repository';
+import { JobRepository } from 'src/repositories/job.repository';
+import { LoggingRepository } from 'src/repositories/logging.repository';
+import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
+import { TagRepository } from 'src/repositories/tag.repository';
+import { DB } from 'src/schema';
+import { DuplicateService } from 'src/services/duplicate.service';
+import { clearConfigCache } from 'src/utils/config';
+import { MediumTestContext, newMediumService } from 'test/medium.factory';
+import { factory } from 'test/small.factory';
+import { getKyselyDB } from 'test/utils';
+
+let defaultDatabase: Kysely;
+
+const setup = (db?: Kysely) => {
+ const { sut, ctx } = newMediumService(DuplicateService, {
+ database: db || defaultDatabase,
+ real: [
+ AccessRepository,
+ AlbumRepository,
+ AssetRepository,
+ ConfigRepository,
+ DuplicateRepository,
+ SystemMetadataRepository,
+ TagRepository,
+ ],
+ mock: [EventRepository, JobRepository, LoggingRepository],
+ });
+
+ ctx.getMock(EventRepository).emit.mockResolvedValue();
+ ctx.getMock(JobRepository).queueAll.mockResolvedValue();
+
+ return { sut, ctx };
+};
+
+const newDuplicateAsset = async (
+ ctx: MediumTestContext,
+ {
+ ownerId,
+ duplicateId,
+ ...dto
+ }: { ownerId: string; duplicateId?: string; isFavorite?: boolean; visibility?: AssetVisibility },
+ exif: { rating?: number; description?: string; latitude?: number; longitude?: number; fileSizeInByte?: number } = {},
+) => {
+ const { asset } = await ctx.newAsset({ ownerId, duplicateId, ...dto });
+ await ctx.newExif({ assetId: asset.id, fileSizeInByte: 1000, ...exif });
+ return asset;
+};
+
+const newTag = async (ctx: MediumTestContext, userId: string, value: string) => {
+ return ctx.get(TagRepository).create({ userId, value });
+};
+
+const expectSuccess = (results: BulkIdResponseDto[], duplicateId: string) => {
+ expect(results).toEqual([{ id: duplicateId, success: true }]);
+};
+
+beforeAll(async () => {
+ defaultDatabase = await getKyselyDB();
+});
+
+beforeEach(() => {
+ clearConfigCache();
+});
+
+describe(DuplicateService.name, () => {
+ describe('getDuplicates', () => {
+ it('should return an empty list when the user has no duplicates', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const auth = factory.auth({ user: { id: user.id } });
+
+ await expect(sut.getDuplicates(auth)).resolves.toEqual([]);
+ });
+
+ it('should return a duplicate group with a suggested keeper', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const small = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 1000 });
+ const large = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 2000 });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ const duplicates = await sut.getDuplicates(auth);
+
+ expect(duplicates).toHaveLength(1);
+ expect(duplicates[0].duplicateId).toBe(duplicateId);
+ expect(duplicates[0].assets.map(({ id }) => id).sort()).toEqual([small.id, large.id].sort());
+ // largest file size wins
+ expect(duplicates[0].suggestedKeepAssetIds).toEqual([large.id]);
+ });
+
+ it('should not return duplicates owned by someone else', async () => {
+ const { sut, ctx } = setup();
+ const { user: owner } = await ctx.newUser();
+ const { user: other } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId });
+ await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: other.id } });
+ await expect(sut.getDuplicates(auth)).resolves.toEqual([]);
+ });
+
+ it('should clear the duplicateId of a singleton group', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const asset = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(sut.getDuplicates(auth)).resolves.toEqual([]);
+
+ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ duplicateId: null });
+ });
+ });
+
+ describe('resolve', () => {
+ it('should throw when the duplicate group does not exist', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const auth = factory.auth({ user: { id: user.id } });
+
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId: factory.uuid(), keepAssetIds: [], trashAssetIds: [] }] }),
+ ).rejects.toThrow('Not found or no duplicate.delete access');
+ });
+
+ it('should throw when the duplicate group belongs to another user', async () => {
+ const { sut, ctx } = setup();
+ const { user: owner } = await ctx.newUser();
+ const { user: other } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const asset1 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId });
+ const asset2 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: other.id } });
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }),
+ ).rejects.toThrow('Not found or no duplicate.delete access');
+ });
+
+ it('should clear the duplicateId of the keeper and trash the rest', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }),
+ ).resolves.toEqual([{ id: duplicateId, success: true }]);
+
+ const assetRepo = ctx.get(AssetRepository);
+ await expect(assetRepo.getById(keeper.id)).resolves.toMatchObject({
+ duplicateId: null,
+ deletedAt: null,
+ });
+ await expect(assetRepo.getById(trashed.id)).resolves.toMatchObject({
+ duplicateId: null,
+ status: AssetStatus.Trashed,
+ });
+ });
+
+ it('should emit AssetTrashAll for the trashed assets', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }),
+ duplicateId,
+ );
+
+ expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetTrashAll', {
+ assetIds: [trashed.id],
+ userId: user.id,
+ });
+ });
+
+ it('should fail when an asset is in both keepAssetIds and trashAssetIds', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }] }),
+ ).resolves.toEqual([
+ {
+ id: duplicateId,
+ success: false,
+ error: BulkIdErrorReason.VALIDATION,
+ errorMessage: 'An asset cannot be in both keepAssetIds and trashAssetIds',
+ },
+ ]);
+ });
+
+ it('should fail when the group is only partially covered', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }),
+ ).resolves.toEqual([
+ {
+ id: duplicateId,
+ success: false,
+ error: BulkIdErrorReason.VALIDATION,
+ errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds',
+ },
+ ]);
+ });
+
+ it('should ignore ids that are not members of the group', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const outsider = await newDuplicateAsset(ctx, { ownerId: user.id });
+
+ // the outsider is filtered out, which leaves asset2 uncovered
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsider.id] }] }),
+ ).resolves.toEqual([
+ {
+ id: duplicateId,
+ success: false,
+ error: BulkIdErrorReason.VALIDATION,
+ errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds',
+ },
+ ]);
+
+ await expect(ctx.get(AssetRepository).getById(outsider.id)).resolves.toMatchObject({ deletedAt: null });
+ });
+
+ it('should trash every asset when no keepers are given', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
+ }),
+ ).resolves.toEqual([{ id: duplicateId, success: true }]);
+
+ const assetRepo = ctx.get(AssetRepository);
+ for (const { id } of [asset1, asset2]) {
+ await expect(assetRepo.getById(id)).resolves.toMatchObject({
+ duplicateId: null,
+ status: AssetStatus.Trashed,
+ });
+ }
+ });
+
+ it('should fail without permission to delete an asset owned by someone else', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const { user: other } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const mine = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const theirs = await newDuplicateAsset(ctx, { ownerId: other.id, duplicateId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [mine.id], trashAssetIds: [theirs.id] }] }),
+ ).resolves.toEqual([
+ {
+ id: duplicateId,
+ success: false,
+ error: BulkIdErrorReason.NO_PERMISSION,
+ errorMessage: 'No permission to delete assets',
+ },
+ ]);
+
+ await expect(ctx.get(AssetRepository).getById(theirs.id)).resolves.toMatchObject({ deletedAt: null });
+ });
+
+ it('should resolve several groups in one request', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId1 = factory.uuid();
+ const duplicateId2 = factory.uuid();
+
+ const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 });
+ const trashed1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 });
+ const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 });
+ const trashed2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, {
+ groups: [
+ { duplicateId: duplicateId1, keepAssetIds: [keeper1.id], trashAssetIds: [trashed1.id] },
+ { duplicateId: duplicateId2, keepAssetIds: [keeper2.id], trashAssetIds: [trashed2.id] },
+ ],
+ }),
+ ).resolves.toEqual([
+ { id: duplicateId1, success: true },
+ { id: duplicateId2, success: true },
+ ]);
+
+ const assetRepo = ctx.get(AssetRepository);
+ await expect(assetRepo.getById(trashed1.id)).resolves.toMatchObject({ status: AssetStatus.Trashed });
+ await expect(assetRepo.getById(trashed2.id)).resolves.toMatchObject({ status: AssetStatus.Trashed });
+ });
+
+ it('should report per-group failures without abandoning the valid groups', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const goodId = factory.uuid();
+ const badId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId });
+ // the bad group exists (so access passes) but is left partially covered
+ const badKeeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId });
+ await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ const results = await sut.resolve(auth, {
+ groups: [
+ { duplicateId: goodId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] },
+ { duplicateId: badId, keepAssetIds: [badKeeper.id], trashAssetIds: [] },
+ ],
+ });
+
+ expect(results).toEqual([
+ { id: goodId, success: true },
+ {
+ id: badId,
+ success: false,
+ error: BulkIdErrorReason.VALIDATION,
+ errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds',
+ },
+ ]);
+
+ // the good group still went through
+ await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({
+ status: AssetStatus.Trashed,
+ });
+ });
+
+ describe('metadata merging', () => {
+ it('should merge isFavorite into the keeper', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }],
+ }),
+ duplicateId,
+ );
+
+ await expect(ctx.get(AssetRepository).getById(keeper.id)).resolves.toMatchObject({
+ isFavorite: true,
+ duplicateId: null,
+ });
+ });
+
+ it('should merge the most restrictive visibility into the keeper', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeperAsset = await newDuplicateAsset(ctx, {
+ ownerId: user.id,
+ duplicateId,
+ visibility: AssetVisibility.Timeline,
+ });
+ const trashedAsset = await newDuplicateAsset(ctx, {
+ ownerId: user.id,
+ duplicateId,
+ visibility: AssetVisibility.Archive,
+ });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeperAsset.id], trashAssetIds: [trashedAsset.id] }],
+ }),
+ duplicateId,
+ );
+
+ await expect(ctx.get(AssetRepository).getById(keeperAsset.id)).resolves.toMatchObject({
+ visibility: AssetVisibility.Archive,
+ duplicateId: null,
+ });
+ });
+
+ it('should merge the highest rating into the keeper', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 1 });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 5 });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }],
+ }),
+ duplicateId,
+ );
+
+ const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true });
+ expect(result?.exifInfo).toMatchObject({ rating: 5 });
+ });
+
+ it('should merge distinct description lines into the keeper', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { description: 'from keeper' });
+ const trashed = await newDuplicateAsset(
+ ctx,
+ { ownerId: user.id, duplicateId },
+ { description: 'from trashed' },
+ );
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }],
+ }),
+ duplicateId,
+ );
+
+ const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true });
+ expect(result?.exifInfo?.description?.split('\n').sort()).toEqual(['from keeper', 'from trashed']);
+ });
+
+ it('should merge a location that both assets agree on', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const trashed = await newDuplicateAsset(
+ ctx,
+ { ownerId: user.id, duplicateId },
+ { latitude: 40.7128, longitude: -74.006 },
+ );
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }],
+ }),
+ duplicateId,
+ );
+
+ const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true });
+ expect(result?.exifInfo).toMatchObject({ latitude: 40.7128, longitude: -74.006 });
+ });
+
+ it('should not merge a location the assets disagree on', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 1, longitude: 1 });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 2, longitude: 2 });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }],
+ }),
+ duplicateId,
+ );
+
+ const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true });
+ expect(result?.exifInfo).toMatchObject({ latitude: 1, longitude: 1 });
+ });
+
+ it('should add the keeper to every album the group belonged to', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const { album: album1 } = await ctx.newAlbum({ ownerId: user.id }, [keeper.id]);
+ const { album: album2 } = await ctx.newAlbum({ ownerId: user.id }, [trashed.id]);
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }],
+ }),
+ duplicateId,
+ );
+
+ const albumRepo = ctx.get(AlbumRepository);
+ await expect(albumRepo.getAssetIds(album1.id, [keeper.id])).resolves.toEqual(new Set([keeper.id]));
+ await expect(albumRepo.getAssetIds(album2.id, [keeper.id])).resolves.toEqual(new Set([keeper.id]));
+ });
+
+ it('should merge the tags of the group onto the keeper', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const tag1 = await newTag(ctx, user.id, 'tag1');
+ const tag2 = await newTag(ctx, user.id, 'tag2');
+ await ctx.newTagAsset({ tagIds: [tag1.id], assetIds: [keeper.id] });
+ await ctx.newTagAsset({ tagIds: [tag2.id], assetIds: [trashed.id] });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ expectSuccess(
+ await sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }],
+ }),
+ duplicateId,
+ );
+
+ const result = await ctx.get(AssetRepository).getById(keeper.id, { tags: true });
+ expect(result?.tags?.map(({ id }) => id).sort()).toEqual([tag1.id, tag2.id].sort());
+ });
+
+ it('should not merge metadata when there is more than one keeper', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false });
+ const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true });
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, {
+ groups: [{ duplicateId, keepAssetIds: [keeper1.id, keeper2.id], trashAssetIds: [] }],
+ }),
+ ).resolves.toEqual([{ id: duplicateId, success: true }]);
+
+ await expect(ctx.get(AssetRepository).getById(keeper1.id)).resolves.toMatchObject({
+ isFavorite: false,
+ duplicateId: null,
+ });
+ });
+ });
+
+ describe('when trash is disabled', () => {
+ it('should delete instead of trash', async () => {
+ const { sut, ctx } = setup();
+ const { user } = await ctx.newUser();
+ const duplicateId = factory.uuid();
+
+ const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+ const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId });
+
+ const config = await ctx.getConfig();
+ await ctx.updateConfig({ ...config, trash: { ...config.trash, enabled: false } });
+ clearConfigCache();
+
+ const auth = factory.auth({ user: { id: user.id } });
+ await expect(
+ sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }),
+ ).resolves.toEqual([{ id: duplicateId, success: true }]);
+
+ await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({
+ duplicateId: null,
+ status: AssetStatus.Deleted,
+ });
+
+ expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetDeleteAll', {
+ assetIds: [trashed.id],
+ userId: user.id,
+ });
+ });
+ });
+ });
+});
From f9c05af45f86aaf2ed2f008dd652c7bfef023528 Mon Sep 17 00:00:00 2001
From: Ben Beckford
Date: Fri, 14 Aug 2026 14:10:28 -0700
Subject: [PATCH 82/89] fix(server): migration order (#30774)
---
...WorkflowLogsTable.ts => 1786741078327-AddWorkflowLogsTable.ts} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename server/src/schema/migrations/{1783930557118-AddWorkflowLogsTable.ts => 1786741078327-AddWorkflowLogsTable.ts} (100%)
diff --git a/server/src/schema/migrations/1783930557118-AddWorkflowLogsTable.ts b/server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts
similarity index 100%
rename from server/src/schema/migrations/1783930557118-AddWorkflowLogsTable.ts
rename to server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts
From bd55f7e73a033ad567d1166f46410079b8e3237b Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:55:45 +0530
Subject: [PATCH 83/89] chore: pump flutter_lints to 6.0 (#30664)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
mobile/lib/pages/common/large_leading_tile.dart | 2 +-
mobile/lib/pages/search/map/map_location_picker.page.dart | 2 +-
mobile/lib/presentation/pages/dev/media_stat.page.dart | 4 ++--
mobile/lib/presentation/pages/drift_place.page.dart | 2 +-
.../pages/feature_message/whats_new.page.dart | 4 ++--
.../widgets/album/pending_uploads_banner.widget.dart | 2 +-
.../asset_details/appears_in_details.widget.dart | 2 +-
.../widgets/asset_viewer/asset_page.widget.dart | 2 +-
.../widgets/asset_viewer/ocr_overlay.widget.dart | 2 +-
.../feature_message/feature_message_dialog.widget.dart | 6 +++---
.../presentation/widgets/timeline/timeline.widget.dart | 2 +-
mobile/lib/routing/router.dart | 2 +-
mobile/lib/services/background_upload.service.dart | 2 +-
mobile/lib/utils/hooks/app_settings_update_hook.dart | 2 +-
mobile/lib/widgets/asset_viewer/video_controls.dart | 2 +-
mobile/lib/widgets/common/immich_sliver_app_bar.dart | 2 +-
mobile/lib/widgets/map/map_settings/map_theme_picker.dart | 2 +-
mobile/lib/widgets/map/map_theme_override.dart | 2 +-
.../photo_view/src/utils/ignorable_change_notifier.dart | 4 ++--
mobile/lib/widgets/settings/advanced_settings.dart | 4 ++--
.../asset_list_settings/asset_list_layout_settings.dart | 2 +-
.../image_viewer_quality_setting.dart | 2 +-
.../image_viewer_tap_to_navigate_setting.dart | 2 +-
.../asset_viewer_settings/slideshow_settings.dart | 8 ++++----
.../asset_viewer_settings/video_viewer_settings.dart | 6 +++---
mobile/lib/widgets/settings/free_up_space_settings.dart | 2 +-
.../settings/networking_settings/networking_settings.dart | 2 +-
.../lib/widgets/settings/settings_button_list_tile.dart | 2 +-
mobile/pubspec.lock | 8 ++++----
mobile/pubspec.yaml | 2 +-
.../repositories/sync_api_repository_test.dart | 6 +++---
.../widgets/asset_viewer/asset_page_zoom_reset_test.dart | 2 +-
.../images/cache_aware_listener_tracker_mixin_test.dart | 6 +++---
.../presentation/widgets/timeline/timeline_args_test.dart | 6 +++---
.../widgets/timeline/timeline_scroll_to_top_test.dart | 2 +-
.../providers/infrastructure/memory_provider_test.dart | 6 +++---
36 files changed, 58 insertions(+), 58 deletions(-)
diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart
index 58528008ca..f8a8c51e17 100644
--- a/mobile/lib/pages/common/large_leading_tile.dart
+++ b/mobile/lib/pages/common/large_leading_tile.dart
@@ -54,7 +54,7 @@ class LargeLeadingTile extends StatelessWidget {
],
),
),
- if (trailing != null) trailing!,
+ ?trailing,
],
),
),
diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart
index b1d79b5150..314f2aba7d 100644
--- a/mobile/lib/pages/search/map/map_location_picker.page.dart
+++ b/mobile/lib/pages/search/map/map_location_picker.page.dart
@@ -140,7 +140,7 @@ class _BottomBar extends StatelessWidget {
const SizedBox(width: 15),
ValueListenableBuilder(
valueListenable: selectedLatLng,
- builder: (_, value, __) =>
+ builder: (_, value, _) =>
Text("${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}"),
),
],
diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart
index b5a95156d8..5947abfeb1 100644
--- a/mobile/lib/presentation/pages/dev/media_stat.page.dart
+++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart
@@ -58,7 +58,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text(context.t.local_media_summary)),
body: Consumer(
- builder: (ctx, ref, __) {
+ builder: (ctx, ref, _) {
final db = ref.watch(driftProvider);
final albumsFuture = ref.watch(localAlbumRepository).getAll();
@@ -138,7 +138,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text(context.t.remote_media_summary)),
body: Consumer(
- builder: (ctx, ref, __) {
+ builder: (ctx, ref, _) {
final db = ref.watch(driftProvider);
final albumsFuture = ref.watch(remoteAlbumRepository).getAll();
diff --git a/mobile/lib/presentation/pages/drift_place.page.dart b/mobile/lib/presentation/pages/drift_place.page.dart
index 68824e2752..626c261eb4 100644
--- a/mobile/lib/presentation/pages/drift_place.page.dart
+++ b/mobile/lib/presentation/pages/drift_place.page.dart
@@ -94,7 +94,7 @@ class _Map extends StatelessWidget {
height: 200,
width: context.width,
child: MapThumbnail(
- onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
+ onTap: (_, _) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
zoom: 8,
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
showAttribution: false,
diff --git a/mobile/lib/presentation/pages/feature_message/whats_new.page.dart b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart
index fb82f59577..d9e4e5c6eb 100644
--- a/mobile/lib/presentation/pages/feature_message/whats_new.page.dart
+++ b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart
@@ -17,7 +17,7 @@ class WhatsNewPage extends StatelessWidget {
body: ListView.separated(
padding: const EdgeInsets.only(top: 16, bottom: 64),
itemCount: highlights.length,
- separatorBuilder: (_, __) => const SizedBox(height: 24),
+ separatorBuilder: (_, _) => const SizedBox(height: 24),
itemBuilder: (_, index) => _HighlightCard(highlight: highlights[index]),
),
);
@@ -53,7 +53,7 @@ class _HighlightCard extends StatelessWidget {
: Image.asset(
highlight.image!,
fit: BoxFit.contain,
- errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
+ errorBuilder: (context, _, _) => const FeatureMessagePlaceholder(),
),
),
),
diff --git a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart
index c281f6b166..796c4ce43a 100644
--- a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart
+++ b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart
@@ -214,7 +214,7 @@ class _PendingUploadsSheet extends ConsumerWidget {
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: pending.length,
- separatorBuilder: (_, __) => const SizedBox(width: 8),
+ separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]),
),
),
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart
index efd2ee00a1..0cb2ccd254 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart
@@ -77,7 +77,7 @@ class AppearsInDetails extends ConsumerWidget {
);
},
loading: () => const SizedBox.shrink(),
- error: (_, __) => const SizedBox.shrink(),
+ error: (_, _) => const SizedBox.shrink(),
);
}
}
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
index 7556505158..9c17d9a035 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
@@ -363,7 +363,7 @@ class _AssetPageState extends ConsumerState {
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
- errorBuilder: (_, __, ___) => SizedBox(
+ errorBuilder: (_, _, _) => SizedBox(
width: size.width,
height: size.height,
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart
index 576d4937b6..b86d7298d5 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart
@@ -110,7 +110,7 @@ class _OcrOverlayState extends ConsumerState {
);
},
loading: () => const SizedBox.shrink(),
- error: (_, __) => const SizedBox.shrink(),
+ error: (_, _) => const SizedBox.shrink(),
);
}
}
diff --git a/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart
index 73dcd054da..dc89c10294 100644
--- a/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart
+++ b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart
@@ -17,8 +17,8 @@ Future showFeatureMessageDialog(BuildContext context) {
barrierLabel: context.t.whats_new,
barrierColor: Colors.black.withValues(alpha: 0.55),
transitionDuration: const Duration(milliseconds: 280),
- pageBuilder: (_, __, ___) => const _FeatureMessageDialog(),
- transitionBuilder: (_, animation, __, child) {
+ pageBuilder: (_, _, _) => const _FeatureMessageDialog(),
+ transitionBuilder: (_, animation, _, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
return FadeTransition(
opacity: animation,
@@ -249,7 +249,7 @@ class _FeaturePage extends StatelessWidget {
: Image.asset(
highlight.image!,
fit: BoxFit.contain,
- errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
+ errorBuilder: (context, _, _) => const FeatureMessagePlaceholder(),
),
),
),
diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
index c41f88fcc0..0387cbf597 100644
--- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
+++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
@@ -408,7 +408,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
return PopScope(
canPop: !isMultiSelectEnabled,
- onPopInvokedWithResult: (_, __) {
+ onPopInvokedWithResult: (_, _) {
if (isMultiSelectEnabled) {
ref.read(multiSelectProvider.notifier).reset();
}
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index a4c5bab4fb..15d32192a0 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -157,7 +157,7 @@ class AppRouter extends RootStackRouter {
customRouteBuilder: (context, child, page) => PageRouteBuilder(
fullscreenDialog: page.fullscreenDialog,
settings: page,
- pageBuilder: (_, __, ___) => child,
+ pageBuilder: (_, _, _) => child,
opaque: false,
transitionsBuilder: TransitionsBuilders.fadeIn,
),
diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart
index b2a134389f..3bc8b2a004 100644
--- a/mobile/lib/services/background_upload.service.dart
+++ b/mobile/lib/services/background_upload.service.dart
@@ -378,7 +378,7 @@ class BackgroundUploadService {
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0',
- if (fields != null) ...fields,
+ ...?fields,
if (CurrentPlatform.isIOS && cloudId != null)
'metadata': jsonEncode([
RemoteAssetMetadataItem(
diff --git a/mobile/lib/utils/hooks/app_settings_update_hook.dart b/mobile/lib/utils/hooks/app_settings_update_hook.dart
index 9d7cc4b064..6b7ba357f3 100644
--- a/mobile/lib/utils/hooks/app_settings_update_hook.dart
+++ b/mobile/lib/utils/hooks/app_settings_update_hook.dart
@@ -9,7 +9,7 @@ ValueNotifier useAppSettingsState(AppSettingsEnum key) {
final notifier = useState(Store.get(key.storeKey, key.defaultValue));
// Listen to changes to the notifier and update app settings
- useValueChanged(notifier.value, (_, __) => unawaited(Store.put(key.storeKey, notifier.value)));
+ useValueChanged(notifier.value, (_, _) => unawaited(Store.put(key.storeKey, notifier.value)));
return notifier;
}
diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart
index 14c4daf5d8..b5678b8d9f 100644
--- a/mobile/lib/widgets/asset_viewer/video_controls.dart
+++ b/mobile/lib/widgets/asset_viewer/video_controls.dart
@@ -97,7 +97,7 @@ class _VideoControlsState extends ConsumerState {
_hideTimer.reset();
}
});
- ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
+ ref.listen(_provider.select((v) => v.status), (_, _) => _hideTimer.reset());
final notifier = ref.watch(_provider.notifier);
final isLoaded = duration != Duration.zero;
diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
index 6a119f8ca3..b966601c31 100644
--- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart
+++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
@@ -73,7 +73,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()),
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
),
- if (actions != null) ...actions!,
+ ...?actions,
if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(),
const _ProfileIndicator(),
const SizedBox(width: 8),
diff --git a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart
index ec83d5085e..6b53a273a0 100644
--- a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart
+++ b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart
@@ -79,7 +79,7 @@ class _BorderedMapThumbnail extends StatelessWidget {
child: MapThumbnail(
zoom: 2,
centre: const LatLng(47, 5),
- onTap: (_, __) => onThemeChange(mode),
+ onTap: (_, _) => onThemeChange(mode),
themeMode: mode,
showAttribution: false,
),
diff --git a/mobile/lib/widgets/map/map_theme_override.dart b/mobile/lib/widgets/map/map_theme_override.dart
index dda96efeaf..93cbd8846f 100644
--- a/mobile/lib/widgets/map/map_theme_override.dart
+++ b/mobile/lib/widgets/map/map_theme_override.dart
@@ -69,7 +69,7 @@ class _MapThemeOverrideState extends ConsumerState with Widget
final appTheme = ref.watch(immichThemeProvider);
final locale = ref.watch(localeProvider);
- useValueChanged(_theme, (_, __) {
+ useValueChanged(_theme, (_, _) {
if (_theme == ThemeMode.system) {
WidgetsBinding.instance.addObserver(this);
} else {
diff --git a/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart b/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart
index 3ca31cb8f9..27aa256b14 100644
--- a/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart
+++ b/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart
@@ -28,12 +28,12 @@ class IgnorableChangeNotifier extends ChangeNotifier {
return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false);
}
- void addIgnorableListener(listener) {
+ void addIgnorableListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.add(listener);
}
- void removeIgnorableListener(listener) {
+ void removeIgnorableListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.remove(listener);
}
diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart
index 9e80f8715c..44a992345f 100644
--- a/mobile/lib/widgets/settings/advanced_settings.dart
+++ b/mobile/lib/widgets/settings/advanced_settings.dart
@@ -36,7 +36,7 @@ class AdvancedSettings extends HookConsumerWidget {
final preferRemote = useState(ref.watch(appConfigProvider).image.preferRemote);
useValueChanged(
preferRemote.value,
- (_, __) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)),
+ (_, _) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)),
);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
@@ -44,7 +44,7 @@ class AdvancedSettings extends HookConsumerWidget {
useValueChanged(
levelId.value,
- (_, __) => unawaited(LogService.I.setLogLevel(Level.LEVELS[levelId.value].toLogLevel())),
+ (_, _) => unawaited(LogService.I.setLogLevel(Level.LEVELS[levelId.value].toLogLevel())),
);
Future checkAndroidVersion() async {
diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
index 6ecd76dd78..651cc43656 100644
--- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
+++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
@@ -15,7 +15,7 @@ class LayoutSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tilesPerRow = useState(ref.watch(appConfigProvider.select((s) => s.timeline.tilesPerRow)));
- useValueChanged(tilesPerRow.value, (_, __) {
+ useValueChanged(tilesPerRow.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, tilesPerRow.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
index d1b1e3d0f2..5b5917addc 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
@@ -15,7 +15,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOriginal = useState(ref.watch(appConfigProvider).image.loadOriginal);
- useValueChanged(isOriginal.value, (_, __) {
+ useValueChanged(isOriginal.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.imageLoadOriginal, isOriginal.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
index ed07028bcf..f8dc6b0e1b 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
@@ -14,7 +14,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tapToNavigate = useState(ref.watch(appConfigProvider).viewer.tapToNavigate);
- useValueChanged(tapToNavigate.value, (_, __) {
+ useValueChanged(tapToNavigate.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.viewerTapToNavigate, tapToNavigate.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
index bc4d9a1235..72d1685d33 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
@@ -23,16 +23,16 @@ class SlideshowSettings extends HookConsumerWidget {
final useLook = useState(slideshow.look);
final useDirection = useState(slideshow.direction);
- useValueChanged(useRepeat.value, (_, __) {
+ useValueChanged(useRepeat.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.slideshowRepeat, useRepeat.value));
});
- useValueChanged(useDuration.value, (_, __) {
+ useValueChanged(useDuration.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.slideshowDuration, useDuration.value));
});
- useValueChanged(useLook.value, (_, __) {
+ useValueChanged(useLook.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.slideshowLook, useLook.value));
});
- useValueChanged(useDirection.value, (_, __) {
+ useValueChanged(useDirection.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.slideshowDirection, useDirection.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
index 2a575061ca..bb14d4b327 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
@@ -18,13 +18,13 @@ class VideoViewerSettings extends HookConsumerWidget {
final useLoopVideo = useState(viewer.loopVideo);
final useOriginalVideo = useState(viewer.loadOriginalVideo);
- useValueChanged(useAutoPlayVideo.value, (_, __) {
+ useValueChanged(useAutoPlayVideo.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value));
});
- useValueChanged(useLoopVideo.value, (_, __) {
+ useValueChanged(useLoopVideo.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.viewerLoopVideo, useLoopVideo.value));
});
- useValueChanged(useOriginalVideo.value, (_, __) {
+ useValueChanged(useOriginalVideo.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value));
});
diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart
index 5885601171..07dfe95b21 100644
--- a/mobile/lib/widgets/settings/free_up_space_settings.dart
+++ b/mobile/lib/widgets/settings/free_up_space_settings.dart
@@ -407,7 +407,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
setState(() => _currentStep = CleanupStep.values[step]);
}
},
- controlsBuilder: (_, __) => const SizedBox.shrink(),
+ controlsBuilder: (_, _) => const SizedBox.shrink(),
steps: [
// Step 1: Select Cutoff Date
Step(
diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
index 581bae4324..80b0b78fc2 100644
--- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
+++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
@@ -21,7 +21,7 @@ class NetworkingSettings extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = getServerUrl();
final featureEnabled = useState(ref.watch(appConfigProvider).network.autoEndpointSwitching);
- useValueChanged(featureEnabled.value, (_, __) {
+ useValueChanged(featureEnabled.value, (_, _) {
unawaited(ref.read(settingsProvider).write(.networkAutoEndpointSwitching, featureEnabled.value));
});
diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart
index 0e8d75b22f..1e06c0b2b4 100644
--- a/mobile/lib/widgets/settings/settings_button_list_tile.dart
+++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart
@@ -41,7 +41,7 @@ class SettingsButtonListTile extends StatelessWidget {
subtileText!,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
- if (subtitle != null) subtitle!,
+ ?subtitle,
const SizedBox(height: 6),
child ?? ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)),
],
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index ac19481079..f84d672090 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -512,10 +512,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
- sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
- version: "5.0.0"
+ version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
@@ -1016,10 +1016,10 @@ packages:
dependency: transitive
description:
name: lints
- sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
- version: "5.1.1"
+ version: "6.1.0"
local_auth:
dependency: "direct main"
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 32e67de4f6..7a8d9ca32b 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -101,7 +101,7 @@ dev_dependencies:
fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.4
- flutter_lints: ^5.0.0
+ flutter_lints: ^6.0.0
flutter_native_splash: ^2.4.7
flutter_test:
sdk: flutter
diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart
index 3a3e40cbae..798a5b7f28 100644
--- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart
+++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart
@@ -189,7 +189,7 @@ void main() {
final Completer firstBatchReceived = Completer();
final Completer secondBatchReceived = Completer();
- Future onDataCallback(List events, Function() _, Function() __) async {
+ Future onDataCallback(List events, Function() _, Function() _) async {
onDataCallCount++;
if (onDataCallCount == 1) {
receivedEventsBatch1 = events;
@@ -244,7 +244,7 @@ void main() {
final streamError = Exception("Network Error");
int onDataCallCount = 0;
- Future onDataCallback(List _, Function() _, Function() __) async {
+ Future onDataCallback(List _, Function() _, Function() _) async {
onDataCallCount++;
}
@@ -270,7 +270,7 @@ void main() {
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(errorBodyController.stream));
int onDataCallCount = 0;
- Future onDataCallback(List _, Function() _, Function() __) async {
+ Future onDataCallback(List _, Function() _, Function() _) async {
onDataCallCount++;
}
diff --git a/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart b/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart
index d52f6b6b83..f4ee4f353c 100644
--- a/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart
+++ b/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart
@@ -20,7 +20,7 @@ class _SwappableTimelineService extends TimelineService {
BaseAsset asset;
_SwappableTimelineService(this.asset)
: super((
- assetSource: (_, __) async => [],
+ assetSource: (_, _) async => [],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: TimelineOrigin.main,
));
diff --git a/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart b/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart
index 02bb0c1053..3a5ad159b5 100644
--- a/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart
+++ b/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart
@@ -29,7 +29,7 @@ void main() {
setUp(() {
// Create a fresh, real Flutter ImageCache for every test
cache = ImageCache();
- uiListener = ImageStreamListener((_, __) {});
+ uiListener = ImageStreamListener((_, _) {});
});
group('CacheAwareListenerTrackerMixin with Real ImageCache', () {
@@ -130,7 +130,7 @@ void main() {
final stream = cache.putIfAbsent(key, () => completer)!;
- final uiListener2 = ImageStreamListener((_, __) {});
+ final uiListener2 = ImageStreamListener((_, _) {});
stream.addListener(uiListener);
stream.addListener(uiListener2);
@@ -157,7 +157,7 @@ void main() {
expect(completer.wasCancelled, isFalse);
// A second UI listener attaches â must NOT be treated as cache
- final uiListener2 = ImageStreamListener((_, __) {});
+ final uiListener2 = ImageStreamListener((_, _) {});
stream.addListener(uiListener2);
// Remove first UI listener; second UI still active â no cancel
diff --git a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart
index 5c03998bfc..439ae9ecf1 100644
--- a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart
+++ b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart
@@ -64,7 +64,7 @@ void main() {
TimelineArgs? probed;
final probe = Consumer(
- builder: (_, ref, __) {
+ builder: (_, ref, _) {
probed = ref.watch(timelineArgsProvider);
return const SizedBox.shrink();
},
@@ -103,7 +103,7 @@ void main() {
TimelineArgs? probed;
final probe = SliverToBoxAdapter(
child: Consumer(
- builder: (_, ref, __) {
+ builder: (_, ref, _) {
probed = ref.watch(timelineArgsProvider);
return const SizedBox.shrink();
},
@@ -148,7 +148,7 @@ void main() {
TimelineArgs? probed;
final probe = Consumer(
- builder: (_, ref, __) {
+ builder: (_, ref, _) {
probed = ref.watch(timelineArgsProvider);
return const SizedBox.shrink();
},
diff --git a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart
index f72af61a0a..e85ced8fb1 100644
--- a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart
+++ b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart
@@ -51,7 +51,7 @@ void main() {
page: PageInfo('Overlay', builder: (_) => const Scaffold(body: SizedBox.shrink())),
type: RouteType.custom(
customRouteBuilder: (_, child, page) =>
- PageRouteBuilder(settings: page, opaque: false, pageBuilder: (_, __, ___) => child),
+ PageRouteBuilder(settings: page, opaque: false, pageBuilder: (_, _, _) => child),
),
),
],
diff --git a/mobile/test/providers/infrastructure/memory_provider_test.dart b/mobile/test/providers/infrastructure/memory_provider_test.dart
index e588153fc9..60b84c5aad 100644
--- a/mobile/test/providers/infrastructure/memory_provider_test.dart
+++ b/mobile/test/providers/infrastructure/memory_provider_test.dart
@@ -48,7 +48,7 @@ void main() {
test('re-queries after local midnight', () {
fakeAsync((async) {
final container = makeContainer();
- container.listen(driftMemoryFutureProvider, (_, __) {});
+ container.listen(driftMemoryFutureProvider, (_, _) {});
async.flushMicrotasks();
verify(() => memoryService.getMemoryLane('user-1')).called(1);
@@ -66,7 +66,7 @@ void main() {
test('cancels the midnight timer when disposed', () {
fakeAsync((async) {
final container = makeContainer();
- final subscription = container.listen(driftMemoryFutureProvider, (_, __) {});
+ final subscription = container.listen(driftMemoryFutureProvider, (_, _) {});
async.flushMicrotasks();
verify(() => memoryService.getMemoryLane('user-1')).called(1);
@@ -83,7 +83,7 @@ void main() {
fakeAsync((async) {
final container = makeContainer();
- container.listen(driftMemoryFutureProvider, (_, __) {});
+ container.listen(driftMemoryFutureProvider, (_, _) {});
async.flushMicrotasks();
async.elapse(const Duration(hours: 25));
From ffc83eae36034fe75525a0abd6c5df351184cd91 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:55:45 +0530
Subject: [PATCH 84/89] chore: more dart lints (#30665)
The lints are regrouped and sorted. Also, added the following new lints:
- unnecessary_ignore
- parameter_assignments
- avoid_unused_constructor_parameters
- tighten_type_of_initializing_formals
- only_throw_errors
- deprecated_consistency
- unnecessary_statements
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
mobile/analysis_options.yaml | 79 ++++++++++---------
mobile/lib/domain/models/memory.model.dart | 1 -
mobile/lib/domain/models/user.model.dart | 1 -
.../models/download/download_state.model.dart | 1 -
.../download/livephotos_medatada.model.dart | 1 -
.../models/search/search_filter.model.dart | 1 -
.../upload/share_intent_attachment.model.dart | 1 -
.../pages/drift_people_collection.page.dart | 10 ++-
.../presentation/pages/drift_place.page.dart | 9 ++-
.../memory/memory_bottom_info.widget.dart | 2 -
.../widgets/timeline/scrubber.widget.dart | 1 -
.../backup/backup_album.provider.dart | 33 +++-----
mobile/lib/routing/router.dart | 3 -
mobile/lib/services/api.service.dart | 6 +-
.../photo_view/photo_view_gallery.dart | 15 ++--
.../src/core/photo_view_gesture_detector.dart | 1 -
.../src/utils/photo_view_utils.dart | 30 +++----
mobile/test/drift/main/migration_test.dart | 2 +-
.../unit/factories/local_album_factory.dart | 6 +-
.../unit/factories/local_asset_factory.dart | 6 +-
.../unit/factories/partner_user_factory.dart | 8 +-
.../unit/factories/remote_album_factory.dart | 8 +-
.../unit/factories/remote_asset_factory.dart | 8 +-
mobile/test/unit/factories/user_factory.dart | 16 ++--
24 files changed, 112 insertions(+), 137 deletions(-)
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index fb5d96697b..065b3d56d8 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -26,52 +26,59 @@ linter:
# producing the lint.
rules:
- # Formatting
- avoid_print: true
- require_trailing_commas: true
- unrelated_type_equality_checks: true
- prefer_const_constructors: true
- always_use_package_imports: true
- always_put_control_body_on_new_line: true
+ # Correctness
+ always_declare_return_types: true
+ avoid_type_to_string: true
+ avoid_unused_constructor_parameters: true
+ avoid_void_async: true
+ cancel_subscriptions: true
+ cast_nullable_to_non_nullable: true
+ close_sinks: true
+ collection_methods_unrelated_type: true
+ deprecated_consistency: true
+ discarded_futures: true
+ no_adjacent_strings_in_list: true
+ no_self_assignments: true
+ noop_primitive_operations: true
+ only_throw_errors: true
+ parameter_assignments: true
+ throw_in_finally: true
+ tighten_type_of_initializing_formals: true
+ unawaited_futures: true
+ unnecessary_ignore: true
unnecessary_null_checks: true
- unnecessary_parenthesis: true
- prefer_final_locals: true
+ unnecessary_statements: true
+ unrelated_type_equality_checks: true
+
+ # Performance
+ avoid_slow_async_io: true
+ prefer_const_constructors: true
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
- use_super_parameters: true
- directives_ordering: true
- no_leading_underscores_for_local_identifiers: true
- always_declare_return_types: true
- avoid_void_async: true
- noop_primitive_operations: true
- use_named_constants: true
- combinators_ordering: true
- avoid_multiple_declarations_per_line: true
- unnecessary_breaks: true
-
- # Correctness
- no_adjacent_strings_in_list: true
- cancel_subscriptions: true
- close_sinks: true
- unawaited_futures: true
- discarded_futures: true
- no_self_assignments: true
- throw_in_finally: true
- collection_methods_unrelated_type: true
- cast_nullable_to_non_nullable: true
-
- # Known issues
- avoid_slow_async_io: true
- avoid_type_to_string: true
# Flutter specific
- use_build_context_synchronously: true
+ avoid_unnecessary_containers: true
sized_box_for_whitespace: true
+ use_build_context_synchronously: true
use_colored_box: true
use_decorated_box: true
- avoid_unnecessary_containers: true
use_full_hex_values_for_flutter_colors: true
+ # Style
+ always_put_control_body_on_new_line: true
+ always_use_package_imports: true
+ avoid_multiple_declarations_per_line: true
+ avoid_print: true
+ combinators_ordering: true
+ directives_ordering: true
+ no_leading_underscores_for_local_identifiers: true
+ prefer_final_locals: true
+ require_trailing_commas: true
+ unnecessary_breaks: true
+ unnecessary_parenthesis: true
+ use_named_constants: true
+ use_super_parameters: true
+
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
analyzer:
diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart
index a75a4a4f0f..1c556f382f 100644
--- a/mobile/lib/domain/models/memory.model.dart
+++ b/mobile/lib/domain/models/memory.model.dart
@@ -1,4 +1,3 @@
-// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart
index 96e07137c9..181fddf581 100644
--- a/mobile/lib/domain/models/user.model.dart
+++ b/mobile/lib/domain/models/user.model.dart
@@ -1,4 +1,3 @@
-// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:ui';
import 'package:freezed_annotation/freezed_annotation.dart';
diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart
index 7e47d60969..5f04fcdfd4 100644
--- a/mobile/lib/models/download/download_state.model.dart
+++ b/mobile/lib/models/download/download_state.model.dart
@@ -1,4 +1,3 @@
-// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:background_downloader/background_downloader.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart
index 228ad707da..833a9ffca7 100644
--- a/mobile/lib/models/download/livephotos_medatada.model.dart
+++ b/mobile/lib/models/download/livephotos_medatada.model.dart
@@ -1,4 +1,3 @@
-// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
enum LivePhotosPart { video, image }
diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart
index a9cc13ba6a..03660204b9 100644
--- a/mobile/lib/models/search/search_filter.model.dart
+++ b/mobile/lib/models/search/search_filter.model.dart
@@ -1,4 +1,3 @@
-// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart
index 0f9c3e4c29..3643020bc8 100644
--- a/mobile/lib/models/upload/share_intent_attachment.model.dart
+++ b/mobile/lib/models/upload/share_intent_attachment.model.dart
@@ -1,4 +1,3 @@
-// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'dart:io';
diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart
index 569cb99b01..92f1ff79ca 100644
--- a/mobile/lib/presentation/pages/drift_people_collection.page.dart
+++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -65,12 +66,15 @@ class _DriftPeopleCollectionPageState extends ConsumerState filtered;
if (_search != null) {
- people = people.where((person) {
+ filtered = people.where((person) {
return person.name.toLowerCase().removeDiacritics().contains(
_search!.toLowerCase().removeDiacritics(),
);
}).toList();
+ } else {
+ filtered = people;
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
@@ -79,9 +83,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState filtered;
if (search.value != null) {
- places = places.where((place) {
+ filtered = places.where((place) {
return place.$1.toLowerCase().contains(search.value!.toLowerCase());
}).toList();
+ } else {
+ filtered = places;
}
return SliverList.builder(
- itemCount: places.length,
+ itemCount: filtered.length,
itemBuilder: (context, index) {
- final place = places[index];
+ final place = filtered[index];
return _PlaceTile(place: place);
},
);
diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart
index 2bafdc0843..eb8ce1815a 100644
--- a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart
+++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart
@@ -1,5 +1,3 @@
-// ignore_for_file: require_trailing_commas
-
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
index eb081a1e6a..a2298313ac 100644
--- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
+++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
@@ -38,7 +38,6 @@ class Scrubber extends ConsumerStatefulWidget {
Scrubber({
super.key,
- Key? scrollThumbKey,
required this.layoutSegments,
required this.timelineHeight,
this.topPadding = 0,
diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart
index 25a4204928..5d16722418 100644
--- a/mobile/lib/providers/backup/backup_album.provider.dart
+++ b/mobile/lib/providers/backup/backup_album.provider.dart
@@ -22,40 +22,25 @@ class BackupAlbumNotifier extends StateNotifier> {
}
Future selectAlbum(LocalAlbum album) async {
- album = album.copyWith(backupSelection: BackupSelection.selected);
- await _localAlbumService.update(album);
+ final selectedAlbum = album.copyWith(backupSelection: BackupSelection.selected);
+ await _localAlbumService.update(selectedAlbum);
- state = state
- .map(
- (currentAlbum) => currentAlbum.id == album.id
- ? currentAlbum.copyWith(backupSelection: BackupSelection.selected)
- : currentAlbum,
- )
- .toList();
+ state = state.map((currentAlbum) => currentAlbum.id == selectedAlbum.id ? selectedAlbum : currentAlbum).toList();
}
Future deselectAlbum(LocalAlbum album) async {
- album = album.copyWith(backupSelection: BackupSelection.none);
- await _localAlbumService.update(album);
+ final deselectedAlbum = album.copyWith(backupSelection: BackupSelection.none);
+ await _localAlbumService.update(deselectedAlbum);
state = state
- .map(
- (currentAlbum) =>
- currentAlbum.id == album.id ? currentAlbum.copyWith(backupSelection: BackupSelection.none) : currentAlbum,
- )
+ .map((currentAlbum) => currentAlbum.id == deselectedAlbum.id ? deselectedAlbum : currentAlbum)
.toList();
}
Future excludeAlbum(LocalAlbum album) async {
- album = album.copyWith(backupSelection: BackupSelection.excluded);
- await _localAlbumService.update(album);
+ final excludedAlbum = album.copyWith(backupSelection: BackupSelection.excluded);
+ await _localAlbumService.update(excludedAlbum);
- state = state
- .map(
- (currentAlbum) => currentAlbum.id == album.id
- ? currentAlbum.copyWith(backupSelection: BackupSelection.excluded)
- : currentAlbum,
- )
- .toList();
+ state = state.map((currentAlbum) => currentAlbum.id == excludedAlbum.id ? excludedAlbum : currentAlbum).toList();
}
}
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index 15d32192a0..1d6455782c 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -71,7 +71,6 @@ import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.pa
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
-import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/routing/duplicate_guard.dart';
import 'package:immich_mobile/routing/locked_guard.dart';
@@ -88,7 +87,6 @@ final appRouterProvider = Provider(
(ref) => AppRouter(
ref.watch(apiServiceProvider),
ref.watch(authServiceProvider),
- ref.watch(galleryPermissionNotifier.notifier),
ref.watch(secureStorageServiceProvider),
ref.watch(localAuthServiceProvider),
),
@@ -103,7 +101,6 @@ class AppRouter extends RootStackRouter {
AppRouter(
ApiService apiService,
AuthService authService,
- GalleryPermissionNotifier galleryPermissionNotifier,
SecureStorageService secureStorageService,
LocalAuthService localAuthService,
) {
diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart
index f5e4c5c33e..d1aec9cc86 100644
--- a/mobile/lib/services/api.service.dart
+++ b/mobile/lib/services/api.service.dart
@@ -113,12 +113,10 @@ class ApiService {
}
Future _isEndpointAvailable(String serverUrl) async {
- if (!serverUrl.endsWith('/api')) {
- serverUrl += '/api';
- }
+ final endpoint = serverUrl.endsWith('/api') ? serverUrl : '$serverUrl/api';
try {
- setEndpoint(serverUrl);
+ setEndpoint(endpoint);
await serverInfoApi.pingServer().timeout(const Duration(seconds: 5));
} on TimeoutException catch (_) {
return false;
diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart
index aa33d18403..3c41b2ca19 100644
--- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart
+++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart
@@ -128,8 +128,8 @@ class PhotoViewGallery extends StatefulWidget {
/// The builder must return a [PhotoViewGalleryPageOptions].
const PhotoViewGallery.builder({
super.key,
- required this.itemCount,
- required this.builder,
+ required int this.itemCount,
+ required PhotoViewGalleryBuilder this.builder,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
@@ -145,9 +145,7 @@ class PhotoViewGallery extends StatefulWidget {
this.customSize,
this.allowImplicitScrolling = false,
this.enablePanAlways = false,
- }) : pageOptions = null,
- assert(itemCount != null),
- assert(builder != null);
+ }) : pageOptions = null;
/// A list of options to describe the items in the gallery
final List? pageOptions;
@@ -352,9 +350,9 @@ class _PhotoViewGalleryState extends State {
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
///
class PhotoViewGalleryPageOptions {
- PhotoViewGalleryPageOptions({
+ const PhotoViewGalleryPageOptions({
this.key,
- required this.imageProvider,
+ required ImageProvider this.imageProvider,
this.heroAttributes,
this.semanticLabel,
this.minScale,
@@ -379,8 +377,7 @@ class PhotoViewGalleryPageOptions {
this.disableGestures,
this.errorBuilder,
}) : child = null,
- childSize = null,
- assert(imageProvider != null);
+ childSize = null;
const PhotoViewGalleryPageOptions.customChild({
this.key,
diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart
index 7b55e0e37e..8f408e043e 100644
--- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart
+++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart
@@ -121,7 +121,6 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
super.debugOwner,
this.validateAxis,
this.touchSlopFactor = 1,
- PointerDeviceKind? kind,
this.disableScaleGestures = false,
}) : super(supportedDevices: null);
final HitCornersDetector? hitDetector;
diff --git a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart
index d120955250..c1ee1ab769 100644
--- a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart
+++ b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart
@@ -32,12 +32,10 @@ class ScaleBoundaries {
double get minScale {
assert(_minScale is double || _minScale is PhotoViewComputedScale);
if (_minScale == PhotoViewComputedScale.contained) {
- return _scaleForContained(outerSize, childSize) *
- (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
+ return _scaleForContained(outerSize, childSize) * (_minScale as PhotoViewComputedScale).multiplier;
}
if (_minScale == PhotoViewComputedScale.covered) {
- return _scaleForCovering(outerSize, childSize) *
- (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
+ return _scaleForCovering(outerSize, childSize) * (_minScale as PhotoViewComputedScale).multiplier;
}
assert(_minScale >= 0.0);
return _minScale;
@@ -46,16 +44,16 @@ class ScaleBoundaries {
double get maxScale {
assert(_maxScale is double || _maxScale is PhotoViewComputedScale);
if (_maxScale == PhotoViewComputedScale.contained) {
- return (_scaleForContained(outerSize, childSize) *
- (_maxScale as PhotoViewComputedScale) // ignore: avoid_as
- .multiplier)
- .clamp(minScale, double.infinity);
+ return (_scaleForContained(outerSize, childSize) * (_maxScale as PhotoViewComputedScale).multiplier).clamp(
+ minScale,
+ double.infinity,
+ );
}
if (_maxScale == PhotoViewComputedScale.covered) {
- return (_scaleForCovering(outerSize, childSize) *
- (_maxScale as PhotoViewComputedScale) // ignore: avoid_as
- .multiplier)
- .clamp(minScale, double.infinity);
+ return (_scaleForCovering(outerSize, childSize) * (_maxScale as PhotoViewComputedScale).multiplier).clamp(
+ minScale,
+ double.infinity,
+ );
}
return _maxScale.clamp(minScale, double.infinity);
}
@@ -63,14 +61,10 @@ class ScaleBoundaries {
double get initialScale {
assert(_initialScale is double || _initialScale is PhotoViewComputedScale);
if (_initialScale == PhotoViewComputedScale.contained) {
- return _scaleForContained(outerSize, childSize) *
- (_initialScale as PhotoViewComputedScale) // ignore: avoid_as
- .multiplier;
+ return _scaleForContained(outerSize, childSize) * (_initialScale as PhotoViewComputedScale).multiplier;
}
if (_initialScale == PhotoViewComputedScale.covered) {
- return _scaleForCovering(outerSize, childSize) *
- (_initialScale as PhotoViewComputedScale) // ignore: avoid_as
- .multiplier;
+ return _scaleForCovering(outerSize, childSize) * (_initialScale as PhotoViewComputedScale).multiplier;
}
return _initialScale.clamp(minScale, maxScale);
}
diff --git a/mobile/test/drift/main/migration_test.dart b/mobile/test/drift/main/migration_test.dart
index 74467492ae..ce3f644b98 100644
--- a/mobile/test/drift/main/migration_test.dart
+++ b/mobile/test/drift/main/migration_test.dart
@@ -1,5 +1,5 @@
// dart format width=80
-// ignore_for_file: unused_local_variable, unused_import
+// ignore_for_file: unused_import
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter_test/flutter_test.dart';
diff --git a/mobile/test/unit/factories/local_album_factory.dart b/mobile/test/unit/factories/local_album_factory.dart
index 447001f971..639237c858 100644
--- a/mobile/test/unit/factories/local_album_factory.dart
+++ b/mobile/test/unit/factories/local_album_factory.dart
@@ -14,10 +14,10 @@ class LocalAlbumFactory {
String? linkedRemoteAlbumId,
int? assetCount,
}) {
- id = TestUtils.uuid(id);
+ final albumId = TestUtils.uuid(id);
return LocalAlbum(
- id: id,
- name: name ?? 'local_album_$id',
+ id: albumId,
+ name: name ?? 'local_album_$albumId',
updatedAt: TestUtils.date(updatedAt),
backupSelection: backupSelection ?? .none,
isIosSharedAlbum: isIosSharedAlbum ?? false,
diff --git a/mobile/test/unit/factories/local_asset_factory.dart b/mobile/test/unit/factories/local_asset_factory.dart
index bebe24e5b9..5b16635d48 100644
--- a/mobile/test/unit/factories/local_asset_factory.dart
+++ b/mobile/test/unit/factories/local_asset_factory.dart
@@ -6,11 +6,11 @@ class LocalAssetFactory {
const LocalAssetFactory();
static LocalAsset create({String? id, String? name, String? remoteId}) {
- id = TestUtils.uuid(id);
+ final assetId = TestUtils.uuid(id);
return LocalAsset(
- id: id,
- name: name ?? 'local_$id.jpg',
+ id: assetId,
+ name: name ?? 'local_$assetId.jpg',
remoteId: remoteId,
type: AssetType.image,
createdAt: TestUtils.yesterday(),
diff --git a/mobile/test/unit/factories/partner_user_factory.dart b/mobile/test/unit/factories/partner_user_factory.dart
index 63f94608ad..2ee0fa0773 100644
--- a/mobile/test/unit/factories/partner_user_factory.dart
+++ b/mobile/test/unit/factories/partner_user_factory.dart
@@ -6,11 +6,11 @@ class PartnerFactory {
const PartnerFactory();
static Partner create({String? id, String? email, String? name, bool? inTimeline}) {
- id = TestUtils.uuid(id);
+ final partnerId = TestUtils.uuid(id);
return Partner(
- id: id,
- email: email ?? '$id@test.com',
- name: name ?? 'user_$id',
+ id: partnerId,
+ email: email ?? '$partnerId@test.com',
+ name: name ?? 'user_$partnerId',
inTimeline: inTimeline ?? false,
hasProfileImage: false,
profileChangedAt: DateTime.now(),
diff --git a/mobile/test/unit/factories/remote_album_factory.dart b/mobile/test/unit/factories/remote_album_factory.dart
index bffe62b651..70976b4485 100644
--- a/mobile/test/unit/factories/remote_album_factory.dart
+++ b/mobile/test/unit/factories/remote_album_factory.dart
@@ -19,10 +19,10 @@ class RemoteAlbumFactory {
String? ownerName,
bool isShared = false,
}) {
- id = TestUtils.uuid(id);
+ final albumId = TestUtils.uuid(id);
return RemoteAlbum(
- id: id,
- name: name ?? 'remote_album_$id',
+ id: albumId,
+ name: name ?? 'remote_album_$albumId',
ownerId: TestUtils.uuid(ownerId),
description: description ?? '',
createdAt: TestUtils.date(createdAt),
@@ -31,7 +31,7 @@ class RemoteAlbumFactory {
isActivityEnabled: isActivityEnabled,
order: order,
assetCount: assetCount,
- ownerName: ownerName ?? 'owner_$id',
+ ownerName: ownerName ?? 'owner_$albumId',
isShared: isShared,
);
}
diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart
index 0876b9d793..2936708ab4 100644
--- a/mobile/test/unit/factories/remote_asset_factory.dart
+++ b/mobile/test/unit/factories/remote_asset_factory.dart
@@ -16,13 +16,13 @@ class RemoteAssetFactory {
DateTime? deletedAt,
String? localId,
}) {
- id = TestUtils.uuid(id);
+ final assetId = TestUtils.uuid(id);
return RemoteAsset(
- id: id,
- name: name ?? 'remote_$id.jpg',
+ id: assetId,
+ name: name ?? 'remote_$assetId.jpg',
ownerId: TestUtils.uuid(ownerId),
- checksum: 'checksum-$id',
+ checksum: 'checksum-$assetId',
type: type,
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
diff --git a/mobile/test/unit/factories/user_factory.dart b/mobile/test/unit/factories/user_factory.dart
index 248e98dc69..d3df9556ef 100644
--- a/mobile/test/unit/factories/user_factory.dart
+++ b/mobile/test/unit/factories/user_factory.dart
@@ -13,11 +13,11 @@ class UserFactory {
bool? hasProfileImage,
AvatarColor? avatarColor,
}) {
- id = TestUtils.uuid(id);
+ final userId = TestUtils.uuid(id);
return User(
- id: id,
- name: name ?? 'user_$id',
- email: email ?? '$id@test.com',
+ id: userId,
+ name: name ?? 'user_$userId',
+ email: email ?? '$userId@test.com',
profileChangedAt: TestUtils.date(profileChangedAt),
hasProfileImage: hasProfileImage ?? false,
avatarColor: avatarColor ?? .primary,
@@ -32,11 +32,11 @@ class UserFactory {
bool? hasProfileImage,
AvatarColor? avatarColor,
}) {
- id = TestUtils.uuid(id);
+ final userId = TestUtils.uuid(id);
return UserDto(
- id: id,
- name: name ?? 'user_$id',
- email: email ?? '$id@test.com',
+ id: userId,
+ name: name ?? 'user_$userId',
+ email: email ?? '$userId@test.com',
profileChangedAt: TestUtils.date(profileChangedAt),
hasProfileImage: hasProfileImage ?? false,
avatarColor: avatarColor ?? .primary,
From 9ab12b0c4e9394160708ba117e1b377039a69439 Mon Sep 17 00:00:00 2001
From: Santo Shakil
Date: Mon, 17 Aug 2026 16:10:26 +0600
Subject: [PATCH 85/89] fix(mobile): mark finished downloads complete instead
of leaving them stuck (#29023)
* wire download status to the ui and dismiss finished entries
* mark finished downloads with a checkmark instead of dismissing
* use the theme color for the done icon and guard status updates on a known task
---
mobile/lib/pages/common/download_panel.dart | 10 +-
.../asset_viewer/download.provider.dart | 20 ++++
.../lib/repositories/download.repository.dart | 3 +
mobile/lib/services/download.service.dart | 6 +
.../pages/common/download_panel_test.dart | 24 ++++
.../asset_viewer/download_provider_test.dart | 113 ++++++++++++++++++
6 files changed, 174 insertions(+), 2 deletions(-)
create mode 100644 mobile/test/pages/common/download_panel_test.dart
create mode 100644 mobile/test/providers/asset_viewer/download_provider_test.dart
diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart
index 9ee5833b83..2cf7d037a3 100644
--- a/mobile/lib/pages/common/download_panel.dart
+++ b/mobile/lib/pages/common/download_panel.dart
@@ -64,6 +64,7 @@ class DownloadTaskTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final isComplete = status == TaskStatus.complete;
final progressPercent = (progress * 100).round();
String getStatusText() => switch (status) {
@@ -88,9 +89,14 @@ class DownloadTaskTile extends StatelessWidget {
leading: const Icon(Icons.video_file_outlined),
title: Text(getStatusText(), style: context.textTheme.labelLarge),
trailing: IconButton(
- icon: Icon(Icons.close, color: context.colorScheme.onError),
+ icon: Icon(
+ isComplete ? Icons.download_done : Icons.close,
+ color: isComplete ? context.colorScheme.primary : context.colorScheme.onError,
+ ),
onPressed: onCancelDownload,
- style: ElevatedButton.styleFrom(backgroundColor: context.colorScheme.error.withAlpha(200)),
+ style: isComplete
+ ? null
+ : ElevatedButton.styleFrom(backgroundColor: context.colorScheme.error.withAlpha(200)),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart
index 2c4854bdb0..ba72ef60c5 100644
--- a/mobile/lib/providers/asset_viewer/download.provider.dart
+++ b/mobile/lib/providers/asset_viewer/download.provider.dart
@@ -14,9 +14,29 @@ class DownloadStateNotifier extends StateNotifier {
taskProgress: {},
),
) {
+ _downloadService.onImageDownloadStatus = _downloadStatusCallback;
+ _downloadService.onVideoDownloadStatus = _downloadStatusCallback;
+ _downloadService.onLivePhotoDownloadStatus = _downloadStatusCallback;
_downloadService.onTaskProgress = _taskProgressCallback;
}
+ void _downloadStatusCallback(TaskStatusUpdate update) {
+ if (update.status == TaskStatus.canceled) {
+ return;
+ }
+
+ final existing = state.taskProgress[update.task.taskId];
+ if (existing == null) {
+ return;
+ }
+
+ state = state.copyWith(
+ taskProgress: {}
+ ..addAll(state.taskProgress)
+ ..addAll({update.task.taskId: existing.copyWith(status: update.status)}),
+ );
+ }
+
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is canceled or completed
if (update.progress == -2 || update.progress == -1) {
diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart
index 855b8302a9..8504487860 100644
--- a/mobile/lib/repositories/download.repository.dart
+++ b/mobile/lib/repositories/download.repository.dart
@@ -28,6 +28,8 @@ class DownloadRepository {
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
+ void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
+
void Function(TaskProgressUpdate)? onTaskProgress;
// #29900: `taskStatusCallback` is called before the DB has been updated, causing a race between the two Live Photo tasks
@@ -49,6 +51,7 @@ class DownloadRepository {
_downloader.registerCallbacks(
group: kDownloadGroupLivePhoto,
+ taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart
index f38b20cc21..09a56f0ce9 100644
--- a/mobile/lib/services/download.service.dart
+++ b/mobile/lib/services/download.service.dart
@@ -22,6 +22,7 @@ class DownloadService {
final Logger _log = Logger("DownloadService");
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
+ void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
/// Active Live Photo IDs undergoing saving
@@ -30,6 +31,7 @@ class DownloadService {
DownloadService(this._fileMediaRepository, this._downloadRepository) {
_downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
_downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
+ _downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback;
_downloadRepository.onTaskProgress = _onTaskProgressCallback;
_downloadRepository.onLivePhotoRecordComplete = _onLivePhotoRecordComplete;
@@ -65,6 +67,10 @@ class DownloadService {
onVideoDownloadStatus?.call(update);
}
+ void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
+ onLivePhotoDownloadStatus?.call(update);
+ }
+
Future _onLivePhotoRecordComplete(TaskRecord record) async {
final livePhotosId = LivePhotosMetadata.fromJson(record.task.metaData).id;
await _saveLivePhotos(livePhotosId);
diff --git a/mobile/test/pages/common/download_panel_test.dart b/mobile/test/pages/common/download_panel_test.dart
new file mode 100644
index 0000000000..f2a6b09d88
--- /dev/null
+++ b/mobile/test/pages/common/download_panel_test.dart
@@ -0,0 +1,24 @@
+import 'package:background_downloader/background_downloader.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/pages/common/download_panel.dart';
+
+import '../../widget_tester_extensions.dart';
+
+void main() {
+ testWidgets('complete uses a checkmark and other statuses use close', (tester) async {
+ await tester.pumpConsumerWidget(
+ DownloadTaskTile(progress: 1, fileName: 'photo.jpg', status: TaskStatus.complete, onCancelDownload: () {}),
+ );
+
+ expect(find.byIcon(Icons.download_done), findsOneWidget);
+ expect(find.byIcon(Icons.close), findsNothing);
+
+ await tester.pumpConsumerWidget(
+ DownloadTaskTile(progress: 1, fileName: 'photo.jpg', status: TaskStatus.failed, onCancelDownload: () {}),
+ );
+
+ expect(find.byIcon(Icons.download_done), findsNothing);
+ expect(find.byIcon(Icons.close), findsOneWidget);
+ });
+}
diff --git a/mobile/test/providers/asset_viewer/download_provider_test.dart b/mobile/test/providers/asset_viewer/download_provider_test.dart
new file mode 100644
index 0000000000..2e2694475d
--- /dev/null
+++ b/mobile/test/providers/asset_viewer/download_provider_test.dart
@@ -0,0 +1,113 @@
+import 'package:background_downloader/background_downloader.dart';
+import 'package:fake_async/fake_async.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
+import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
+import 'package:immich_mobile/services/download.service.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockDownloadService extends Mock implements DownloadService {}
+
+DownloadTask _task(String id, {String filename = 'photo.jpg', String metaData = ''}) =>
+ DownloadTask(taskId: id, url: 'https://example.com/$filename', filename: filename, metaData: metaData);
+
+void main() {
+ late MockDownloadService service;
+ late DownloadStateNotifier notifier;
+ late void Function(TaskProgressUpdate) onProgress;
+ late void Function(TaskStatusUpdate) onImage;
+ late void Function(TaskStatusUpdate) onLivePhoto;
+
+ setUp(() {
+ service = MockDownloadService();
+ notifier = DownloadStateNotifier(service);
+ addTearDown(() {
+ if (notifier.mounted) {
+ notifier.dispose();
+ }
+ });
+
+ onProgress = verify(() => service.onTaskProgress = captureAny()).captured.last as void Function(TaskProgressUpdate);
+ onImage =
+ verify(() => service.onImageDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate);
+ onLivePhoto =
+ verify(() => service.onLivePhotoDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate);
+ });
+
+ test('complete flips the entry and keeps it visible', () {
+ fakeAsync((async) {
+ final task = _task('task-1');
+ onProgress(TaskProgressUpdate(task, 0.4));
+ onImage(TaskStatusUpdate(task, TaskStatus.complete));
+
+ expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.complete);
+ expect(notifier.state.showProgress, isTrue);
+
+ async.elapse(const Duration(seconds: 5));
+
+ expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.complete);
+ expect(notifier.state.showProgress, isTrue);
+ });
+ });
+
+ test('failed keeps the entry visible', () {
+ fakeAsync((async) {
+ final task = _task('task-1');
+ onProgress(TaskProgressUpdate(task, 0.4));
+ onImage(TaskStatusUpdate(task, TaskStatus.failed));
+
+ expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.failed);
+
+ async.elapse(const Duration(seconds: 5));
+
+ expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.failed);
+ expect(notifier.state.showProgress, isTrue);
+ });
+ });
+
+ test('a live photo part completion keeps both entries', () {
+ fakeAsync((async) {
+ final image = _task(
+ 'live-image',
+ metaData: LivePhotosMetadata(part: LivePhotosPart.image, id: 'live-1').toJson(),
+ );
+ final video = _task(
+ 'live-video',
+ filename: 'photo.MOV',
+ metaData: LivePhotosMetadata(part: LivePhotosPart.video, id: 'live-1').toJson(),
+ );
+ onProgress(TaskProgressUpdate(image, 0.9));
+ onProgress(TaskProgressUpdate(video, 0.9));
+ onLivePhoto(TaskStatusUpdate(image, TaskStatus.complete));
+
+ async.elapse(const Duration(seconds: 5));
+
+ expect(notifier.state.taskProgress['live-image']?.status, TaskStatus.complete);
+ expect(notifier.state.taskProgress['live-video']?.status, TaskStatus.running);
+ expect(notifier.state.showProgress, isTrue);
+ });
+ });
+
+ test('canceled does not resurrect or alter the entry', () {
+ fakeAsync((async) {
+ final task = _task('task-1');
+ onProgress(TaskProgressUpdate(task, 0.4));
+ onImage(TaskStatusUpdate(task, TaskStatus.canceled));
+
+ expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.running);
+ expect(notifier.state.taskProgress['task-1']?.progress, 0.4);
+
+ onImage(TaskStatusUpdate(_task('ghost'), TaskStatus.canceled));
+ expect(notifier.state.taskProgress.containsKey('ghost'), isFalse);
+ });
+ });
+
+ test('a status for an unknown task does not create an entry', () {
+ fakeAsync((async) {
+ onImage(TaskStatusUpdate(_task('ghost'), TaskStatus.complete));
+
+ expect(notifier.state.taskProgress, isEmpty);
+ expect(notifier.state.showProgress, isFalse);
+ });
+ });
+}
From 3cec8fcc3ce92a0635028e0f1ae92172ce2a5dfd Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 13:01:30 +0000
Subject: [PATCH 86/89] chore(deps): update base-image to v202608111116
(#30818)
---
server/Dockerfile | 4 ++--
server/Dockerfile.dev | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/server/Dockerfile b/server/Dockerfile
index 9d5eb1cccb..c43d485144 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,4 +1,4 @@
-FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS builder
+FROM ghcr.io/immich-app/base-server-dev:202608111116@sha256:f53e6bc2aa6e4c7b043e6ec1740448af4ba69e2090411daa7fa1df288bc0131a AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -81,7 +81,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
-FROM ghcr.io/immich-app/base-server-prod:202607211135@sha256:ced131da7523544fe975cfd25abd67386e712e39496340b437cd95a08d0a18f3
+FROM ghcr.io/immich-app/base-server-prod:202608111116@sha256:15083a121ada9d243c3e717d84c2819a028f214c1f4bf3b39d0b63a299e994b3
WORKDIR /usr/src/app
ENV NODE_ENV=production \
diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev
index 9a46f41b4e..1570436ac2 100644
--- a/server/Dockerfile.dev
+++ b/server/Dockerfile.dev
@@ -1,5 +1,5 @@
# dev build
-FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev
+FROM ghcr.io/immich-app/base-server-dev:202608111116@sha256:f53e6bc2aa6e4c7b043e6ec1740448af4ba69e2090411daa7fa1df288bc0131a AS dev
COPY --from=ghcr.io/jdx/mise:2026.8.3@sha256:92dbc3f2573926d8974e4641ad8449f16c323130b9f41c39aff19b7b2f500ef6 /usr/local/bin/mise /usr/local/bin/mise
From d84be93c2ccff215d8208fdaaff40227efb8e3d0 Mon Sep 17 00:00:00 2001
From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Date: Mon, 17 Aug 2026 22:14:14 +0530
Subject: [PATCH 87/89] chore: flutter pub upgrade (#29985)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
mobile/pubspec.lock | 40 ++++++++++++++++++++--------------------
mobile/pubspec.yaml | 18 +++++++++---------
2 files changed, 29 insertions(+), 29 deletions(-)
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index f84d672090..34676069dd 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -69,10 +69,10 @@ packages:
dependency: "direct main"
description:
name: background_downloader
- sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
+ sha256: d27fcaf94f738032f1e6db098230799bf31ae7bf184f173fc9a752fcdae75c0e
url: "https://pub.dev"
source: hosted
- version: "9.5.5"
+ version: "9.5.6"
bonsoir:
dependency: "direct overridden"
description:
@@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: build
- sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
+ sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae"
url: "https://pub.dev"
source: hosted
- version: "4.0.6"
+ version: "4.0.7"
build_config:
dependency: transitive
description:
@@ -157,10 +157,10 @@ packages:
dependency: "direct dev"
description:
name: build_runner
- sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
+ sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16"
url: "https://pub.dev"
source: hosted
- version: "2.15.0"
+ version: "2.15.1"
built_collection:
dependency: transitive
description:
@@ -253,10 +253,10 @@ packages:
dependency: "direct main"
description:
name: connectivity_plus
- sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057"
+ sha256: "25cebb79dfe304022550e0c3c893ae051ded07183d2607f7b88473cb3ade33cb"
url: "https://pub.dev"
source: hosted
- version: "7.1.1"
+ version: "7.3.0"
connectivity_plus_platform_interface:
dependency: transitive
description:
@@ -634,10 +634,10 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
- sha256: fc599671cbe8b328e509c961ec121880406ed994dde659cc9ece9c7503cd31c7
+ sha256: f23555e9d34d5a7dc70fdfa3ab8915b401786879d278568b3c0e801af9a14d2e
url: "https://pub.dev"
source: hosted
- version: "4.1.2"
+ version: "4.1.6"
flutter_web_auth_2:
dependency: "direct main"
description:
@@ -868,10 +868,10 @@ packages:
dependency: "direct main"
description:
name: image_picker
- sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
+ sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
- version: "1.2.2"
+ version: "1.2.3"
image_picker_android:
dependency: transitive
description:
@@ -1250,10 +1250,10 @@ packages:
dependency: "direct main"
description:
name: path_provider
- sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+ sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
- version: "2.1.5"
+ version: "2.1.6"
path_provider_android:
dependency: transitive
description:
@@ -1656,18 +1656,18 @@ packages:
dependency: "direct main"
description:
name: sqlite3
- sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
+ sha256: "61c7930bebf32c552ac5808c91bf33802e66711c9216090d3b5579c9f862e80c"
url: "https://pub.dev"
source: hosted
- version: "3.3.2"
+ version: "3.4.0"
sqlite3_connection_pool:
dependency: "direct main"
description:
name: sqlite3_connection_pool
- sha256: "9d2b3b398b03c96743fd071521fc665be73c33c9cd5c56d87196baff8d8b4398"
+ sha256: ad51972d37bdf82929888e4c85e722bd9a0b5ff8ad389f89b1b00d19603c0d2d
url: "https://pub.dev"
source: hosted
- version: "0.2.6"
+ version: "0.2.7"
sqlite3_web:
dependency: transitive
description:
@@ -1864,10 +1864,10 @@ packages:
dependency: "direct main"
description:
name: uuid
- sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+ sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
- version: "4.5.3"
+ version: "4.6.0"
vector_graphics:
dependency: transitive
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 7a8d9ca32b..5a83ad7556 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -11,10 +11,10 @@ environment:
dependencies:
async: ^2.13.1
auto_route: ^11.1.0
- background_downloader: ^9.5.4
+ background_downloader: ^9.5.6
cast: ^2.1.0
collection: ^1.19.1
- connectivity_plus: ^7.0.0
+ connectivity_plus: ^7.3.0
crop_image: ^1.0.17
crypto: ^3.0.7
device_info_plus: ^12.4.0
@@ -31,14 +31,14 @@ dependencies:
flutter_local_notifications: ^17.2.4
flutter_secure_storage: ^9.2.4
flutter_svg: ^2.2.4
- flutter_udid: ^4.1.2
+ flutter_udid: ^4.1.6
flutter_web_auth_2: ^5.0.2
fluttertoast: ^8.2.14
geolocator: ^14.0.2
home_widget: ^0.9.3
hooks_riverpod: ^2.6.1
http: ^1.6.0
- image_picker: ^1.2.1
+ image_picker: ^1.2.3
immich_ui:
path: './packages/ui'
intl: ^0.20.2
@@ -55,7 +55,7 @@ dependencies:
path: generated/openapi
package_info_plus: ^8.3.1
path: ^1.9.1
- path_provider: ^2.1.5
+ path_provider: ^2.1.6
path_provider_foundation: ^2.6.0
permission_handler: ^11.4.0
photo_manager: 3.9.0
@@ -67,13 +67,13 @@ dependencies:
share_plus: ^10.1.4
sliver_tools: ^0.2.12
stream_transform: ^2.1.1
- sqlite3: ^3.3.2
+ sqlite3: ^3.4.0
sqlite_async: 0.14.2
- sqlite3_connection_pool: ^0.2.6
+ sqlite3_connection_pool: ^0.2.7
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.2
- uuid: ^4.5.3
+ uuid: ^4.6.0
wakelock_plus: ^1.3.3
worker_manager: ^7.2.9
web_socket: ^1.0.1
@@ -95,7 +95,7 @@ dependencies:
dev_dependencies:
auto_route_generator: ^10.5.0
- build_runner: ^2.13.1
+ build_runner: ^2.15.1
# Drift generator
drift_dev: ^2.34.0
fake_async: ^1.3.3
From 01e40c07486ee56c2928053740c9c27189e62e0b Mon Sep 17 00:00:00 2001
From: Yuichiro Yamashita
Date: Tue, 18 Aug 2026 02:52:43 +0900
Subject: [PATCH 88/89] chore: speed up lint ~4x by enabling
svelte-eslint-parser's ts.sys hook (#30782)
---
pnpm-lock.yaml | 70 ++++++++++++++++++++++----------------------
web/eslint.config.js | 2 ++
web/lint-env.js | 5 ++++
web/package.json | 4 +--
4 files changed, 44 insertions(+), 37 deletions(-)
create mode 100644 web/lint-env.js
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1e147c2502..a24e61a884 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1033,8 +1033,8 @@ importers:
specifier: ^4.4.6
version: 4.7.4(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.66.0))
svelte-eslint-parser:
- specifier: ^1.3.3
- version: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))
+ specifier: ^1.8.1
+ version: 1.8.1(svelte@5.56.8(@typescript-eslint/types@8.66.0))
tailwindcss:
specifier: ^4.2.4
version: 4.3.3
@@ -10738,8 +10738,8 @@ packages:
resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
engines: {node: '>=4'}
- postcss-selector-parser@7.1.4:
- resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
+ postcss-selector-parser@7.1.5:
+ resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==}
engines: {node: '>=4'}
postcss-sort-media-queries@5.2.0:
@@ -11786,9 +11786,9 @@ packages:
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: ^5.0.0 || ^6.0.0
- svelte-eslint-parser@1.8.0:
- resolution: {integrity: sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.1}
+ svelte-eslint-parser@1.8.1:
+ resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5}
peerDependencies:
svelte: ^3.37.0 || ^4.0.0 || ^5.0.0
peerDependenciesMeta:
@@ -14135,9 +14135,9 @@ snapshots:
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
dependencies:
- '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4)
+ '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5)
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
dependencies:
@@ -14243,9 +14243,9 @@ snapshots:
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
dependencies:
- '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4)
+ '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5)
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
dependencies:
@@ -14347,7 +14347,7 @@ snapshots:
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
dependencies:
@@ -14391,13 +14391,13 @@ snapshots:
dependencies:
postcss: 8.5.26
- '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.4)':
+ '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.5)':
dependencies:
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
- '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.4)':
+ '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.5)':
dependencies:
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
'@csstools/utilities@2.0.0(postcss@8.5.26)':
dependencies:
@@ -19701,7 +19701,7 @@ snapshots:
css-blank-pseudo@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
css-declaration-sorter@7.4.0(postcss@8.5.26):
dependencies:
@@ -19709,9 +19709,9 @@ snapshots:
css-has-pseudo@7.0.3(postcss@8.5.26):
dependencies:
- '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4)
+ '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5)
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-value-parser: 4.2.0
css-loader@6.11.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)):
@@ -20600,7 +20600,7 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.26)
postcss-safe-parser: 7.0.1(postcss@8.5.26)
semver: 7.8.5
- svelte-eslint-parser: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))
+ svelte-eslint-parser: 1.8.1(svelte@5.56.8(@typescript-eslint/types@8.66.0))
optionalDependencies:
svelte: 5.56.8(@typescript-eslint/types@8.66.0)
transitivePeerDependencies:
@@ -23857,7 +23857,7 @@ snapshots:
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-calc@9.0.1(postcss@8.5.26):
dependencies:
@@ -23928,12 +23928,12 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-discard-comments@6.0.2(postcss@8.5.26):
dependencies:
@@ -23966,12 +23966,12 @@ snapshots:
postcss-focus-visible@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-focus-within@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-font-variant@5.0.0(postcss@8.5.26):
dependencies:
@@ -24091,13 +24091,13 @@ snapshots:
dependencies:
icss-utils: 5.1.0(postcss@8.5.26)
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-value-parser: 4.2.0
postcss-modules-scope@3.2.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-modules-values@4.0.0(postcss@8.5.26):
dependencies:
@@ -24111,10 +24111,10 @@ snapshots:
postcss-nesting@13.0.2(postcss@8.5.26):
dependencies:
- '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.4)
- '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4)
+ '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.5)
+ '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5)
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-normalize-charset@6.0.2(postcss@8.5.26):
dependencies:
@@ -24263,7 +24263,7 @@ snapshots:
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-reduce-idents@6.0.3(postcss@8.5.26):
dependencies:
@@ -24296,14 +24296,14 @@ snapshots:
postcss-selector-not@8.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
postcss-selector-parser@6.1.4:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss-selector-parser@7.1.4:
+ postcss-selector-parser@7.1.5:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -25627,14 +25627,14 @@ snapshots:
transitivePeerDependencies:
- picomatch
- svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)):
+ svelte-eslint-parser@1.8.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)):
dependencies:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
postcss: 8.5.26
postcss-scss: 4.0.9(postcss@8.5.26)
- postcss-selector-parser: 7.1.4
+ postcss-selector-parser: 7.1.5
semver: 7.8.5
optionalDependencies:
svelte: 5.56.8(@typescript-eslint/types@8.66.0)
diff --git a/web/eslint.config.js b/web/eslint.config.js
index fc69385c93..f7fd82d751 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -10,6 +10,7 @@ import parser from 'svelte-eslint-parser';
import typescriptEslint from 'typescript-eslint';
import fs from 'node:fs';
import path from 'node:path';
+import './lint-env.js';
export default typescriptEslint.config(
...eslintPluginSvelte.configs.recommended,
@@ -68,6 +69,7 @@ export default typescriptEslint.config(
'**/yarn.lock',
'**/svelte.config.js',
'eslint.config.js',
+ 'lint-env.js',
'tailwind.config.js',
'coverage',
'vite.config.ts',
diff --git a/web/lint-env.js b/web/lint-env.js
new file mode 100644
index 0000000000..3394cdb783
--- /dev/null
+++ b/web/lint-env.js
@@ -0,0 +1,5 @@
+// Must be imported before eslint-plugin-svelte / svelte-eslint-parser.
+// Enables svelte-eslint-parser's ts.sys hook so typescript-eslint can reuse
+// its TypeScript program across .svelte files instead of rebuilding it per
+// file (~4x faster lint). See https://github.com/sveltejs/eslint-plugin-svelte/issues/1552
+process.env.SVELTE_ESLINT_PARSER_EXPERIMENTAL_TS_SYS_HOOK ??= '1';
diff --git a/web/package.json b/web/package.json
index d709ca0de6..ca0274df88 100644
--- a/web/package.json
+++ b/web/package.json
@@ -90,6 +90,7 @@
"@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2",
"@types/qrcode": "^1.5.5",
+ "@typescript/native": "npm:typescript@^7.0.2",
"@vitest/coverage-v8": "^4.0.0",
"dotenv": "^17.0.0",
"eslint": "^10.2.1",
@@ -107,9 +108,8 @@
"rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.56.8",
"svelte-check": "^4.4.6",
- "svelte-eslint-parser": "^1.3.3",
+ "svelte-eslint-parser": "^1.8.1",
"tailwindcss": "^4.2.4",
- "@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.45.0",
"vite": "^8.0.0",
From 225ca9ab8dfbddc8a9cce4a2a0b4165ef1c6937b Mon Sep 17 00:00:00 2001
From: Adam Gastineau
Date: Mon, 17 Aug 2026 14:43:32 -0700
Subject: [PATCH 89/89] chore(mobile): use Drift @DriftAccessor() and collapse
some providers (#30693)
* chore(mobile): use Drift @DriftAccessor() and collapse some providers
* Fix import order
* Required formatting
---
mobile/build.yaml | 3 +-
mobile/lib/domain/services/asset.service.dart | 12 ++--
.../services/background_worker.service.dart | 23 ++++----
mobile/lib/domain/services/hash.service.dart | 6 +-
.../domain/services/local_album.service.dart | 2 +-
.../domain/services/local_sync.service.dart | 8 +--
mobile/lib/domain/services/map.service.dart | 2 +-
.../lib/domain/services/memory.service.dart | 2 +-
.../lib/domain/services/partner.service.dart | 4 +-
.../lib/domain/services/people.service.dart | 2 +-
.../domain/services/remote_album.service.dart | 4 +-
mobile/lib/domain/services/store.service.dart | 10 ++--
.../services/sync_linked_album.service.dart | 19 ++++---
.../domain/services/sync_stream.service.dart | 4 +-
.../lib/domain/services/timeline.service.dart | 2 +-
.../lib/domain/utils/migrate_cloud_ids.dart | 2 +-
.../repositories/backup.repository.dart | 13 ++---
.../repositories/db.repository.dart | 48 +++++++++++++---
.../repositories/local_album.repository.dart | 10 ++--
.../repositories/local_asset.repository.dart | 10 ++--
.../repositories/map.repository.dart | 8 ++-
.../repositories/memory.repository.dart | 9 ++-
.../repositories/ocr.repository.dart | 9 ++-
.../repositories/partner.repository.dart | 13 +++--
.../repositories/people.repository.dart | 9 ++-
.../repositories/remote_album.repository.dart | 11 ++--
.../repositories/remote_asset.repository.dart | 12 ++--
.../repositories/remote_exif.repository.dart | 11 ++--
.../repositories/stack.repository.dart | 9 ++-
.../repositories/store.repository.dart | 15 +++--
.../sync_migration.repository.dart | 9 ++-
.../repositories/sync_stream.repository.dart | 11 ++--
.../repositories/timeline.repository.dart | 8 ++-
.../trashed_local_asset.repository.dart | 8 ++-
.../repositories/user.repository.dart | 17 ++++--
.../user_metadata.repository.dart | 8 ++-
.../backup/drift_upload_detail.page.dart | 6 +-
.../actions/edit_asset.action.dart | 3 +-
.../actions/edit_datetime.action.dart | 3 +-
.../actions/edit_location.action.dart | 3 +-
.../pages/dev/media_stat.page.dart | 5 +-
.../backup/backup_album.provider.dart | 4 +-
.../backup/drift_backup.provider.dart | 7 ++-
.../infrastructure/album.provider.dart | 20 +------
.../infrastructure/asset.provider.dart | 35 +++---------
.../infrastructure/map.provider.dart | 8 +--
.../infrastructure/memory.provider.dart | 11 +---
.../infrastructure/ocr.provider.dart | 7 +--
.../infrastructure/people.provider.dart | 7 +--
.../infrastructure/stack.provider.dart | 5 --
.../infrastructure/sync.provider.dart | 55 +++++++++----------
.../infrastructure/timeline.provider.dart | 10 +---
.../infrastructure/trash_sync.provider.dart | 4 +-
.../infrastructure/user.provider.dart | 17 ++----
.../user_metadata.provider.dart | 7 +--
mobile/lib/services/action.service.dart | 4 +-
.../services/background_upload.service.dart | 11 ++--
mobile/lib/services/cleanup.service.dart | 8 +--
mobile/lib/services/deep_link.service.dart | 4 +-
.../services/foreground_upload.service.dart | 5 +-
.../view_intent_asset_resolver.service.dart | 6 +-
mobile/lib/utils/bootstrap.dart | 2 +-
.../sync_status_and_actions.dart | 8 ++-
.../services/local_sync_service_test.dart | 8 +--
.../domain/services/store_service_test.dart | 8 +--
.../sync_linked_album_service_test.dart | 9 ++-
.../services/sync_stream_service_test.dart | 6 +-
.../local_album_repository_test.dart | 6 +-
.../repositories/store_repository_test.dart | 6 +-
.../sync_api_repository_test.dart | 2 +-
.../test/infrastructure/repository.mock.dart | 20 ++++---
.../repositories/backup_repository_test.dart | 4 +-
.../local_asset_repository_test.dart | 4 +-
.../repositories/memory_repository_test.dart | 4 +-
.../repositories/partner_repository_test.dart | 4 +-
.../repositories/people_repository_test.dart | 4 +-
.../remote_album_repository_test.dart | 4 +-
.../timeline_repository_test.dart | 4 +-
.../trashed_local_asset_repository_test.dart | 4 +-
.../modules/map/map_theme_override_test.dart | 2 +-
.../infrastructure/memory_provider_test.dart | 32 +++++++----
mobile/test/services/action.service_test.dart | 2 +-
mobile/test/services/auth.service_test.dart | 2 +-
.../background_upload.service_test.dart | 10 ++--
.../test/services/cleanup.service_test.dart | 14 ++---
.../foreground_upload.service_test.dart | 6 +-
.../view_intent_asset_resolver_test.dart | 11 ++--
mobile/test/test_utils/medium_factory.dart | 4 +-
mobile/test/unit/mocks.dart | 10 ++--
.../presentation/presentation_context.dart | 12 +++-
.../unit/services/asset_service_test.dart | 14 ++---
mobile/test/utils/image_url_builder_test.dart | 2 +-
92 files changed, 434 insertions(+), 402 deletions(-)
delete mode 100644 mobile/lib/providers/infrastructure/stack.provider.dart
diff --git a/mobile/build.yaml b/mobile/build.yaml
index cb718d9d3e..32cd96c5d3 100644
--- a/mobile/build.yaml
+++ b/mobile/build.yaml
@@ -18,8 +18,7 @@ targets:
generate_for: &drift_generate_for
- lib/infrastructure/entities/*.dart
- lib/infrastructure/entities/*.drift
- - lib/infrastructure/repositories/db.repository.dart
- - lib/infrastructure/repositories/logger_db.repository.dart
+ - lib/infrastructure/repositories/*.repository.dart
drift_dev:modular:
enabled: true
options: *drift_options
diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart
index 59f41f1e51..0032403348 100644
--- a/mobile/lib/domain/services/asset.service.dart
+++ b/mobile/lib/domain/services/asset.service.dart
@@ -17,10 +17,10 @@ import 'package:maplibre_gl/maplibre_gl.dart';
class AssetService {
final RemoteAssetRepository _remoteRepository;
final RemoteExifRepository _exifRepository;
- final DriftLocalAssetRepository _localRepository;
+ final LocalAssetRepository _localRepository;
final AssetApiRepository _apiRepository;
final AssetMediaRepository _mediaRepository;
- final DriftTrashedLocalAssetRepository _trashedLocalRepository;
+ final TrashedLocalAssetRepository _trashedLocalRepository;
const AssetService({
required this._remoteRepository,
@@ -136,13 +136,13 @@ class AssetService {
location: location,
dateTimeOriginal: dateTime,
);
- await _remoteRepository.update(
+ await _remoteRepository.updateAssets(
remoteIds,
isFavorite: isFavorite,
visibility: visibility,
createdAt: parsedDateTime,
);
- await _exifRepository.update(
+ await _exifRepository.updateExif(
remoteIds,
location: location,
dateTimeOriginal: parsedDateTime,
@@ -165,7 +165,7 @@ class AssetService {
}
await _apiRepository.delete(remoteIds, true);
- await _remoteRepository.delete(remoteIds);
+ await _remoteRepository.deleteAssets(remoteIds);
}
Future applyEdits(String remoteId, List edits) async {
@@ -189,7 +189,7 @@ class AssetService {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalRepository.applyTrashedAssets(deletedIds);
} else {
- await _localRepository.delete(deletedIds);
+ await _localRepository.deleteAssets(deletedIds);
}
return deletedIds.length;
}
diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart
index 8161df5c51..817cc33380 100644
--- a/mobile/lib/domain/services/background_worker.service.dart
+++ b/mobile/lib/domain/services/background_worker.service.dart
@@ -19,8 +19,6 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
-import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
-import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
@@ -76,31 +74,32 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
_ref = ref;
+ final db = ref.read(driftProvider);
_localSyncService = LocalSyncService(
- localAlbumRepository: ref.read(localAlbumRepository),
- localAssetRepository: ref.read(localAssetRepository),
+ localAlbumRepository: db.localAlbumRepository,
+ localAssetRepository: db.localAssetRepository,
nativeSyncApi: ref.read(nativeSyncApiProvider),
- trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
+ trashedLocalAssetRepository: db.trashedLocalAssetRepository,
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
permissionRepository: ref.read(permissionRepositoryProvider),
cancellation: _cancellationToken,
);
_remoteSyncService = SyncStreamService(
syncApiRepository: ref.read(syncApiRepositoryProvider),
- syncStreamRepository: ref.read(syncStreamRepositoryProvider),
- localAssetRepository: ref.read(localAssetRepository),
- trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
+ syncStreamRepository: db.syncStreamRepository,
+ localAssetRepository: db.localAssetRepository,
+ trashedLocalAssetRepository: db.trashedLocalAssetRepository,
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
permissionRepository: ref.read(permissionRepositoryProvider),
- syncMigrationRepository: ref.read(syncMigrationRepositoryProvider),
+ syncMigrationRepository: db.syncMigrationRepository,
api: ref.read(apiServiceProvider),
cancellation: _cancellationToken,
);
_hashService = HashService(
- localAlbumRepository: ref.read(localAlbumRepository),
- localAssetRepository: ref.read(localAssetRepository),
+ localAlbumRepository: db.localAlbumRepository,
+ localAssetRepository: db.localAssetRepository,
nativeSyncApi: ref.read(nativeSyncApiProvider),
- trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
+ trashedLocalAssetRepository: db.trashedLocalAssetRepository,
cancellation: _cancellationToken,
);
BackgroundWorkerFlutterApi.setUp(this);
diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart
index 8a573ee209..64595aff2b 100644
--- a/mobile/lib/domain/services/hash.service.dart
+++ b/mobile/lib/domain/services/hash.service.dart
@@ -15,9 +15,9 @@ const String _kHashCancelledCode = "HASH_CANCELLED";
class HashService {
final int _batchSize;
- final DriftLocalAlbumRepository _localAlbumRepository;
- final DriftLocalAssetRepository _localAssetRepository;
- final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
+ final LocalAlbumRepository _localAlbumRepository;
+ final LocalAssetRepository _localAssetRepository;
+ final TrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final Completer? _cancellation;
final _log = Logger('HashService');
diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart
index e3d888f063..8661a8d10a 100644
--- a/mobile/lib/domain/services/local_album.service.dart
+++ b/mobile/lib/domain/services/local_album.service.dart
@@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
class LocalAlbumService {
- final DriftLocalAlbumRepository _repository;
+ final LocalAlbumRepository _repository;
const LocalAlbumService(this._repository);
diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart
index 9e272ac65f..0793d191b0 100644
--- a/mobile/lib/domain/services/local_sync.service.dart
+++ b/mobile/lib/domain/services/local_sync.service.dart
@@ -21,11 +21,11 @@ import 'package:logging/logging.dart';
const String _kSyncCancelledCode = "SYNC_CANCELLED";
class LocalSyncService {
- final DriftLocalAlbumRepository _localAlbumRepository;
+ final LocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
- final DriftLocalAssetRepository _localAssetRepository;
+ final LocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
- final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
+ final TrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DevicePermissionRepository _permissionRepository;
final Completer? _cancellation;
@@ -186,7 +186,7 @@ class LocalSyncService {
_log.fine("Removing device album ${a.name}");
try {
// Asset deletion is handled in the repository
- await _localAlbumRepository.delete(a.id);
+ await _localAlbumRepository.deleteAlbum(a.id);
} catch (e, s) {
_log.warning("Error while removing device album", e, s);
}
diff --git a/mobile/lib/domain/services/map.service.dart b/mobile/lib/domain/services/map.service.dart
index 6c484d1a14..5e0a3bd857 100644
--- a/mobile/lib/domain/services/map.service.dart
+++ b/mobile/lib/domain/services/map.service.dart
@@ -8,7 +8,7 @@ typedef MapMarkerSource = Future> Function(LatLngBounds? bounds);
typedef MapQuery = ({MapMarkerSource markerSource});
class MapFactory {
- final DriftMapRepository _mapRepository;
+ final MapRepository _mapRepository;
const MapFactory({required this._mapRepository});
diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart
index ead613370f..ccb865fb64 100644
--- a/mobile/lib/domain/services/memory.service.dart
+++ b/mobile/lib/domain/services/memory.service.dart
@@ -5,7 +5,7 @@ import 'package:logging/logging.dart';
class DriftMemoryService {
final log = Logger("DriftMemoryService");
- final DriftMemoryRepository _repository;
+ final MemoryRepository _repository;
DriftMemoryService(this._repository);
diff --git a/mobile/lib/domain/services/partner.service.dart b/mobile/lib/domain/services/partner.service.dart
index 63985823aa..3fd0d32683 100644
--- a/mobile/lib/domain/services/partner.service.dart
+++ b/mobile/lib/domain/services/partner.service.dart
@@ -27,7 +27,7 @@ class PartnerService {
Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) async {
await _partnerApiRepository.update(sharedById, inTimeline: inTimeline);
- await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
+ await _partnerRepository.updatePartner(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
Future create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async {
@@ -37,6 +37,6 @@ class PartnerService {
Future delete({required String sharedById, required String sharedWithId}) async {
await _partnerApiRepository.delete(sharedWithId);
- await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId);
+ await _partnerRepository.deletePartner(sharedById: sharedById, sharedWithId: sharedWithId);
}
}
diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart
index 196cf1edbb..e0323246b0 100644
--- a/mobile/lib/domain/services/people.service.dart
+++ b/mobile/lib/domain/services/people.service.dart
@@ -5,7 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/people.repository.dart
import 'package:immich_mobile/repositories/person_api.repository.dart';
class DriftPeopleService {
- final DriftPeopleRepository _repository;
+ final PeopleRepository _repository;
final PersonApiRepository _personApiRepository;
const DriftPeopleService(this._repository, this._personApiRepository);
diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart
index e59d75b01a..8e6a6c8aae 100644
--- a/mobile/lib/domain/services/remote_album.service.dart
+++ b/mobile/lib/domain/services/remote_album.service.dart
@@ -25,7 +25,7 @@ class AlbumAssetCandidates {
class RemoteAlbumService {
static final _logger = Logger('RemoteAlbumService');
- final DriftRemoteAlbumRepository _repository;
+ final RemoteAlbumRepository _repository;
final DriftAlbumApiRepository _albumApiRepository;
final ForegroundUploadService _uploadService;
@@ -152,7 +152,7 @@ class RemoteAlbumService {
);
// Update the local database
- await _repository.update(updatedAlbum);
+ await _repository.updateAlbum(updatedAlbum);
return updatedAlbum;
}
diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart
index 758622a43b..ea830dc322 100644
--- a/mobile/lib/domain/services/store.service.dart
+++ b/mobile/lib/domain/services/store.service.dart
@@ -6,13 +6,13 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
/// Provides access to a persistent key-value store with an in-memory cache.
/// Listens for repository changes to keep the cache updated.
class StoreService {
- final DriftStoreRepository _storeRepository;
+ final StoreRepository _storeRepository;
/// In-memory cache. Keys are [StoreKey.id]
final Map _cache = {};
StreamSubscription>? _storeUpdateSubscription;
- StoreService._({required DriftStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
+ StoreService._({required StoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
static StoreService? _instance;
@@ -24,12 +24,12 @@ class StoreService {
}
// TODO: Replace the implementation with the one from create after removing the typedef
- static Future init({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async {
+ static Future init({required StoreRepository storeRepository, bool listenUpdates = true}) async {
_instance ??= await create(storeRepository: storeRepository, listenUpdates: listenUpdates);
return _instance!;
}
- static Future create({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async {
+ static Future create({required StoreRepository storeRepository, bool listenUpdates = true}) async {
final instance = StoreService._(isarStoreRepository: storeRepository);
await instance.populateCache();
if (listenUpdates) {
@@ -90,7 +90,7 @@ class StoreService {
/// Removes the value for [key]
Future delete(StoreKey key) async {
- await _storeRepository.delete(key);
+ await _storeRepository.deleteValue(key);
_cache.remove(key.id);
}
diff --git a/mobile/lib/domain/services/sync_linked_album.service.dart b/mobile/lib/domain/services/sync_linked_album.service.dart
index 091ca222f0..80a1fbaf9e 100644
--- a/mobile/lib/domain/services/sync_linked_album.service.dart
+++ b/mobile/lib/domain/services/sync_linked_album.service.dart
@@ -6,24 +6,25 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
-import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
-final syncLinkedAlbumServiceProvider = Provider(
- (ref) => SyncLinkedAlbumService(
- ref.watch(localAlbumRepository),
- ref.watch(remoteAlbumRepository),
+final syncLinkedAlbumServiceProvider = Provider((ref) {
+ final db = ref.watch(driftProvider);
+ return SyncLinkedAlbumService(
+ db.localAlbumRepository,
+ db.remoteAlbumRepository,
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
- ),
-);
+ );
+});
class SyncLinkedAlbumService {
- final DriftLocalAlbumRepository _localAlbumRepository;
- final DriftRemoteAlbumRepository _remoteAlbumRepository;
+ final LocalAlbumRepository _localAlbumRepository;
+ final RemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index 71228ba2ba..2f6ad3f045 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -34,8 +34,8 @@ class SyncStreamService {
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
- final DriftLocalAssetRepository _localAssetRepository;
- final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
+ final LocalAssetRepository _localAssetRepository;
+ final TrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DevicePermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart
index b20ba306ff..84895b6056 100644
--- a/mobile/lib/domain/services/timeline.service.dart
+++ b/mobile/lib/domain/services/timeline.service.dart
@@ -38,7 +38,7 @@ enum TimelineOrigin {
}
class TimelineFactory {
- final DriftTimelineRepository _timelineRepository;
+ final TimelineRepository _timelineRepository;
final SettingsRepository _settingsRepository;
const TimelineFactory({required this._timelineRepository, required this._settingsRepository});
diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart
index e1d5fe451e..ecdf5fc9a0 100644
--- a/mobile/lib/domain/utils/migrate_cloud_ids.dart
+++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart
@@ -172,7 +172,7 @@ Future