From 7de73dc1769297f321072486340920e07a14c21e Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 7 May 2026 18:00:54 +0200 Subject: [PATCH 01/71] fix(server): hide isFavorite from partner asset sync stream (#28035) * fix(server): hide isFavorite from partner asset sync stream * use new column entry instead * sync sql * add migration * use sql.val * sync sql --- server/src/database.ts | 20 +++++++++++++++++++ server/src/queries/sync.repository.sql | 18 ++++++++--------- server/src/repositories/sync.repository.ts | 6 ++++-- .../1777897107000-PartnerAssetSyncReset.ts | 10 ++++++++++ 4 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 server/src/schema/migrations/1777897107000-PartnerAssetSyncReset.ts diff --git a/server/src/database.ts b/server/src/database.ts index c001388e79..3f1eb60a07 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -395,6 +395,26 @@ export const columns = { 'asset.height', 'asset.isEdited', ], + syncPartnerAsset: [ + 'asset.id', + 'asset.ownerId', + 'asset.originalFileName', + 'asset.thumbhash', + 'asset.checksum', + 'asset.fileCreatedAt', + 'asset.fileModifiedAt', + 'asset.localDateTime', + 'asset.type', + 'asset.deletedAt', + 'asset.visibility', + 'asset.duration', + 'asset.livePhotoVideoId', + 'asset.stackId', + 'asset.libraryId', + 'asset.width', + 'asset.height', + 'asset.isEdited', + ], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index ed8db709e6..8ac7fcbc5a 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -739,7 +739,6 @@ select "asset"."localDateTime", "asset"."type", "asset"."deletedAt", - "asset"."isFavorite", "asset"."visibility", "asset"."duration", "asset"."livePhotoVideoId", @@ -748,14 +747,15 @@ select "asset"."width", "asset"."height", "asset"."isEdited", + $1 as "isFavorite", "asset"."updateId" from "asset" as "asset" where - "asset"."updateId" < $1 - and "asset"."updateId" <= $2 - and "asset"."updateId" >= $3 - and "ownerId" = $4 + "asset"."updateId" < $2 + and "asset"."updateId" <= $3 + and "asset"."updateId" >= $4 + and "ownerId" = $5 order by "asset"."updateId" asc @@ -791,7 +791,6 @@ select "asset"."localDateTime", "asset"."type", "asset"."deletedAt", - "asset"."isFavorite", "asset"."visibility", "asset"."duration", "asset"."livePhotoVideoId", @@ -800,19 +799,20 @@ select "asset"."width", "asset"."height", "asset"."isEdited", + $1 as "isFavorite", "asset"."updateId" from "asset" as "asset" where - "asset"."updateId" < $1 - and "asset"."updateId" > $2 + "asset"."updateId" < $2 + and "asset"."updateId" > $3 and "ownerId" in ( select "sharedById" from "partner" where - "sharedWithId" = $3 + "sharedWithId" = $4 ) order by "asset"."updateId" asc diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 320b6e6094..5ca1d541d6 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -595,7 +595,8 @@ class PartnerAssetsSync extends BaseSync { @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true }) getBackfill(options: SyncBackfillOptions, partnerId: string) { return this.backfillQuery('asset', options) - .select(columns.syncAsset) + .select(columns.syncPartnerAsset) + .select(sql.val(false).as('isFavorite')) .select('asset.updateId') .where('ownerId', '=', partnerId) .stream(); @@ -614,7 +615,8 @@ class PartnerAssetsSync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getUpserts(options: SyncQueryOptions) { return this.upsertQuery('asset', options) - .select(columns.syncAsset) + .select(columns.syncPartnerAsset) + .select(sql.val(false).as('isFavorite')) .select('asset.updateId') .where('ownerId', 'in', (eb) => eb.selectFrom('partner').select(['sharedById']).where('sharedWithId', '=', options.userId), diff --git a/server/src/schema/migrations/1777897107000-PartnerAssetSyncReset.ts b/server/src/schema/migrations/1777897107000-PartnerAssetSyncReset.ts new file mode 100644 index 0000000000..cd75895755 --- /dev/null +++ b/server/src/schema/migrations/1777897107000-PartnerAssetSyncReset.ts @@ -0,0 +1,10 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // isFavorite was incorrectly included in partner asset sync on server <=2.X.X + await sql`DELETE FROM session_sync_checkpoint WHERE type in ('PartnerAssetV1', 'PartnerAssetBackfillV1')`.execute(db); +} + +export async function down(): Promise { + // Not implemented +} From 1fcc2b704b0508a5cedabbc22e8d5106bd420e6c Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 7 May 2026 18:13:07 +0200 Subject: [PATCH 02/71] feat(server)!: add isOwned filter to albums API (#28213) * feat(server)!: add owned filter to albums API BREAKING CHANGE: GET /albums with no parameters now returns all accessible albums (owned + shared-with-me) instead of only owned albums. * document tri-state matrix * web impl * collapse to single method and handover branching to sql * dedupe * verify that owned, shared, and notShared counts are mapped independently from their respective queries * refactor(server): add select:['id'] overload to albumRepository.getAll Avoid fetching full album rows (with albumUsers/sharedLinks subqueries) in map.service where only album IDs are needed. * focus relevant test filters * fmt * Revert "verify that owned, shared, and notShared counts are mapped independently from their respective queries" This reverts commit 47aab458192c766de4662aada5a6841b091d2a80. * sync sql * Revert "document tri-state matrix" This reverts commit a5b2355d0c6a084f2c021d517bf5ee80123d108c. * address review comments * inline shared condition and return as ternary * sync sql * use [...albums].sort Array.toSorted() is not supported in Chrome 109 * use isShared and isOwned nomenclature * fix e2e tests * add params to sql query --- e2e/src/specs/server/api/album.e2e-spec.ts | 84 ++++++- e2e/src/ui/mock-network/base-network.ts | 3 +- mobile/openapi/lib/api/albums_api.dart | 31 ++- open-api/immich-openapi-specs.json | 15 +- open-api/typescript-sdk/src/fetch-client.ts | 8 +- .../src/controllers/album.controller.spec.ts | 4 +- server/src/dtos/album.dto.ts | 9 +- server/src/queries/album.repository.sql | 209 ++++-------------- server/src/repositories/album.repository.ts | 110 +++------ server/src/services/album.service.spec.ts | 78 +++++-- server/src/services/album.service.ts | 27 ++- server/src/services/map.service.spec.ts | 10 +- server/src/services/map.service.ts | 10 +- web/src/lib/modals/AlbumPickerModal.svelte | 7 +- web/src/routes/(user)/albums/+page.ts | 4 +- web/src/routes/(user)/sharing/+page.ts | 2 +- 16 files changed, 284 insertions(+), 327 deletions(-) diff --git a/e2e/src/specs/server/api/album.e2e-spec.ts b/e2e/src/specs/server/api/album.e2e-spec.ts index e1e5178476..55b9c44b70 100644 --- a/e2e/src/specs/server/api/album.e2e-spec.ts +++ b/e2e/src/specs/server/api/album.e2e-spec.ts @@ -146,7 +146,7 @@ describe('/albums', () => { it('should not return shared albums with a deleted owner', async () => { const { status, body } = await request(app) - .get('/albums?shared=true') + .get('/albums?isShared=true') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -188,7 +188,7 @@ describe('/albums', () => { it('should return the album collection including owned and shared', async () => { const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(5); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -219,13 +219,20 @@ describe('/albums', () => { ]), shared: false, }), + expect.objectContaining({ + albumName: user2SharedUser, + albumUsers: expect.arrayContaining([ + { role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) }, + ]), + shared: true, + }), ]), ); }); - it('should return the album collection filtered by shared', async () => { + it('should return the album collection filtered by isShared', async () => { const { status, body } = await request(app) - .get('/albums?shared=true') + .get('/albums?isShared=true') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); @@ -263,9 +270,9 @@ describe('/albums', () => { ); }); - it('should return the album collection filtered by NOT shared', async () => { + it('should return the album collection filtered by NOT isShared', async () => { const { status, body } = await request(app) - .get('/albums?shared=false') + .get('/albums?isShared=false') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); @@ -282,6 +289,63 @@ describe('/albums', () => { ); }); + it('should return only owned albums when filtered by isOwned=true', async () => { + const { status, body } = await request(app) + .get('/albums?isOwned=true') + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(4); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ albumName: user1SharedEditorUser }), + expect.objectContaining({ albumName: user1SharedViewerUser }), + expect.objectContaining({ albumName: user1SharedLink }), + expect.objectContaining({ albumName: user1NotShared }), + ]), + ); + }); + + it('should return only shared-with-me albums when filtered by isOwned=false', async () => { + const { status, body } = await request(app) + .get('/albums?isOwned=false') + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + albumName: user2SharedUser, + albumUsers: expect.arrayContaining([ + { role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) }, + ]), + }), + ]), + ); + }); + + it('should return owned shared-out albums when filtered by isOwned=true&ishared=true', async () => { + const { status, body } = await request(app) + .get('/albums?isOwned=true&isShared=true') + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(3); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ albumName: user1SharedEditorUser }), + expect.objectContaining({ albumName: user1SharedViewerUser }), + expect.objectContaining({ albumName: user1SharedLink }), + ]), + ); + }); + + it('should return empty list when filtered by isOwned=false&isShared=false', async () => { + const { status, body } = await request(app) + .get('/albums?isOwned=false&isShared=false') + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(0); + }); + it('should return the album collection filtered by assetId', async () => { const { status, body } = await request(app) .get(`/albums?assetId=${user1Asset2.id}`) @@ -290,17 +354,17 @@ describe('/albums', () => { expect(body).toHaveLength(2); }); - it('should return the album collection filtered by assetId and ignores shared=true', async () => { + it('should return the album collection filtered by assetId and ignores isShared=true', async () => { const { status, body } = await request(app) - .get(`/albums?shared=true&assetId=${user1Asset1.id}`) + .get(`/albums?isShared=true&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(5); }); - it('should return the album collection filtered by assetId and ignores shared=false', async () => { + it('should return the album collection filtered by assetId and ignores isShared=false', async () => { const { status, body } = await request(app) - .get(`/albums?shared=false&assetId=${user1Asset1.id}`) + .get(`/albums?isShared=false&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(5); diff --git a/e2e/src/ui/mock-network/base-network.ts b/e2e/src/ui/mock-network/base-network.ts index 3dc3580396..6680b83dd1 100644 --- a/e2e/src/ui/mock-network/base-network.ts +++ b/e2e/src/ui/mock-network/base-network.ts @@ -240,7 +240,8 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI }); }); await context.route('**/api/albums*', async (route, request) => { - if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) { + const url = request.url(); + if (url.endsWith('albums?isShared=true') || url.endsWith('albums?isOwned=true') || url.endsWith('albums')) { return route.fulfill({ status: 200, contentType: 'application/json', diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index d08d1cba9d..e0fc383c1d 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -506,11 +506,14 @@ class AlbumsApi { /// Parameters: /// /// * [String] assetId: - /// Filter albums containing this asset ID (ignores shared parameter) + /// Filter albums containing this asset ID (ignores other parameters) /// - /// * [bool] shared: - /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums - Future getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async { + /// * [bool] isOwned: + /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter + /// + /// * [bool] isShared: + /// Filter by shared status: true = only shared, false = not shared, undefined = no filter + Future getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async { // ignore: prefer_const_declarations final apiPath = r'/albums'; @@ -524,8 +527,11 @@ class AlbumsApi { if (assetId != null) { queryParams.addAll(_queryParams('', 'assetId', assetId)); } - if (shared != null) { - queryParams.addAll(_queryParams('', 'shared', shared)); + if (isOwned != null) { + queryParams.addAll(_queryParams('', 'isOwned', isOwned)); + } + if (isShared != null) { + queryParams.addAll(_queryParams('', 'isShared', isShared)); } const contentTypes = []; @@ -549,12 +555,15 @@ class AlbumsApi { /// Parameters: /// /// * [String] assetId: - /// Filter albums containing this asset ID (ignores shared parameter) + /// Filter albums containing this asset ID (ignores other parameters) /// - /// * [bool] shared: - /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums - Future?> getAllAlbums({ String? assetId, bool? shared, }) async { - final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, ); + /// * [bool] isOwned: + /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter + /// + /// * [bool] isShared: + /// Filter by shared status: true = only shared, false = not shared, undefined = no filter + Future?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async { + final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0cb47f4021..960d90d91c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1641,7 +1641,7 @@ "name": "assetId", "required": false, "in": "query", - "description": "Filter albums containing this asset ID (ignores shared parameter)", + "description": "Filter albums containing this asset ID (ignores other parameters)", "schema": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", @@ -1649,10 +1649,19 @@ } }, { - "name": "shared", + "name": "isOwned", "required": false, "in": "query", - "description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums", + "description": "Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter", + "schema": { + "type": "boolean" + } + }, + { + "name": "isShared", + "required": false, + "in": "query", + "description": "Filter by shared status: true = only shared, false = not shared, undefined = no filter", "schema": { "type": "boolean" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d2bb6304b1..6b3e9fe26f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3657,16 +3657,18 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility } /** * List all albums */ -export function getAllAlbums({ assetId, shared }: { +export function getAllAlbums({ assetId, isOwned, isShared }: { assetId?: string; - shared?: boolean; + isOwned?: boolean; + isShared?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AlbumResponseDto[]; }>(`/albums${QS.query(QS.explode({ assetId, - shared + isOwned, + isShared }))}`, { ...opts })); diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index 0c7a4eb09f..2ab7b08ceb 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -25,11 +25,11 @@ describe(AlbumController.name, () => { }); it('should reject an invalid shared param', async () => { - const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid'); + const { status, body } = await request(ctx.getHttpServer()).get('/albums?isShared=invalid'); expect(status).toEqual(400); expect(body).toEqual( factory.responses.validationError([ - { path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' }, + { path: ['isShared'], message: 'Invalid option: expected one of "true"|"false"' }, ]), ); }); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 33870cd6fc..095e399b96 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -65,10 +65,13 @@ const UpdateAlbumSchema = z const GetAlbumsSchema = z .object({ - shared: stringToBool + isOwned: stringToBool .optional() - .describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'), - assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'), + .describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'), + isShared: stringToBool + .optional() + .describe('Filter by shared status: true = only shared, false = not shared, undefined = no filter'), + assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores other parameters)'), }) .meta({ id: 'GetAlbumsDto' }); diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index f5bf79ac9e..35c82fdb73 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -185,7 +185,7 @@ where group by "album_asset"."albumId" --- AlbumRepository.getOwned +-- AlbumRepository.getAll select "album".*, ( @@ -242,172 +242,55 @@ from "album" inner join "album_user" on "album_user"."albumId" = "album"."id" and "album_user"."userId" = $2 - and "album_user"."role" = 'owner' where "album"."deletedAt" is null -order by - "album"."createdAt" desc - --- AlbumRepository.getShared -select - "album".*, - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "album_user"."role", - ( - select - to_json(obj) - from - ( - select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" - from - ( - select - 1 - ) as "dummy" - ) as obj - ) as "user" - from - "album_user" - inner join "user" on "user"."id" = "album_user"."userId" - where - "album_user"."albumId" = "album"."id" - order by - "album_user"."role", - "album_user"."userId" = $1 desc, - "user"."name" asc - ) as agg - ) as "albumUsers", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "shared_link".* - from - "shared_link" - where - "shared_link"."albumId" = "album"."id" - ) as agg - ) as "sharedLinks" -from - "album" - inner join ( - select - "album_user"."albumId" as "id" - from - "album_user" - where - "album_user"."userId" = $2 - and "album_user"."albumId" in ( - select - "album_user"."albumId" - from - "album_user" - where - "album_user"."role" != 'owner' - ) - union - select - "shared_link"."albumId" as "id" - from - "shared_link" - where - "shared_link"."userId" = $3 - and "shared_link"."albumId" is not null - ) as "matching" on "matching"."id" = "album"."id" - inner join "album_user" on "album_user"."albumId" = "album"."id" and "album_user"."role" = 'owner' -where - "album"."deletedAt" is null -order by - "album"."createdAt" desc - --- AlbumRepository.getNotShared -select - "album".*, - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "shared_link".* - from - "shared_link" - where - "shared_link"."albumId" = "album"."id" - ) as agg - ) as "sharedLinks", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "album_user"."role", - ( - select - to_json(obj) - from - ( - select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" - from - ( - select - 1 - ) as "dummy" - ) as obj - ) as "user" - from - "album_user" - inner join "user" on "user"."id" = "album_user"."userId" - where - "album_user"."albumId" = "album"."id" - order by - "album_user"."role", - "album_user"."userId" = $1 desc, - "user"."name" asc - ) as agg - ) as "albumUsers" -from - "album" - inner join "album_user" on "album_user"."albumId" = "album"."id" - and "album_user"."userId" = $2 - and "album_user"."role" = 'owner' -where - "album"."deletedAt" is null - and not exists ( - select - from - "album_user" as "au" - where - "au"."albumId" = "album"."id" - and "au"."role" != 'owner' + and ( + exists ( + select + from + "album_user" as "au" + where + "au"."albumId" = "album"."id" + and "au"."role" != 'owner' + ) + or exists ( + select + from + "shared_link" + where + "shared_link"."albumId" = "album"."id" + ) ) - and not exists ( - select - from - "shared_link" - where - "shared_link"."albumId" = "album"."id" +order by + "album"."createdAt" desc + +-- AlbumRepository.getAllIds +select + "album"."id" +from + "album" + inner join "album_user" on "album_user"."albumId" = "album"."id" + and "album_user"."userId" = $1 +where + "album"."deletedAt" is null + and "album_user"."role" = 'owner' + and ( + exists ( + select + from + "album_user" as "au" + where + "au"."albumId" = "album"."id" + and "au"."role" != 'owner' + ) + or exists ( + select + from + "shared_link" + where + "shared_link"."albumId" = "album"."id" + ) ) order by "album"."createdAt" desc diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index a910673c62..a712151355 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -13,7 +13,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; -import { AlbumUserCreateDto } from 'src/dtos/album.dto'; +import { AlbumUserCreateDto, MapAlbumDto } from 'src/dtos/album.dto'; import { AlbumUserRole } from 'src/enum'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; @@ -183,98 +183,48 @@ export class AlbumRepository { ); } - @GenerateSql({ params: [DummyValue.UUID] }) - getOwned(ownerId: string) { + private buildAlbumBaseQuery(ownerId: string, { isOwned, isShared }: { isOwned?: boolean; isShared?: boolean }) { return this.db .selectFrom('album') - .selectAll('album') .innerJoin('album_user', (join) => - join - .onRef('album_user.albumId', '=', 'album.id') - .on('album_user.userId', '=', ownerId) - .on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)), + join.onRef('album_user.albumId', '=', 'album.id').on('album_user.userId', '=', ownerId), ) .where('album.deletedAt', 'is', null) + .$if(isOwned === true, (qb) => qb.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner))) + .$if(isOwned === false, (qb) => qb.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner))) + .$if(isShared !== undefined, (qb) => + qb.where((eb) => { + const isSharedAlbum = eb.or([ + eb.exists( + eb + .selectFrom('album_user as au') + .whereRef('au.albumId', '=', 'album.id') + .where('au.role', '!=', sql.lit(AlbumUserRole.Owner)), + ), + eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id')), + ]); + return isShared ? isSharedAlbum : eb.not(isSharedAlbum); + }), + ); + } + + @GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] }) + getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise { + return this.buildAlbumBaseQuery(ownerId, options) + .selectAll('album') .select(withAlbumUsers(ownerId)) .select(withSharedLink) .orderBy('album.createdAt', 'desc') .execute(); } - /** - * Get albums shared with and shared by owner. - */ - @GenerateSql({ params: [DummyValue.UUID] }) - getShared(ownerId: string) { - return this.db - .selectFrom('album') - .selectAll('album') - .innerJoin( - (eb) => - eb - .selectFrom('album_user') - .select('album_user.albumId as id') - .where('album_user.userId', '=', ownerId) - .where( - 'album_user.albumId', - 'in', - eb - .selectFrom('album_user') - .select('album_user.albumId') - .where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)), - ) - .union( - eb - .selectFrom('shared_link') - .where('shared_link.userId', '=', ownerId) - .where('shared_link.albumId', 'is not', null) - .select('shared_link.albumId as id') - .$narrowType<{ id: NotNull }>(), - ) - .as('matching'), - (join) => join.onRef('matching.id', '=', 'album.id'), - ) - .innerJoin('album_user', (join) => - join.onRef('album_user.albumId', '=', 'album.id').on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)), - ) - .where('album.deletedAt', 'is', null) - .select(withAlbumUsers(ownerId)) - .select(withSharedLink) - .orderBy('album.createdAt', 'desc') - .execute(); - } - - /** - * Get albums of owner that are _not_ shared - */ - @GenerateSql({ params: [DummyValue.UUID] }) - getNotShared(ownerId: string) { - return this.db - .selectFrom('album') - .selectAll('album') - .innerJoin('album_user', (join) => - join - .onRef('album_user.albumId', '=', 'album.id') - .on('album_user.userId', '=', ownerId) - .on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)), - ) - .where('album.deletedAt', 'is', null) - .where(({ not, exists, selectFrom }) => - not( - exists( - selectFrom('album_user as au') - .whereRef('au.albumId', '=', 'album.id') - .where('au.role', '!=', sql.lit(AlbumUserRole.Owner)), - ), - ), - ) - .where(({ not, exists, selectFrom }) => - not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))), - ) - .select(withSharedLink) - .select(withAlbumUsers(ownerId)) + @GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] }) + async getAllIds(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise { + const rows = await this.buildAlbumBaseQuery(ownerId, options) + .select('album.id') .orderBy('album.createdAt', 'desc') .execute(); + return rows.map((r) => r.id); } async restoreAll(userId: string): Promise { diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 24e28a9701..288c3c1d3c 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -26,18 +26,16 @@ describe(AlbumService.name, () => { describe('getStatistics', () => { it('should get the album count', async () => { - mocks.album.getOwned.mockResolvedValue([]); - mocks.album.getShared.mockResolvedValue([]); - mocks.album.getNotShared.mockResolvedValue([]); + mocks.album.getAll.mockResolvedValue([]); await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ owned: 0, shared: 0, notShared: 0, }); - expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); - expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id); - expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isOwned: true }); + expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isShared: true }); + expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isOwned: true, isShared: false }); }); }); @@ -46,7 +44,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.from().albumUser().build(); const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build(); - mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]); + mocks.album.getAll.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -68,6 +66,7 @@ describe(AlbumService.name, () => { expect(result).toHaveLength(2); expect(result[0].id).toEqual(album.id); expect(result[1].id).toEqual(sharedWithUserAlbum.id); + expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: undefined, isShared: undefined }); }); it('gets list of albums that have a specific asset', async () => { @@ -98,7 +97,7 @@ describe(AlbumService.name, () => { it('gets list of albums that are shared', async () => { const album = AlbumFactory.from().albumUser().build(); const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; - mocks.album.getShared.mockResolvedValue([getForAlbum(album)]); + mocks.album.getAll.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -109,16 +108,16 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(AuthFactory.create(owner), { shared: true }); + const result = await sut.getAll(AuthFactory.create(owner), { isShared: true }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(album.id); - expect(mocks.album.getShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isShared: true })); }); it('gets list of albums that are NOT shared', async () => { const album = AlbumFactory.create(); const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; - mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]); + mocks.album.getAll.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -129,17 +128,66 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(AuthFactory.create(owner), { shared: false }); + const result = await sut.getAll(AuthFactory.create(owner), { isShared: false }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(album.id); - expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isShared: false })); + }); + + it('gets only owned albums when isOwned=true', async () => { + const album = AlbumFactory.create(); + const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; + mocks.album.getAll.mockResolvedValue([getForAlbum(album)]); + mocks.album.getMetadataForIds.mockResolvedValue([ + { albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null }, + ]); + + const result = await sut.getAll(AuthFactory.create(owner), { isOwned: true }); + expect(result).toHaveLength(1); + expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isOwned: true })); + }); + + it('gets only shared-with-me albums when isOwned=false', async () => { + const album = AlbumFactory.create(); + const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; + mocks.album.getAll.mockResolvedValue([getForAlbum(album)]); + mocks.album.getMetadataForIds.mockResolvedValue([ + { albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null }, + ]); + + const result = await sut.getAll(AuthFactory.create(owner), { isOwned: false }); + expect(result).toHaveLength(1); + expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isOwned: false })); + }); + + it('gets owned shared-out albums when isOwned=true and isShared=true', async () => { + const album = AlbumFactory.create(); + const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; + mocks.album.getAll.mockResolvedValue([getForAlbum(album)]); + mocks.album.getMetadataForIds.mockResolvedValue([ + { albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null }, + ]); + + const result = await sut.getAll(AuthFactory.create(owner), { isOwned: true, isShared: true }); + expect(result).toHaveLength(1); + expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: true, isShared: true }); + }); + + it('returns empty list when isOwned=false and isShared=false', async () => { + const album = AlbumFactory.create(); + const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; + mocks.album.getAll.mockResolvedValue([]); + + const result = await sut.getAll(AuthFactory.create(owner), { isOwned: false, isShared: false }); + expect(result).toHaveLength(0); + expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: false, isShared: false }); }); }); it('counts assets correctly', async () => { const album = AlbumFactory.create(); const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; - mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]); + mocks.album.getAll.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -153,7 +201,7 @@ describe(AlbumService.name, () => { const result = await sut.getAll(AuthFactory.create(owner), {}); expect(result).toHaveLength(1); expect(result[0].assetCount).toEqual(1); - expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); + expect(mocks.album.getAll).toHaveBeenCalledTimes(1); }); describe('create', () => { diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 7bfc0bdcc2..723288e5b5 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -8,7 +8,6 @@ import { CreateAlbumDto, GetAlbumsDto, mapAlbum, - MapAlbumDto, UpdateAlbumDto, UpdateAlbumUserDto, } from 'src/dtos/album.dto'; @@ -26,9 +25,9 @@ import { getPreferences } from 'src/utils/preferences'; export class AlbumService extends BaseService { async getStatistics(auth: AuthDto): Promise { const [owned, shared, notShared] = await Promise.all([ - this.albumRepository.getOwned(auth.user.id), - this.albumRepository.getShared(auth.user.id), - this.albumRepository.getNotShared(auth.user.id), + this.albumRepository.getAll(auth.user.id, { isOwned: true }), + this.albumRepository.getAll(auth.user.id, { isShared: true }), + this.albumRepository.getAll(auth.user.id, { isOwned: true, isShared: false }), ]); return { @@ -38,18 +37,18 @@ export class AlbumService extends BaseService { }; } - async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise { + async getAll( + { user: { id: ownerId } }: AuthDto, + { assetId, isOwned, isShared }: GetAlbumsDto, + ): Promise { await this.albumRepository.updateThumbnails(); - let albums: MapAlbumDto[]; - if (assetId) { - albums = await this.albumRepository.getByAssetId(ownerId, assetId); - } else if (shared === true) { - albums = await this.albumRepository.getShared(ownerId); - } else if (shared === false) { - albums = await this.albumRepository.getNotShared(ownerId); - } else { - albums = await this.albumRepository.getOwned(ownerId); + const albums = assetId + ? await this.albumRepository.getByAssetId(ownerId, assetId) + : await this.albumRepository.getAll(ownerId, { isOwned, isShared }); + + if (albums.length === 0) { + return []; } // Get asset count for each album. Then map the result to an object: diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index fdf7aee68b..6ef94cd40c 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -4,7 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { PartnerFactory } from 'test/factories/partner.factory'; import { userStub } from 'test/fixtures/user.stub'; -import { getForAlbum, getForPartner } from 'test/mappers'; +import { getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { @@ -82,15 +82,15 @@ describe(MapService.name, () => { }; mocks.partner.getAll.mockResolvedValue([]); mocks.map.getMapMarkers.mockResolvedValue([marker]); - mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]); - mocks.album.getShared.mockResolvedValue([ - getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()), - ]); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build(); + mocks.album.getAllIds.mockResolvedValue([album1.id, album2.id]); const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true }); expect(markers).toHaveLength(1); expect(markers[0]).toEqual(marker); + expect(mocks.album.getAllIds).toHaveBeenCalledWith(auth.user.id); }); }); diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index 94eca77a60..3a825697b4 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -13,15 +13,7 @@ export class MapService extends BaseService { userIds.push(...partnerIds); } - // TODO convert to SQL join - const albumIds: string[] = []; - if (options.withSharedAlbums) { - const [ownedAlbums, sharedAlbums] = await Promise.all([ - this.albumRepository.getOwned(auth.user.id), - this.albumRepository.getShared(auth.user.id), - ]); - albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id)); - } + const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : []; return this.mapRepository.getMapMarkers(userIds, albumIds, options); } diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 34bf3c0490..5d0d5692da 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -28,11 +28,8 @@ let { onClose }: Props = $props(); onMount(async () => { - // TODO the server should *really* just return all albums (paginated ideally) - const ownedAlbums = await getAllAlbums({ shared: false }); - ownedAlbums.push.apply(ownedAlbums, await getAllAlbums({ shared: true })); - albums = ownedAlbums; - recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3); + albums = await getAllAlbums({}); + recentAlbums = [...albums].sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3); loading = false; }); diff --git a/web/src/routes/(user)/albums/+page.ts b/web/src/routes/(user)/albums/+page.ts index 8a9a5ef66d..43fe1f712f 100644 --- a/web/src/routes/(user)/albums/+page.ts +++ b/web/src/routes/(user)/albums/+page.ts @@ -5,8 +5,8 @@ import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url); - const sharedAlbums = await getAllAlbums({ shared: true }); - const albums = await getAllAlbums({}); + const sharedAlbums = await getAllAlbums({ isShared: true }); + const albums = await getAllAlbums({ isOwned: true }); const $t = await getFormatter(); return { diff --git a/web/src/routes/(user)/sharing/+page.ts b/web/src/routes/(user)/sharing/+page.ts index 19008038ab..aeb6d431e3 100644 --- a/web/src/routes/(user)/sharing/+page.ts +++ b/web/src/routes/(user)/sharing/+page.ts @@ -5,7 +5,7 @@ import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url); - const sharedAlbums = await getAllAlbums({ shared: true }); + const sharedAlbums = await getAllAlbums({ isShared: true }); const partners = await getPartners({ direction: PartnerDirection.SharedWith }); const $t = await getFormatter(); From 21af18404537ea2497e8a9bcc579495e97771187 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 7 May 2026 23:27:06 +0700 Subject: [PATCH 03/71] refactor: move cleanup config to metadata table (#28225) * refactor: app metadata * refactor to per row store * cleanup * more test * review changes * more refactor * refactor * migrate primary color * migrate dynamic theme * migrate colorfulInterface * cleanup providers * migrate cleanup --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/config/app_config.dart | 14 ++++-- .../domain/models/config/cleanup_config.dart | 48 ++++++++++++++++++ mobile/lib/domain/models/metadata_key.dart | 50 +++++++++++++++++-- mobile/lib/domain/models/store.model.dart | 11 ++-- .../repositories/metadata.repository.dart | 13 +++-- .../lib/pages/common/splash_screen.page.dart | 2 +- mobile/lib/providers/cleanup.provider.dart | 34 ++++++------- mobile/lib/services/app_settings.service.dart | 7 +-- mobile/lib/utils/migration.dart | 49 ++++++++++++++---- .../primary_color_setting.dart | 7 ++- .../preference_settings/theme_setting.dart | 7 ++- 11 files changed, 181 insertions(+), 61 deletions(-) create mode 100644 mobile/lib/domain/models/config/cleanup_config.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index 3f0ca91db7..7179137af4 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,18 +1,22 @@ +import 'package:immich_mobile/domain/models/config/cleanup_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; class AppConfig { final ThemeConfig theme; + final CleanupConfig cleanup; - const AppConfig({this.theme = const ThemeConfig()}); + const AppConfig({this.theme = const .new(), this.cleanup = const .new()}); - AppConfig copyWith({ThemeConfig? theme}) => .new(theme: theme ?? this.theme); + AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup}) => + .new(theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup); @override - bool operator ==(Object other) => identical(this, other) || (other is AppConfig && other.theme == theme); + bool operator ==(Object other) => + identical(this, other) || (other is AppConfig && other.theme == theme && other.cleanup == cleanup); @override - int get hashCode => theme.hashCode; + int get hashCode => Object.hash(theme, cleanup); @override - String toString() => 'AppConfig(theme: $theme)'; + String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup)'; } diff --git a/mobile/lib/domain/models/config/cleanup_config.dart b/mobile/lib/domain/models/config/cleanup_config.dart new file mode 100644 index 0000000000..4b34814492 --- /dev/null +++ b/mobile/lib/domain/models/config/cleanup_config.dart @@ -0,0 +1,48 @@ +import 'package:immich_mobile/constants/enums.dart'; + +class CleanupConfig { + final bool keepFavorites; + final AssetKeepType keepMediaType; + final List keepAlbumIds; + final int cutoffDaysAgo; + final bool defaultsInitialized; + + const CleanupConfig({ + this.keepFavorites = true, + this.keepMediaType = AssetKeepType.none, + this.keepAlbumIds = const [], + this.cutoffDaysAgo = -1, + this.defaultsInitialized = false, + }); + + CleanupConfig copyWith({ + bool? keepFavorites, + AssetKeepType? keepMediaType, + List? keepAlbumIds, + int? cutoffDaysAgo, + bool? defaultsInitialized, + }) => .new( + keepFavorites: keepFavorites ?? this.keepFavorites, + keepMediaType: keepMediaType ?? this.keepMediaType, + keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds, + cutoffDaysAgo: cutoffDaysAgo ?? this.cutoffDaysAgo, + defaultsInitialized: defaultsInitialized ?? this.defaultsInitialized, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CleanupConfig && + other.keepFavorites == keepFavorites && + other.keepMediaType == keepMediaType && + other.keepAlbumIds == keepAlbumIds && + other.cutoffDaysAgo == cutoffDaysAgo && + other.defaultsInitialized == defaultsInitialized); + + @override + int get hashCode => Object.hash(keepFavorites, keepMediaType, keepAlbumIds, cutoffDaysAgo, defaultsInitialized); + + @override + String toString() => + 'CleanupConfig(keepFavorites: $keepFavorites, keepMediaType: $keepMediaType, keepAlbumIds: $keepAlbumIds, cutoffDaysAgo: $cutoffDaysAgo, defaultsInitialized: $defaultsInitialized)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index a5d158f9c7..667822663a 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; @@ -15,13 +18,25 @@ enum MetadataDomain { enum MetadataKey { // Theme - primaryColor(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)), + themePrimaryColor(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)), themeMode(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)), - dynamicTheme(.appConfig, 'theme.dynamicTheme', false), - colorfulInterface(.appConfig, 'theme.colorfulInterface', true), + themeDynamic(.appConfig, 'theme.dynamic', false), + themeColorfulInterface(.appConfig, 'theme.colorfulInterface', true), // Log - logLevel(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)); + logLevel(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)), + + // Cleanup + cleanupKeepFavorites(.appConfig, 'cleanup.keepFavorites', true), + cleanupKeepMediaType( + .appConfig, + 'cleanup.keepMediaType', + AssetKeepType.none, + _EnumCodec(AssetKeepType.values), + ), + cleanupKeepAlbumIds>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)), + cleanupCutoffDaysAgo(.appConfig, 'cleanup.cutoffDaysAgo', -1), + cleanupDefaultsInitialized(.appConfig, 'cleanup.defaultsInitialized', false); final MetadataDomain domain; final String name; @@ -88,6 +103,33 @@ final class _DateTimeCodec extends _MetadataCodec { DateTime? decode(String raw) => DateTime.tryParse(raw); } +final class _ListCodec extends _MetadataCodec> { + final _MetadataCodec _elementCodec; + + const _ListCodec(this._elementCodec); + + @override + String encode(List value) => jsonEncode(value.map(_elementCodec.encode).toList()); + + @override + List? decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return null; + final result = []; + for (final item in decoded) { + if (item is! String) return null; + final element = _elementCodec.decode(item); + if (element == null) return null; + result.add(element); + } + return result; + } on FormatException { + return null; + } + } +} + final class _PrimitiveCodec extends _MetadataCodec { final T? Function(String) _parse; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index fb497f1e9e..8a7cd3a71e 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -81,12 +81,6 @@ enum StoreKey { shouldResetSync._(1007), // Free up space - cleanupKeepFavorites._(1008), - cleanupKeepMediaType._(1009), - cleanupKeepAlbumIds._(1010), - cleanupCutoffDaysAgo._(1011), - cleanupDefaultsInitialized._(1012), - syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store @@ -94,6 +88,11 @@ enum StoreKey { legacyDynamicTheme._(129), legacyColorfulInterface._(130), legacyThemeMode._(102), + legacyCleanupKeepFavorites._(1008), + legacyCleanupKeepMediaType._(1009), + legacyCleanupKeepAlbumIds._(1010), + legacyCleanupCutoffDaysAgo._(1011), + legacyCleanupDefaultsInitialized._(1012), legacyLogLevel._(115); const StoreKey._(this.id); diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index 96b2f2b3df..6dafa5f02a 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -103,9 +103,16 @@ extension on MetadataDomain { repo._appConfig = .new( theme: .new( mode: repo._read(.themeMode), - primaryColor: repo._read(.primaryColor), - dynamicTheme: repo._read(.dynamicTheme), - colorfulInterface: repo._read(.colorfulInterface), + primaryColor: repo._read(.themePrimaryColor), + dynamicTheme: repo._read(.themeDynamic), + colorfulInterface: repo._read(.themeColorfulInterface), + ), + cleanup: .new( + keepFavorites: repo._read(.cleanupKeepFavorites), + keepMediaType: repo._read(.cleanupKeepMediaType), + keepAlbumIds: repo._read(.cleanupKeepAlbumIds), + cutoffDaysAgo: repo._read(.cleanupCutoffDaysAgo), + defaultsInitialized: repo._read(.cleanupDefaultsInitialized), ), ); case .systemConfig: diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 512940aeb3..594f47999c 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -35,7 +35,7 @@ class BootstrapErrorWidget extends StatelessWidget { @override Widget build(BuildContext _) { - final immichTheme = MetadataKey.primaryColor.defaultValue.themeOfPreset; + final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset; return EasyLocalization( supportedLocales: locales.values.toList(), diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart index 4d0bdba301..c18185710e 100644 --- a/mobile/lib/providers/cleanup.provider.dart +++ b/mobile/lib/providers/cleanup.provider.dart @@ -1,9 +1,9 @@ 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/providers/app_settings.provider.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; class CleanupState { @@ -54,27 +54,25 @@ final cleanupProvider = StateNotifierProvider((re return CleanupNotifier( ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id, - ref.watch(appSettingsServiceProvider), + ref.watch(metadataProvider), ); }); class CleanupNotifier extends StateNotifier { final CleanupService _cleanupService; final String? _userId; - final AppSettingsService _appSettingsService; + final MetadataRepository _metadataRepository; - CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) { + CleanupNotifier(this._cleanupService, this._userId, this._metadataRepository) : super(const CleanupState()) { _loadPersistedSettings(); } void _loadPersistedSettings() { - final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites); - final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType); - final keepAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepAlbumIds); - final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo); - - final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)]; - final keepAlbumIds = keepAlbumIdsString.isEmpty ? {} : keepAlbumIdsString.split(',').toSet(); + final cleanup = _metadataRepository.appConfig.cleanup; + final keepFavorites = cleanup.keepFavorites; + final keepMediaType = cleanup.keepMediaType; + final keepAlbumIds = cleanup.keepAlbumIds.toSet(); + final cutoffDaysAgo = cleanup.cutoffDaysAgo; final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null; state = state.copyWith( @@ -89,18 +87,18 @@ class CleanupNotifier extends StateNotifier { state = state.copyWith(selectedDate: date, assetsToDelete: []); if (date != null) { final daysAgo = DateTime.now().difference(date).inDays; - _appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo); + _metadataRepository.write(.cleanupCutoffDaysAgo, daysAgo); } } void setKeepMediaType(AssetKeepType keepMediaType) { state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []); - _appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index); + _metadataRepository.write(.cleanupKeepMediaType, keepMediaType); } void setKeepFavorites(bool keepFavorites) { state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); - _appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites); + _metadataRepository.write(.cleanupKeepFavorites, keepFavorites); } void toggleKeepAlbum(String albumId) { @@ -120,7 +118,7 @@ class CleanupNotifier extends StateNotifier { } void _persistExcludedAlbumIds(Set albumIds) { - _appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(',')); + _metadataRepository.write(.cleanupKeepAlbumIds, albumIds.toList()); } void cleanupStaleAlbumIds(Set existingAlbumIds) { @@ -133,7 +131,7 @@ class CleanupNotifier extends StateNotifier { } void applyDefaultAlbumSelections(List<(String id, String name)> albums) { - final isInitialized = _appSettingsService.getSetting(AppSettingsEnum.cleanupDefaultsInitialized); + final isInitialized = _metadataRepository.appConfig.cleanup.defaultsInitialized; if (isInitialized) return; final toKeep = _cleanupService.getDefaultKeepAlbumIds(albums); @@ -144,7 +142,7 @@ class CleanupNotifier extends StateNotifier { _persistExcludedAlbumIds(keepAlbumIds); } - _appSettingsService.setSetting(AppSettingsEnum.cleanupDefaultsInitialized, true); + _metadataRepository.write(.cleanupDefaultsInitialized, true); } Future scanAssets() async { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 5c565aff03..72fd389871 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -49,12 +49,7 @@ enum AppSettingsEnum { readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), - cleanupKeepFavorites(StoreKey.cleanupKeepFavorites, null, true), - cleanupKeepMediaType(StoreKey.cleanupKeepMediaType, null, 0), - cleanupKeepAlbumIds(StoreKey.cleanupKeepAlbumIds, null, ""), - cleanupCutoffDaysAgo(StoreKey.cleanupCutoffDaysAgo, null, -1), - cleanupDefaultsInitialized(StoreKey.cleanupDefaultsInitialized, null, false); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 7b3cfa00dc..b3da549f98 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -43,9 +44,29 @@ Future _migrateTo26(Drift drift) async { final migrator = _StoreMigrator(drift); await migrator.migrateEnumName(StoreKey.legacyThemeMode, MetadataKey.themeMode, ThemeMode.values); await migrator.migrateEnumIndex(StoreKey.legacyLogLevel, MetadataKey.logLevel, LogLevel.values); - await migrator.migrateEnumName(StoreKey.legacyPrimaryColor, MetadataKey.primaryColor, ImmichColorPreset.values); - await migrator.migrateBool(StoreKey.legacyDynamicTheme, MetadataKey.dynamicTheme); - await migrator.migrateBool(StoreKey.legacyColorfulInterface, MetadataKey.colorfulInterface); + await migrator.migrateEnumName(StoreKey.legacyPrimaryColor, MetadataKey.themePrimaryColor, ImmichColorPreset.values); + await migrator.migrateBool(StoreKey.legacyDynamicTheme, MetadataKey.themeDynamic); + await migrator.migrateBool(StoreKey.legacyColorfulInterface, MetadataKey.themeColorfulInterface); + final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id); + if (cleanupKeepAlbumIds != null) { + final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList(); + await drift.metadataEntity.insertOnConflictUpdate( + MetadataEntityCompanion.insert( + key: MetadataKey.cleanupKeepAlbumIds.key, + value: MetadataKey.cleanupKeepAlbumIds.encode(ids), + updatedAt: Value(DateTime.now()), + ), + ); + await migrator.deleteLegacyStoreRows([StoreKey.legacyCleanupKeepAlbumIds.id]); + } + await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, MetadataKey.cleanupKeepFavorites); + await migrator.migrateEnumIndex( + StoreKey.legacyCleanupKeepMediaType, + MetadataKey.cleanupKeepMediaType, + AssetKeepType.values, + ); + await migrator.migrateInt(StoreKey.legacyCleanupCutoffDaysAgo, MetadataKey.cleanupCutoffDaysAgo); + await migrator.migrateBool(StoreKey.legacyCleanupDefaultsInitialized, MetadataKey.cleanupDefaultsInitialized); await migrator.complete(); } @@ -57,7 +78,7 @@ class _StoreMigrator { _StoreMigrator(this._db); Future migrateEnumIndex(StoreKey legacyKey, MetadataKey newKey, List values) async { - final index = await _readLegacyStoreInt(legacyKey.id); + final index = await readLegacyStoreInt(legacyKey.id); if (index == null) return; final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue; @@ -70,7 +91,7 @@ class _StoreMigrator { MetadataKey newKey, List values, ) async { - final name = await _readLegacyStoreString(legacyKey.id); + final name = await readLegacyStoreString(legacyKey.id); if (name == null) return; final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue); @@ -79,7 +100,7 @@ class _StoreMigrator { } Future migrateBool(StoreKey legacyKey, MetadataKey newKey) async { - final intValue = await _readLegacyStoreInt(legacyKey.id); + final intValue = await readLegacyStoreInt(legacyKey.id); if (intValue == null) return; final boolValue = intValue != 0; @@ -87,6 +108,14 @@ class _StoreMigrator { _migratedStoreIds.add(legacyKey.id); } + Future migrateInt(StoreKey legacyKey, MetadataKey newKey) async { + final intValue = await readLegacyStoreInt(legacyKey.id); + if (intValue == null) return; + + _cache[newKey] = intValue; + _migratedStoreIds.add(legacyKey.id); + } + Future complete() async { await _db.batch((batch) { for (final entry in _cache.entries) { @@ -97,20 +126,20 @@ class _StoreMigrator { ); } }); - await _deleteLegacyStoreRows(_migratedStoreIds); + await deleteLegacyStoreRows(_migratedStoreIds); } - Future _readLegacyStoreString(int id) async { + Future readLegacyStoreString(int id) async { final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull(); return row?.stringValue; } - Future _readLegacyStoreInt(int id) async { + Future readLegacyStoreInt(int id) async { final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull(); return row?.intValue; } - Future _deleteLegacyStoreRows(List ids) async { + Future deleteLegacyStoreRows(List ids) async { if (ids.isEmpty) return; await (_db.storeEntity.delete()..where((t) => t.id.isIn(ids))).go(); } diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart index 617a9a0758..330555ed54 100644 --- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart @@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/colors.dart'; -import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; @@ -27,16 +26,16 @@ class PrimaryColorSetting extends HookConsumerWidget { } onUseSystemColorChange(bool newValue) { - ref.read(metadataProvider).write(MetadataKey.dynamicTheme, newValue); + ref.read(metadataProvider).write(.themeDynamic, newValue); popBottomSheet(); } onPrimaryColorChange(ImmichColorPreset colorPreset) { - ref.read(metadataProvider).write(MetadataKey.primaryColor, colorPreset); + ref.read(metadataProvider).write(.themePrimaryColor, colorPreset); //turn off system color setting if (themeConfig.dynamicTheme) { - ref.read(metadataProvider).write(MetadataKey.dynamicTheme, false); + ref.read(metadataProvider).write(.themeDynamic, false); } popBottomSheet(); } diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index a011252061..d71842d786 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; @@ -23,7 +22,7 @@ class ThemeSetting extends HookConsumerWidget { void onThemeChange(bool isDark) { currentTheme.value = isDark ? ThemeMode.dark : ThemeMode.light; - ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value); + ref.read(metadataProvider).write(.themeMode, currentTheme.value); } void onSystemThemeChange(bool isSystem) { @@ -40,11 +39,11 @@ class ThemeSetting extends HookConsumerWidget { currentTheme.value = ThemeMode.dark; } } - ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value); + ref.read(metadataProvider).write(.themeMode, currentTheme.value); } void onSurfaceColorSettingChange(bool useColorfulInterface) { - ref.read(metadataProvider).write(MetadataKey.colorfulInterface, useColorfulInterface); + ref.read(metadataProvider).write(.themeColorfulInterface, useColorfulInterface); colorfulInterface.value = useColorfulInterface; } From 52b00b0bad79f94cc0075758f2d37ab0ddd698e2 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 7 May 2026 18:28:24 +0200 Subject: [PATCH 04/71] chore(ml): add mise checklist command (#28267) * chore(ml): add mise checklist command * don't depend tests on installing a cpu flavor --- machine-learning/mise.toml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/machine-learning/mise.toml b/machine-learning/mise.toml index d83bdcd28b..eceaa6f35d 100644 --- a/machine-learning/mise.toml +++ b/machine-learning/mise.toml @@ -15,4 +15,14 @@ run = "uv run pytest --cov=immich_ml --cov-report term-missing" run = "uv run ruff format immich_ml" [tasks.check] -run = "uv run mypy --strict immich_ml/" \ No newline at end of file +run = "uv run mypy --strict immich_ml/" + +[tasks.checklist] +run = [ + { task = ":install" }, + { task = ":format" }, + { task = ":lint" }, + { task = ":check" }, + { task = ":test" }, +] + From 2039c129f257e86029c0cf43b38a9a8a90f0da23 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 7 May 2026 21:00:23 +0200 Subject: [PATCH 05/71] refactor: settings accordion reactivity (#28281) --- e2e-auth-server/package.json | 3 +- .../settings/SettingAccordion.svelte | 35 ++- .../settings/SettingAccordionState.svelte | 43 ---- .../lib/managers/accordion-manager.svelte.ts | 46 ++++ web/src/lib/utils/context.ts | 8 - .../user-settings/UserSettingsList.svelte | 211 +++++++++--------- web/src/routes/admin/maintenance/+page.svelte | 20 +- .../routes/admin/system-settings/+page.svelte | 14 +- 8 files changed, 178 insertions(+), 202 deletions(-) delete mode 100644 web/src/lib/components/shared-components/settings/SettingAccordionState.svelte create mode 100644 web/src/lib/managers/accordion-manager.svelte.ts delete mode 100644 web/src/lib/utils/context.ts diff --git a/e2e-auth-server/package.json b/e2e-auth-server/package.json index f8ea7243fd..8aa24c04ec 100644 --- a/e2e-auth-server/package.json +++ b/e2e-auth-server/package.json @@ -11,5 +11,6 @@ "@types/oidc-provider": "^9.0.0", "oidc-provider": "^9.0.0", "tsx": "^4.20.6" - } + }, + "packageManager": "pnpm@10.33.1" } diff --git a/web/src/lib/components/shared-components/settings/SettingAccordion.svelte b/web/src/lib/components/shared-components/settings/SettingAccordion.svelte index d4b46b93c6..8d820047fb 100755 --- a/web/src/lib/components/shared-components/settings/SettingAccordion.svelte +++ b/web/src/lib/components/shared-components/settings/SettingAccordion.svelte @@ -1,10 +1,8 @@ @@ -72,7 +63,7 @@ + + {/each} + {/if} From 8f4b0fce49051122636d6a79e7b4bddad49da6b2 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 12 May 2026 11:08:17 +0700 Subject: [PATCH 47/71] fix: limit android background worker duration (#23566) * fix: limit each android background run to 20 mins # Conflicts: # mobile/lib/domain/services/background_worker.service.dart * review chages --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../immich/background/BackgroundWorker.g.kt | 4 +- .../immich/background/BackgroundWorker.kt | 2 +- .../Background/BackgroundWorker.g.swift | 6 +- .../services/background_worker.service.dart | 64 +++++++++++-------- .../lib/platform/background_worker_api.g.dart | 6 +- mobile/pigeon/background_worker_api.dart | 2 +- 6 files changed, 50 insertions(+), 34 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt index 0ae49f87f6..3fcaed34bc 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt @@ -416,12 +416,12 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p } } } - fun onAndroidUpload(callback: (Result) -> Unit) + fun onAndroidUpload(maxMinutesArg: Long?, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(null) { + channel.send(listOf(maxMinutesArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt index 7dce1f6edf..716477904c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -107,7 +107,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : * This method acts as a bridge between the native Android background task system and Flutter. */ override fun onInitialized() { - flutterApi?.onAndroidUpload { handleHostResult(it) } + flutterApi?.onAndroidUpload(maxMinutesArg = 20) { handleHostResult(it) } } // TODO: Move this to a separate NotificationManager class diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift index 40553441a6..bd01e953f9 100644 --- a/mobile/ios/Runner/Background/BackgroundWorker.g.swift +++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift @@ -348,7 +348,7 @@ class BackgroundWorkerBgHostApiSetup { /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. protocol BackgroundWorkerFlutterApiProtocol { func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) - func onAndroidUpload(completion: @escaping (Result) -> Void) + func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result) -> Void) func cancel(completion: @escaping (Result) -> Void) } class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol { @@ -379,10 +379,10 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol { } } } - func onAndroidUpload(completion: @escaping (Result) -> Void) { + func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result) -> Void) { let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage(nil) { response in + channel.sendMessage([maxMinutesArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { completion(.failure(createConnectionError(withChannelName: channelName))) return diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index a2e96f2313..0c8746700c 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -105,46 +105,58 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } @override - Future onAndroidUpload() async { - _logger.info('Android background processing started'); - final sw = Stopwatch()..start(); - try { - if (!await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6))) { - _logger.warning("Remote sync did not complete successfully, skipping backup"); - return; - } - await _handleBackup(); - } catch (error, stack) { - _logger.severe("Failed to complete Android background processing", error, stack); - } finally { - sw.stop(); - _logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s"); - await _cleanup(); - } + Future onAndroidUpload(int? maxMinutes) async { + final hashTimeout = Duration(minutes: _isBackupEnabled ? 3 : 6); + final backupTimeout = maxMinutes != null ? Duration(minutes: maxMinutes - 1) : null; + return _backgroundLoop( + hashTimeout: hashTimeout, + backupTimeout: backupTimeout, + debugLabel: 'Android background upload', + ); } @override Future onIosUpload(bool isRefresh, int? maxSeconds) async { - _logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s'); + final hashTimeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6); + final backupTimeout = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null; + return _backgroundLoop(hashTimeout: hashTimeout, backupTimeout: backupTimeout, debugLabel: 'iOS background upload'); + } + + Future _backgroundLoop({ + required Duration hashTimeout, + required Duration? backupTimeout, + required String debugLabel, + }) async { + _logger.info( + '$debugLabel started hashTimeout: ${hashTimeout.inSeconds}s, backupTimeout: ${backupTimeout?.inMinutes ?? '~'}m', + ); final sw = Stopwatch()..start(); try { - final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6); - if (!await _syncAssets(hashTimeout: timeout)) { + if (!await _syncAssets(hashTimeout: hashTimeout)) { _logger.warning("Remote sync did not complete successfully, skipping backup"); return; } final backupFuture = _handleBackup(); - if (maxSeconds != null) { - await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {}); - } else { + Timer? cancelTimer; + if (backupTimeout != null) { + cancelTimer = Timer(backupTimeout, () { + if (!_cancellationToken.isCompleted) { + _logger.warning("$debugLabel timed out after ${backupTimeout.inMinutes}m, cancelling backup"); + _cancellationToken.complete(); + } + }); + } + try { await backupFuture; + } finally { + cancelTimer?.cancel(); } } catch (error, stack) { - _logger.severe("Failed to complete iOS background upload", error, stack); + _logger.severe("Failed to complete $debugLabel", error, stack); } finally { sw.stop(); - _logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s"); + _logger.info("$debugLabel completed in ${sw.elapsed.inSeconds}s"); await _cleanup(); } } @@ -177,7 +189,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final nativeSyncApi = _ref?.read(nativeSyncApiProvider); _logger.info("Cleaning up background worker"); - _cancellationToken.complete(); + if (!_cancellationToken.isCompleted) { + _cancellationToken.complete(); + } final cleanupFutures = [ nativeSyncApi?.cancelHashing(), workerManagerPatch.dispose().catchError((_) async { diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart index 580531b0f0..34f4c41b48 100644 --- a/mobile/lib/platform/background_worker_api.g.dart +++ b/mobile/lib/platform/background_worker_api.g.dart @@ -277,7 +277,7 @@ abstract class BackgroundWorkerFlutterApi { Future onIosUpload(bool isRefresh, int? maxSeconds); - Future onAndroidUpload(); + Future onAndroidUpload(int? maxMinutes); Future cancel(); @@ -323,8 +323,10 @@ abstract class BackgroundWorkerFlutterApi { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int? arg_maxMinutes = args[0] as int?; try { - await api.onAndroidUpload(); + await api.onAndroidUpload(arg_maxMinutes); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); diff --git a/mobile/pigeon/background_worker_api.dart b/mobile/pigeon/background_worker_api.dart index a40d290199..06395fae7b 100644 --- a/mobile/pigeon/background_worker_api.dart +++ b/mobile/pigeon/background_worker_api.dart @@ -47,7 +47,7 @@ abstract class BackgroundWorkerFlutterApi { // Android Only: Called when the Android background upload is triggered @async - void onAndroidUpload(); + void onAndroidUpload(int? maxMinutes); @async void cancel(); From 4eee023648c903b44824a6df65c8eb995f1f2003 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 11:52:30 +0200 Subject: [PATCH 48/71] chore(deps): update docker.io/valkey/valkey:9 docker digest to 8436e10 (#28369) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- docker/docker-compose.rootless.yml | 2 +- docker/docker-compose.yml | 2 +- e2e/docker-compose.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 58ed573e1e..5a34bf4d3c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -157,7 +157,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index d85186b6f1..24ecb02624 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 9e600289b9..3f3e53424b 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 610b375011..5f3ad35245 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 8b69197554..0ccd54cf3f 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 From ce292bdce9e9f93a6b59d4db404dbbdc71869586 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:02:43 +0200 Subject: [PATCH 49/71] chore(deps): update base-image to v202605051129 (#28374) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- server/Dockerfile.dev | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 049e76bb2e..6e20d900b2 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202604141125@sha256:9338c216fb0fef4172cf53cd8e4ff607c6635d576dcc1366151f13d69bbb45ef AS builder +FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -73,7 +73,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ cd plugins && mise run build -FROM ghcr.io/immich-app/base-server-prod:202604141125@sha256:3b05219afcda09cebfb8513743fc92cec1a3ae262249bfe0de6f90da21326991 +FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index f8a70f03b1..0b2cc0beec 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202604141125@sha256:9338c216fb0fef4172cf53cd8e4ff607c6635d576dcc1366151f13d69bbb45ef AS dev +FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ From bcef7aa6b6c5708ffecf9d0806b1bbaa9b7705d3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:07:32 +0200 Subject: [PATCH 50/71] chore(deps): update github-actions (#28372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/check-openapi.yml | 2 +- .github/workflows/close-duplicates.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/pr-labeler.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 47ac1d3730..07c7505762 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@37bf9ff785c7315df88216660826e71be4cc03da # v0.0.44 + uses: oasdiff/oasdiff-action/breaking@26ccb332c67a45ca649de9faf60552ef1b8260d9 # v0.0.46 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index 55fd5ea0ca..b0b5258048 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -35,7 +35,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:32abe582452b12dff55055e1d6bc24508a8f17164f9d1831db7bb70953c014c6 + image: ghcr.io/immich-app/mdq:main@sha256:0a8b8867773a0f8368061f47578603f438349f8f1f28b0e16105f481e5c794e0 outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 827864cec5..f9e6dbfa2d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 5e547fb47a..4df27e581e 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -17,6 +17,6 @@ jobs: client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: repo-token: ${{ steps.token.outputs.token }} From f88fdae048955cc9138be7f6fe74d7ae2759b886 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:15:47 +0200 Subject: [PATCH 51/71] fix(deps): update dependency @immich/ui to ^0.77.0 (#28373) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 41 +++++++---------------------------------- web/package.json | 2 +- 2 files changed, 8 insertions(+), 35 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2dede8603..175217f4d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,8 +731,8 @@ importers: specifier: workspace:* version: link:../packages/sdk '@immich/ui': - specifier: ^0.76.0 - version: 0.76.2(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2) + specifier: ^0.77.0 + version: 0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2) '@mapbox/mapbox-gl-rtl-text': specifier: 0.4.0 version: 0.4.0 @@ -3027,14 +3027,10 @@ packages: resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} hasBin: true - '@immich/svelte-markdown-preprocess@0.4.1': - resolution: {integrity: sha512-/N5dhu3fnRZUoZ+Z9hrIV61o9wi6Uf70TDxqiinXNYlXfqP81p1o77Z5mhbxtNigTNcp6GwpGeHAXRHQrU9JAQ==} - peerDependencies: - svelte: ^5.0.0 - - '@immich/ui@0.76.2': - resolution: {integrity: sha512-D5oqBMyGg8x7YcrmWLgYO1z6d5BU454jejoDJqkW/oJGHMXCSSyY+l/skmVR+fLd1Pttf28gJE9TVG1xXqJ0rA==} + '@immich/ui@0.77.3': + resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==} peerDependencies: + '@sveltejs/kit': ^2.13.0 svelte: ^5.0.0 '@inquirer/ansi@1.0.2': @@ -7778,9 +7774,6 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - front-matter@4.0.2: - resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -9031,11 +9024,6 @@ packages: engines: {node: '>= 20'} hasBin: true - marked@17.0.6: - resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} - engines: {node: '>= 20'} - hasBin: true - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -15405,18 +15393,11 @@ snapshots: pg-connection-string: 2.12.0 postgres: 3.4.9 - '@immich/svelte-markdown-preprocess@0.4.1(svelte@5.55.2)': + '@immich/ui@0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)': dependencies: - front-matter: 4.0.2 - marked: 17.0.6 - node-emoji: 2.2.0 - svelte: 5.55.2 - - '@immich/ui@0.76.2(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)': - dependencies: - '@immich/svelte-markdown-preprocess': 0.4.1(svelte@5.55.2) '@internationalized/date': 3.12.1 '@mdi/js': 7.4.47 + '@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2) luxon: 3.7.2 simple-icons: 16.17.0 @@ -15425,8 +15406,6 @@ snapshots: tailwind-merge: 3.5.0 tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.4) tailwindcss: 4.2.4 - transitivePeerDependencies: - - '@sveltejs/kit' '@inquirer/ansi@1.0.2': {} @@ -20636,10 +20615,6 @@ snapshots: fresh@2.0.0: {} - front-matter@4.0.2: - dependencies: - js-yaml: 3.14.2 - fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -22015,8 +21990,6 @@ snapshots: marked@16.4.2: {} - marked@17.0.6: {} - math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: diff --git a/web/package.json b/web/package.json index fedb4a1c9b..1ff9beb68e 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.76.0", + "@immich/ui": "^0.77.0", "@mapbox/mapbox-gl-rtl-text": "0.4.0", "@mdi/js": "^7.4.47", "@noble/hashes": "^2.2.0", From 4791313def93188dcbd988dc8bd9bbd5fd86a568 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Tue, 12 May 2026 14:12:27 +0200 Subject: [PATCH 52/71] fix: manage oazapfts through mise (#28380) --- mise.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 21022238cd..768bef5222 100644 --- a/mise.toml +++ b/mise.toml @@ -21,6 +21,7 @@ pnpm = "10.33.1" terragrunt = "1.0.3" opentofu = "1.11.6" java = "21.0.2" +"npm:oazapfts" = "7.5.0" [tools."github:CQLabs/homebrew-dcm"] version = "1.37.0" @@ -42,7 +43,7 @@ pin = true [tasks.open-api-typescript] run = [ - "pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts", + "oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts", { task = "//:sdk:install" }, { task = "//:sdk:build" }, ] From 571e6a8560fbf4031b30e530196e281b4858bfb4 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Tue, 12 May 2026 19:12:26 +0600 Subject: [PATCH 53/71] chore(mobile): drop deprecated deviceAssetId / deviceId from upload fields (#28384) server removed both fields from AssetMediaCreateDto in #27818. zod silently strips unknown fields so uploads still work, but we send dead weight on every request. drop from foreground + background upload paths + share intent path. deviceAssetId stays as the internal background_downloader taskId, just not in the multipart form fields anymore. --- mobile/lib/services/background_upload.service.dart | 3 --- mobile/lib/services/foreground_upload.service.dart | 7 ------- 2 files changed, 10 deletions(-) diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index 92be0bfb7f..ab091f3925 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -394,12 +394,9 @@ class BackgroundUploadService { final serverEndpoint = Store.get(StoreKey.serverEndpoint); final url = Uri.parse('$serverEndpoint/assets').toString(); final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); final fieldsMap = { 'filename': originalFileName ?? filename, - 'deviceAssetId': deviceAssetId ?? '', - 'deviceId': deviceId, 'fileCreatedAt': createdAt.toUtc().toIso8601String(), 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'isFavorite': isFavorite?.toString() ?? 'false', diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 258a3105f5..2a3ebcb62a 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -5,8 +5,6 @@ import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.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/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -321,11 +319,8 @@ class ForegroundUploadService { } final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; - final deviceId = Store.get(StoreKey.deviceId); final fields = { - 'deviceAssetId': asset.localId!, - 'deviceId': deviceId, 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'isFavorite': asset.isFavorite.toString(), @@ -431,8 +426,6 @@ class ForegroundUploadService { final filename = p.basename(file.path); final fields = { - 'deviceAssetId': deviceAssetId, - 'deviceId': Store.get(StoreKey.deviceId), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'isFavorite': 'false', From ad7aedb8433e72b7e76b2c417671dfb6f91cbe20 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 12 May 2026 09:28:30 -0400 Subject: [PATCH 54/71] refactor: move plugins to packages (#28389) --- .../server/container-compose-overrides.yml | 2 +- docker/docker-compose.dev.yml | 2 +- mise.toml | 2 +- {plugins => packages/plugins}/.gitignore | 0 {plugins => packages/plugins}/LICENSE | 0 {plugins => packages/plugins}/esbuild.js | 0 {plugins => packages/plugins}/manifest.json | 0 {plugins => packages/plugins}/mise.toml | 0 .../plugins}/package-lock.json | 0 {plugins => packages/plugins}/package.json | 0 {plugins => packages/plugins}/src/index.d.ts | 0 {plugins => packages/plugins}/src/index.ts | 0 {plugins => packages/plugins}/tsconfig.json | 0 pnpm-lock.yaml | 24 +++++++++---------- server/Dockerfile | 14 +++++------ 15 files changed, 22 insertions(+), 22 deletions(-) rename {plugins => packages/plugins}/.gitignore (100%) rename {plugins => packages/plugins}/LICENSE (100%) rename {plugins => packages/plugins}/esbuild.js (100%) rename {plugins => packages/plugins}/manifest.json (100%) rename {plugins => packages/plugins}/mise.toml (100%) rename {plugins => packages/plugins}/package-lock.json (100%) rename {plugins => packages/plugins}/package.json (100%) rename {plugins => packages/plugins}/src/index.d.ts (100%) rename {plugins => packages/plugins}/src/index.ts (100%) rename {plugins => packages/plugins}/tsconfig.json (100%) diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 5c312efd07..8f9e562e0a 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -16,7 +16,7 @@ services: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../plugins:/build/corePlugin + - ../packages/plugins:/build/corePlugin immich-web: env_file: !reset [] immich-machine-learning: diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5a34bf4d3c..dfb876e6bd 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -74,7 +74,7 @@ services: - ${UPLOAD_LOCATION}/photos:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../plugins:/build/corePlugin + - ../packages/plugins:/build/corePlugin env_file: - .env environment: diff --git a/mise.toml b/mise.toml index 768bef5222..a88e3bb2b1 100644 --- a/mise.toml +++ b/mise.toml @@ -2,7 +2,7 @@ experimental_monorepo_root = true [monorepo] config_roots = [ - "plugins", + "packages/plugins", "server", "packages/cli", "deployment", diff --git a/plugins/.gitignore b/packages/plugins/.gitignore similarity index 100% rename from plugins/.gitignore rename to packages/plugins/.gitignore diff --git a/plugins/LICENSE b/packages/plugins/LICENSE similarity index 100% rename from plugins/LICENSE rename to packages/plugins/LICENSE diff --git a/plugins/esbuild.js b/packages/plugins/esbuild.js similarity index 100% rename from plugins/esbuild.js rename to packages/plugins/esbuild.js diff --git a/plugins/manifest.json b/packages/plugins/manifest.json similarity index 100% rename from plugins/manifest.json rename to packages/plugins/manifest.json diff --git a/plugins/mise.toml b/packages/plugins/mise.toml similarity index 100% rename from plugins/mise.toml rename to packages/plugins/mise.toml diff --git a/plugins/package-lock.json b/packages/plugins/package-lock.json similarity index 100% rename from plugins/package-lock.json rename to packages/plugins/package-lock.json diff --git a/plugins/package.json b/packages/plugins/package.json similarity index 100% rename from plugins/package.json rename to packages/plugins/package.json diff --git a/plugins/src/index.d.ts b/packages/plugins/src/index.d.ts similarity index 100% rename from plugins/src/index.d.ts rename to packages/plugins/src/index.d.ts diff --git a/plugins/src/index.ts b/packages/plugins/src/index.ts similarity index 100% rename from plugins/src/index.ts rename to packages/plugins/src/index.ts diff --git a/plugins/tsconfig.json b/packages/plugins/tsconfig.json similarity index 100% rename from plugins/tsconfig.json rename to packages/plugins/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 175217f4d8..157b0746e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,18 @@ importers: specifier: ^4.20.6 version: 4.21.0 + packages/plugins: + devDependencies: + '@extism/js-pdk': + specifier: ^1.0.1 + version: 1.1.1 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 + typescript: + specifier: ^6.0.0 + version: 6.0.3 + packages/sdk: dependencies: '@oazapfts/runtime': @@ -325,18 +337,6 @@ importers: specifier: ^6.0.0 version: 6.0.3 - plugins: - devDependencies: - '@extism/js-pdk': - specifier: ^1.0.1 - version: 1.1.1 - esbuild: - specifier: ^0.28.0 - version: 0.28.0 - typescript: - specifier: ^6.0.0 - version: 6.0.3 - server: dependencies: '@extism/extism': diff --git a/server/Dockerfile b/server/Dockerfile index 6e20d900b2..d35d029958 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -57,13 +57,13 @@ ARG TARGETPLATFORM COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise WORKDIR /usr/src/app -COPY ./plugins/mise.toml ./plugins/ -ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml +COPY ./packages/plugins/mise.toml ./packages/plugins/ +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/packages/plugins/mise.toml ENV MISE_DATA_DIR=/buildcache/mise RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - mise install --cd plugins + mise install --cd packages/plugins -COPY ./plugins ./plugins/ +COPY ./packages/plugins ./packages/plugins/ # Build plugins RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=bind,source=package.json,target=package.json \ @@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - cd plugins && mise run build + cd packages/plugins && mise run build FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd @@ -83,8 +83,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli -COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist -COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json +COPY --from=plugins /usr/src/app/packages/plugins/dist /build/corePlugin/dist +COPY --from=plugins /usr/src/app/packages/plugins/manifest.json /build/corePlugin/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE From dc6f8e746e8ae7ffbca15bca0148eaf9fbe178bd Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 12 May 2026 11:19:54 -0500 Subject: [PATCH 55/71] fix: deep link for assets when asset viewer already open (#27971) --- mobile/lib/main.dart | 29 ++++++++---- mobile/lib/services/deep_link.service.dart | 51 +++++----------------- 2 files changed, 32 insertions(+), 48 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 14d09e4cb7..19455be61c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -179,19 +179,32 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve final isColdStart = currentRouteName == null || currentRouteName == SplashScreenRoute.name; + PageRouteInfo? route; if (deepLink.uri.scheme == "immich") { - final proposedRoute = await deepLinkHandler.handleScheme(deepLink, ref, isColdStart); - - return proposedRoute; + route = await deepLinkHandler.handleScheme(deepLink, ref); + } else if (deepLink.uri.host == "my.immich.app") { + route = await deepLinkHandler.handleMyImmichApp(deepLink, ref); + } else { + return DeepLink.path(deepLink.path); } - if (deepLink.uri.host == "my.immich.app") { - final proposedRoute = await deepLinkHandler.handleMyImmichApp(deepLink, ref, isColdStart); - - return proposedRoute; + if (route == null) { + return isColdStart ? DeepLink.defaultPath : DeepLink.none; } - return DeepLink.path(deepLink.path); + // We need to replace the route if the destination is the current route + if (!isColdStart) { + unawaited( + ref.read(appRouterProvider).pushAndPopUntil(route, predicate: (r) => r.settings.name != route!.routeName), + ); + return DeepLink.none; + } + + return DeepLink([ + // we need something to segue back to if the app was cold started + if (isColdStart) const TabShellRoute(children: [MainTimelineRoute()]), + route, + ]); } @override diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 3d2d702917..26f2fb685b 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -45,21 +45,12 @@ class DeepLinkService { this._currentUser, ); - DeepLink _handleColdStart(PageRouteInfo route, bool isColdStart) { - return DeepLink([ - // we need something to segue back to if the app was cold started - // TODO: use MainTimelineRoute this when beta is default - if (isColdStart) const TabShellRoute(), - route, - ]); - } - - Future handleScheme(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async { + Future handleScheme(PlatformDeepLink link, WidgetRef ref) async { // get everything after the scheme, since Uri cannot parse path final intent = link.uri.host; final queryParams = link.uri.queryParameters; - PageRouteInfo? deepLinkRoute = switch (intent) { + return switch (intent) { "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), @@ -67,20 +58,9 @@ class DeepLinkService { "activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''), _ => null, }; - - // Deep link resolution failed, safely handle it based on the app state - if (deepLinkRoute == null) { - if (isColdStart) { - return DeepLink.defaultPath; - } - - return DeepLink.none; - } - - return _handleColdStart(deepLinkRoute, isColdStart); } - Future handleMyImmichApp(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async { + Future handleMyImmichApp(PlatformDeepLink link, WidgetRef ref) async { final path = link.uri.path; const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; @@ -88,29 +68,20 @@ class DeepLinkService { final albumRegex = RegExp('/albums/($uuidRegex)'); final peopleRegex = RegExp('/people/($uuidRegex)'); - PageRouteInfo? deepLinkRoute; if (assetRegex.hasMatch(path)) { final assetId = assetRegex.firstMatch(path)?.group(1) ?? ''; - deepLinkRoute = await _buildAssetDeepLink(assetId, ref); - } else if (albumRegex.hasMatch(path)) { + return _buildAssetDeepLink(assetId, ref); + } + if (albumRegex.hasMatch(path)) { final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; - deepLinkRoute = await _buildAlbumDeepLink(albumId); - } else if (peopleRegex.hasMatch(path)) { + return _buildAlbumDeepLink(albumId); + } + if (peopleRegex.hasMatch(path)) { final peopleId = peopleRegex.firstMatch(path)?.group(1) ?? ''; - deepLinkRoute = await _buildPeopleDeepLink(peopleId); - } else if (path == "/memory") { - deepLinkRoute = await _buildMemoryDeepLink(null); + return _buildPeopleDeepLink(peopleId); } - // Deep link resolution failed, safely handle it based on the app state - if (deepLinkRoute == null) { - if (isColdStart) { - return DeepLink.defaultPath; - } - return DeepLink.none; - } - - return _handleColdStart(deepLinkRoute, isColdStart); + return null; } Future _buildMemoryDeepLink(String? memoryId) async { From 1932c60e1cf4e55783764c18e9959a643436b808 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 12 May 2026 23:27:55 +0700 Subject: [PATCH 56/71] fix: kekab icon colors in light mode (#28366) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../open_in_browser_action_button.widget.dart | 3 --- .../asset_viewer/viewer_kebab_menu.widget.dart | 14 ++++++++------ mobile/lib/utils/action_button.utils.dart | 7 +------ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart index 17703d0beb..541a9f8093 100644 --- a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart @@ -12,7 +12,6 @@ class OpenInBrowserActionButton extends ConsumerWidget { final TimelineOrigin origin; final bool iconOnly; final bool menuItem; - final Color? iconColor; const OpenInBrowserActionButton({ super.key, @@ -20,7 +19,6 @@ class OpenInBrowserActionButton extends ConsumerWidget { required this.origin, this.iconOnly = false, this.menuItem = false, - this.iconColor, }); void _onTap() async { @@ -52,7 +50,6 @@ class OpenInBrowserActionButton extends ConsumerWidget { return BaseActionButton( label: 'open_in_browser'.t(context: context), iconData: Icons.open_in_browser, - iconColor: iconColor, iconOnly: iconOnly, menuItem: menuItem, onPressed: _onTap, diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 78b2e50da5..5a79485daf 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -4,8 +4,8 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -48,7 +48,6 @@ class ViewerKebabMenu extends ConsumerWidget { source: ActionSource.viewer, isCasting: isCasting, timelineOrigin: timelineOrigin, - originalTheme: originalTheme, ); final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); @@ -67,10 +66,13 @@ class ViewerKebabMenu extends ConsumerWidget { menuChildren: [ ConstrainedBox( constraints: const BoxConstraints(minWidth: 150), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: menuChildren, + child: Theme( + data: originalTheme ?? context.themeData, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: menuChildren, + ), ), ), ], diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 4f7ad83093..3f79cd498f 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -22,10 +22,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browse import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -44,7 +44,6 @@ class ActionButtonContext { final ActionSource source; final bool isCasting; final TimelineOrigin timelineOrigin; - final ThemeData? originalTheme; final int selectedCount; const ActionButtonContext({ @@ -59,7 +58,6 @@ class ActionButtonContext { required this.source, this.isCasting = false, this.timelineOrigin = TimelineOrigin.main, - this.originalTheme, this.selectedCount = 1, }); } @@ -244,7 +242,6 @@ enum ActionButtonType { origin: context.timelineOrigin, iconOnly: iconOnly, menuItem: menuItem, - iconColor: context.originalTheme?.iconTheme.color, ), ActionButtonType.similarPhotos => SimilarPhotosActionButton( assetId: (context.asset as RemoteAsset).id, @@ -259,14 +256,12 @@ enum ActionButtonType { ActionButtonType.openInfo => BaseActionButton( label: 'info'.tr(), iconData: Icons.info_outline, - iconColor: context.originalTheme?.iconTheme.color, menuItem: true, onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.tr(), iconData: Icons.image_search, - iconColor: context.originalTheme?.iconTheme.color, iconOnly: iconOnly, menuItem: menuItem, onPressed: buildContext == null From 2ddaf6a611ba480cccfb79f06672dc7d00bf95f8 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 12 May 2026 23:43:24 +0700 Subject: [PATCH 57/71] fix: indexes on remote_asset_entity (#28264) * fix: periodically execute pragma optimize * fix: indexes on remote_asset_entity * regen files --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- .../drift_schemas/main/drift_schema_v25.json | 234 +++++++++--------- .../drift_schemas/main/drift_schema_v26.json | 234 +++++++++--------- .../entities/asset_face.entity.dart | 5 + .../entities/asset_face.entity.drift.dart | 4 + .../infrastructure/entities/exif.entity.dart | 4 + .../entities/exif.entity.drift.dart | 5 + .../entities/remote_asset.entity.dart | 13 +- .../entities/remote_asset.entity.drift.dart | 20 +- .../repositories/db.repository.dart | 6 + .../repositories/db.repository.drift.dart | 6 +- .../repositories/db.repository.steps.dart | 56 ++--- .../test/drift/main/generated/schema_v25.dart | 28 +-- .../test/drift/main/generated/schema_v26.dart | 28 +-- 13 files changed, 327 insertions(+), 316 deletions(-) diff --git a/mobile/drift_schemas/main/drift_schema_v25.json b/mobile/drift_schemas/main/drift_schema_v25.json index 95fa57d4d2..5a3f78aae7 100644 --- a/mobile/drift_schemas/main/drift_schema_v25.json +++ b/mobile/drift_schemas/main/drift_schema_v25.json @@ -1003,20 +1003,6 @@ 1 ], "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_owner_checksum", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)", - "unique": false, - "columns": [] - } - }, - { - "id": 12, - "references": [ - 1 - ], - "type": "index", "data": { "on": 1, "name": "UQ_remote_assets_owner_checksum", @@ -1026,7 +1012,7 @@ } }, { - "id": 13, + "id": 12, "references": [ 1 ], @@ -1040,7 +1026,7 @@ } }, { - "id": 14, + "id": 13, "references": [ 1 ], @@ -1054,7 +1040,7 @@ } }, { - "id": 15, + "id": 14, "references": [ 1 ], @@ -1067,36 +1053,22 @@ "columns": [] } }, + { + "id": 15, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_remote_asset_owner_visibility_deleted_created", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created\nON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)\n", + "unique": false, + "columns": [] + } + }, { "id": 16, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_day", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 17, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_month", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 18, "references": [], "type": "table", "data": { @@ -1226,7 +1198,7 @@ } }, { - "id": 19, + "id": 17, "references": [ 0 ], @@ -1301,7 +1273,7 @@ } }, { - "id": 20, + "id": 18, "references": [ 0 ], @@ -1388,7 +1360,7 @@ } }, { - "id": 21, + "id": 19, "references": [ 1 ], @@ -1644,7 +1616,7 @@ } }, { - "id": 22, + "id": 20, "references": [ 1, 4 @@ -1718,7 +1690,7 @@ } }, { - "id": 23, + "id": 21, "references": [ 4, 0 @@ -1806,7 +1778,7 @@ } }, { - "id": 24, + "id": 22, "references": [ 1 ], @@ -1902,7 +1874,7 @@ } }, { - "id": 25, + "id": 23, "references": [ 0 ], @@ -2066,10 +2038,10 @@ } }, { - "id": 26, + "id": 24, "references": [ 1, - 25 + 23 ], "type": "table", "data": { @@ -2140,7 +2112,7 @@ } }, { - "id": 27, + "id": 25, "references": [ 0 ], @@ -2284,10 +2256,10 @@ } }, { - "id": 28, + "id": 26, "references": [ 1, - 27 + 25 ], "type": "table", "data": { @@ -2461,7 +2433,7 @@ } }, { - "id": 29, + "id": 27, "references": [], "type": "table", "data": { @@ -2509,7 +2481,7 @@ } }, { - "id": 30, + "id": 28, "references": [], "type": "table", "data": { @@ -2684,7 +2656,7 @@ } }, { - "id": 31, + "id": 29, "references": [ 1 ], @@ -2778,7 +2750,7 @@ } }, { - "id": 32, + "id": 30, "references": [], "type": "table", "data": { @@ -2826,13 +2798,13 @@ } }, { - "id": 33, + "id": 31, "references": [ - 20 + 18 ], "type": "index", "data": { - "on": 20, + "on": 18, "name": "idx_partner_shared_with_id", "sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)", "unique": false, @@ -2840,19 +2812,47 @@ } }, { - "id": 34, + "id": 32, "references": [ - 21 + 19 ], "type": "index", "data": { - "on": 21, + "on": 19, "name": "idx_lat_lng", "sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)", "unique": false, "columns": [] } }, + { + "id": 33, + "references": [ + 19 + ], + "type": "index", + "data": { + "on": 19, + "name": "idx_remote_exif_city", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city\nON remote_exif_entity (city) WHERE city IS NOT NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 34, + "references": [ + 20 + ], + "type": "index", + "data": { + "on": 20, + "name": "idx_remote_album_asset_album_asset", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", + "unique": false, + "columns": [] + } + }, { "id": 35, "references": [ @@ -2861,20 +2861,6 @@ "type": "index", "data": { "on": 22, - "name": "idx_remote_album_asset_album_asset", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", - "unique": false, - "columns": [] - } - }, - { - "id": 36, - "references": [ - 24 - ], - "type": "index", - "data": { - "on": 24, "name": "idx_remote_asset_cloud_id", "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)", "unique": false, @@ -2882,13 +2868,13 @@ } }, { - "id": 37, + "id": 36, "references": [ - 27 + 25 ], "type": "index", "data": { - "on": 27, + "on": 25, "name": "idx_person_owner_id", "sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)", "unique": false, @@ -2896,13 +2882,13 @@ } }, { - "id": 38, + "id": 37, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_person_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)", "unique": false, @@ -2910,13 +2896,13 @@ } }, { - "id": 39, + "id": 38, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)", "unique": false, @@ -2924,13 +2910,27 @@ } }, { - "id": 40, + "id": 39, "references": [ - 30 + 26 ], "type": "index", "data": { - "on": 30, + "on": 26, + "name": "idx_asset_face_visible_person", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person\nON asset_face_entity (person_id, asset_id)\nWHERE is_visible = 1 AND deleted_at IS NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 40, + "references": [ + 28 + ], + "type": "index", + "data": { + "on": 28, "name": "idx_trashed_local_asset_checksum", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)", "unique": false, @@ -2940,11 +2940,11 @@ { "id": 41, "references": [ - 30 + 28 ], "type": "index", "data": { - "on": 30, + "on": 28, "name": "idx_trashed_local_asset_album", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)", "unique": false, @@ -2954,11 +2954,11 @@ { "id": 42, "references": [ - 31 + 29 ], "type": "index", "data": { - "on": 31, + "on": 29, "name": "idx_asset_edit_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)", "unique": false, @@ -3066,15 +3066,6 @@ } ] }, - { - "name": "idx_remote_asset_owner_checksum", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)" - } - ] - }, { "name": "UQ_remote_assets_owner_checksum", "sql": [ @@ -3112,20 +3103,11 @@ ] }, { - "name": "idx_remote_asset_local_date_time_day", + "name": "idx_remote_asset_owner_visibility_deleted_created", "sql": [ { "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))" - } - ] - }, - { - "name": "idx_remote_asset_local_date_time_month", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))" + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)" } ] }, @@ -3282,6 +3264,15 @@ } ] }, + { + "name": "idx_remote_exif_city", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL" + } + ] + }, { "name": "idx_remote_album_asset_album_asset", "sql": [ @@ -3327,6 +3318,15 @@ } ] }, + { + "name": "idx_asset_face_visible_person", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL" + } + ] + }, { "name": "idx_trashed_local_asset_checksum", "sql": [ diff --git a/mobile/drift_schemas/main/drift_schema_v26.json b/mobile/drift_schemas/main/drift_schema_v26.json index db90edc24b..b958bcca43 100644 --- a/mobile/drift_schemas/main/drift_schema_v26.json +++ b/mobile/drift_schemas/main/drift_schema_v26.json @@ -1013,20 +1013,6 @@ 1 ], "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_owner_checksum", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)", - "unique": false, - "columns": [] - } - }, - { - "id": 12, - "references": [ - 1 - ], - "type": "index", "data": { "on": 1, "name": "UQ_remote_assets_owner_checksum", @@ -1036,7 +1022,7 @@ } }, { - "id": 13, + "id": 12, "references": [ 1 ], @@ -1050,7 +1036,7 @@ } }, { - "id": 14, + "id": 13, "references": [ 1 ], @@ -1064,7 +1050,7 @@ } }, { - "id": 15, + "id": 14, "references": [ 1 ], @@ -1077,36 +1063,22 @@ "columns": [] } }, + { + "id": 15, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_remote_asset_owner_visibility_deleted_created", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created\nON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)\n", + "unique": false, + "columns": [] + } + }, { "id": 16, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_day", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 17, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_month", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 18, "references": [], "type": "table", "data": { @@ -1236,7 +1208,7 @@ } }, { - "id": 19, + "id": 17, "references": [ 0 ], @@ -1311,7 +1283,7 @@ } }, { - "id": 20, + "id": 18, "references": [ 0 ], @@ -1398,7 +1370,7 @@ } }, { - "id": 21, + "id": 19, "references": [ 1 ], @@ -1654,7 +1626,7 @@ } }, { - "id": 22, + "id": 20, "references": [ 1, 4 @@ -1728,7 +1700,7 @@ } }, { - "id": 23, + "id": 21, "references": [ 4, 0 @@ -1816,7 +1788,7 @@ } }, { - "id": 24, + "id": 22, "references": [ 1 ], @@ -1912,7 +1884,7 @@ } }, { - "id": 25, + "id": 23, "references": [ 0 ], @@ -2076,10 +2048,10 @@ } }, { - "id": 26, + "id": 24, "references": [ 1, - 25 + 23 ], "type": "table", "data": { @@ -2150,7 +2122,7 @@ } }, { - "id": 27, + "id": 25, "references": [ 0 ], @@ -2294,10 +2266,10 @@ } }, { - "id": 28, + "id": 26, "references": [ 1, - 27 + 25 ], "type": "table", "data": { @@ -2471,7 +2443,7 @@ } }, { - "id": 29, + "id": 27, "references": [], "type": "table", "data": { @@ -2519,7 +2491,7 @@ } }, { - "id": 30, + "id": 28, "references": [], "type": "table", "data": { @@ -2694,7 +2666,7 @@ } }, { - "id": 31, + "id": 29, "references": [ 1 ], @@ -2788,7 +2760,7 @@ } }, { - "id": 32, + "id": 30, "references": [], "type": "table", "data": { @@ -2836,13 +2808,13 @@ } }, { - "id": 33, + "id": 31, "references": [ - 20 + 18 ], "type": "index", "data": { - "on": 20, + "on": 18, "name": "idx_partner_shared_with_id", "sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)", "unique": false, @@ -2850,19 +2822,47 @@ } }, { - "id": 34, + "id": 32, "references": [ - 21 + 19 ], "type": "index", "data": { - "on": 21, + "on": 19, "name": "idx_lat_lng", "sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)", "unique": false, "columns": [] } }, + { + "id": 33, + "references": [ + 19 + ], + "type": "index", + "data": { + "on": 19, + "name": "idx_remote_exif_city", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city\nON remote_exif_entity (city) WHERE city IS NOT NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 34, + "references": [ + 20 + ], + "type": "index", + "data": { + "on": 20, + "name": "idx_remote_album_asset_album_asset", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", + "unique": false, + "columns": [] + } + }, { "id": 35, "references": [ @@ -2871,20 +2871,6 @@ "type": "index", "data": { "on": 22, - "name": "idx_remote_album_asset_album_asset", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", - "unique": false, - "columns": [] - } - }, - { - "id": 36, - "references": [ - 24 - ], - "type": "index", - "data": { - "on": 24, "name": "idx_remote_asset_cloud_id", "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)", "unique": false, @@ -2892,13 +2878,13 @@ } }, { - "id": 37, + "id": 36, "references": [ - 27 + 25 ], "type": "index", "data": { - "on": 27, + "on": 25, "name": "idx_person_owner_id", "sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)", "unique": false, @@ -2906,13 +2892,13 @@ } }, { - "id": 38, + "id": 37, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_person_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)", "unique": false, @@ -2920,13 +2906,13 @@ } }, { - "id": 39, + "id": 38, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)", "unique": false, @@ -2934,13 +2920,27 @@ } }, { - "id": 40, + "id": 39, "references": [ - 30 + 26 ], "type": "index", "data": { - "on": 30, + "on": 26, + "name": "idx_asset_face_visible_person", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person\nON asset_face_entity (person_id, asset_id)\nWHERE is_visible = 1 AND deleted_at IS NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 40, + "references": [ + 28 + ], + "type": "index", + "data": { + "on": 28, "name": "idx_trashed_local_asset_checksum", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)", "unique": false, @@ -2950,11 +2950,11 @@ { "id": 41, "references": [ - 30 + 28 ], "type": "index", "data": { - "on": 30, + "on": 28, "name": "idx_trashed_local_asset_album", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)", "unique": false, @@ -2964,11 +2964,11 @@ { "id": 42, "references": [ - 31 + 29 ], "type": "index", "data": { - "on": 31, + "on": 29, "name": "idx_asset_edit_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)", "unique": false, @@ -3076,15 +3076,6 @@ } ] }, - { - "name": "idx_remote_asset_owner_checksum", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)" - } - ] - }, { "name": "UQ_remote_assets_owner_checksum", "sql": [ @@ -3122,20 +3113,11 @@ ] }, { - "name": "idx_remote_asset_local_date_time_day", + "name": "idx_remote_asset_owner_visibility_deleted_created", "sql": [ { "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))" - } - ] - }, - { - "name": "idx_remote_asset_local_date_time_month", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))" + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)" } ] }, @@ -3292,6 +3274,15 @@ } ] }, + { + "name": "idx_remote_exif_city", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL" + } + ] + }, { "name": "idx_remote_album_asset_album_asset", "sql": [ @@ -3337,6 +3328,15 @@ } ] }, + { + "name": "idx_asset_face_visible_person", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL" + } + ] + }, { "name": "idx_trashed_local_asset_checksum", "sql": [ diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.dart b/mobile/lib/infrastructure/entities/asset_face.entity.dart index 40fe9ab1c1..b94a0cf094 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.dart @@ -5,6 +5,11 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)') +@TableIndex.sql(''' +CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person +ON asset_face_entity (person_id, asset_id) +WHERE is_visible = 1 AND deleted_at IS NULL +''') class AssetFaceEntity extends Table with DriftDefaultsMixin { const AssetFaceEntity(); diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart index c97dd545a8..d262325742 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart @@ -1350,3 +1350,7 @@ i0.Index get idxAssetFaceAssetId => i0.Index( 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); +i0.Index get idxAssetFaceVisiblePerson => i0.Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', +); diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index e009029ea7..120fbd0c68 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -6,6 +6,10 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)') +@TableIndex.sql(''' +CREATE INDEX IF NOT EXISTS idx_remote_exif_city +ON remote_exif_entity (city) WHERE city IS NOT NULL +''') class RemoteExifEntity extends Table with DriftDefaultsMixin { const RemoteExifEntity(); diff --git a/mobile/lib/infrastructure/entities/exif.entity.drift.dart b/mobile/lib/infrastructure/entities/exif.entity.drift.dart index 8695e2004b..cbe31f5bb4 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.drift.dart @@ -1883,3 +1883,8 @@ class RemoteExifEntityCompanion .toString(); } } + +i0.Index get idxRemoteExifCity => i0.Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', +); diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 1050081b46..8644667168 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -5,9 +5,6 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; -@TableIndex.sql( - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', -) @TableIndex.sql(''' CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) @@ -20,12 +17,10 @@ WHERE (library_id IS NOT NULL); ''') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)') -@TableIndex.sql( - "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))", -) -@TableIndex.sql( - "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))", -) +@TableIndex.sql(''' +CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created +ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC) +''') class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const RemoteAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index a2333696b0..8141573343 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart @@ -666,9 +666,9 @@ typedef $$RemoteAssetEntityTableProcessedTableManager = i1.RemoteAssetEntityData, i0.PrefetchHooks Function({bool ownerId}) >; -i0.Index get idxRemoteAssetOwnerChecksum => i0.Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', +i0.Index get uQRemoteAssetsOwnerChecksum => i0.Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', ); class $RemoteAssetEntityTable extends i3.RemoteAssetEntity @@ -1763,10 +1763,6 @@ class RemoteAssetEntityCompanion } } -i0.Index get uQRemoteAssetsOwnerChecksum => i0.Index( - 'UQ_remote_assets_owner_checksum', - 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', -); i0.Index get uQRemoteAssetsOwnerLibraryChecksum => i0.Index( 'UQ_remote_assets_owner_library_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', @@ -1779,11 +1775,7 @@ i0.Index get idxRemoteAssetStackId => i0.Index( 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); -i0.Index get idxRemoteAssetLocalDateTimeDay => i0.Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', -); -i0.Index get idxRemoteAssetLocalDateTimeMonth => i0.Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', +i0.Index get idxRemoteAssetOwnerVisibilityDeletedCreated => i0.Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 2e3df13d38..e81fe58ba9 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -266,6 +266,12 @@ class Drift extends $Drift { }, from24To25: (m, v25) async { await m.createTable(v25.metadata); + await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum'); + await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day'); + await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month'); + await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated); + await m.createIndex(v25.idxRemoteExifCity); + await m.createIndex(v25.idxAssetFaceVisiblePerson); }, from25To26: (m, v26) async { await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 01f44372fb..c43a83f86a 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -113,13 +113,11 @@ abstract class $Drift extends i0.GeneratedDatabase { i4.idxLocalAssetChecksum, i4.idxLocalAssetCloudId, i3.idxStackPrimaryAssetId, - i2.idxRemoteAssetOwnerChecksum, i2.uQRemoteAssetsOwnerChecksum, i2.uQRemoteAssetsOwnerLibraryChecksum, i2.idxRemoteAssetChecksum, i2.idxRemoteAssetStackId, - i2.idxRemoteAssetLocalDateTimeDay, - i2.idxRemoteAssetLocalDateTimeMonth, + i2.idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -137,11 +135,13 @@ abstract class $Drift extends i0.GeneratedDatabase { metadataEntity, i10.idxPartnerSharedWithId, i11.idxLatLng, + i11.idxRemoteExifCity, i12.idxRemoteAlbumAssetAlbumAsset, i14.idxRemoteAssetCloudId, i17.idxPersonOwnerId, i18.idxAssetFacePersonId, i18.idxAssetFaceAssetId, + i18.idxAssetFaceVisiblePerson, i20.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetAlbum, i21.idxAssetEditAssetId, diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 21d915f8af..1fb88de1d0 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -12390,13 +12390,11 @@ final class Schema25 extends i0.VersionedSchema { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -12414,11 +12412,13 @@ final class Schema25 extends i0.VersionedSchema { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, @@ -12583,10 +12583,6 @@ final class Schema25 extends i0.VersionedSchema { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -12603,13 +12599,9 @@ final class Schema25 extends i0.VersionedSchema { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final Shape40 authUserEntity = Shape40( source: i0.VersionedTable( @@ -12883,6 +12875,10 @@ final class Schema25 extends i0.VersionedSchema { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + final i1.Index idxRemoteExifCity = i1.Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -12903,6 +12899,10 @@ final class Schema25 extends i0.VersionedSchema { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + final i1.Index idxAssetFaceVisiblePerson = i1.Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); final i1.Index idxTrashedLocalAssetChecksum = i1.Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', @@ -12959,13 +12959,11 @@ final class Schema26 extends i0.VersionedSchema { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -12983,11 +12981,13 @@ final class Schema26 extends i0.VersionedSchema { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, @@ -13153,10 +13153,6 @@ final class Schema26 extends i0.VersionedSchema { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -13173,13 +13169,9 @@ final class Schema26 extends i0.VersionedSchema { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final Shape40 authUserEntity = Shape40( source: i0.VersionedTable( @@ -13453,6 +13445,10 @@ final class Schema26 extends i0.VersionedSchema { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + final i1.Index idxRemoteExifCity = i1.Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -13473,6 +13469,10 @@ final class Schema26 extends i0.VersionedSchema { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + final i1.Index idxAssetFaceVisiblePerson = i1.Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); final i1.Index idxTrashedLocalAssetChecksum = i1.Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', diff --git a/mobile/test/drift/main/generated/schema_v25.dart b/mobile/test/drift/main/generated/schema_v25.dart index 932b50498c..aad45f0bd3 100644 --- a/mobile/test/drift/main/generated/schema_v25.dart +++ b/mobile/test/drift/main/generated/schema_v25.dart @@ -9030,10 +9030,6 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - late final Index idxRemoteAssetOwnerChecksum = Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); late final Index uQRemoteAssetsOwnerChecksum = Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -9050,13 +9046,9 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - late final Index idxRemoteAssetLocalDateTimeDay = Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - late final Index idxRemoteAssetLocalDateTimeMonth = Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + late final Index idxRemoteAssetOwnerVisibilityDeletedCreated = Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final AuthUserEntity authUserEntity = AuthUserEntity(this); late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); @@ -9085,6 +9077,10 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + late final Index idxRemoteExifCity = Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); late final Index idxRemoteAlbumAssetAlbumAsset = Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -9105,6 +9101,10 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + late final Index idxAssetFaceVisiblePerson = Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); late final Index idxTrashedLocalAssetChecksum = Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', @@ -9133,13 +9133,11 @@ class DatabaseAtV25 extends GeneratedDatabase { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -9157,11 +9155,13 @@ class DatabaseAtV25 extends GeneratedDatabase { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, diff --git a/mobile/test/drift/main/generated/schema_v26.dart b/mobile/test/drift/main/generated/schema_v26.dart index f9e3af5d7c..b91afd1b8a 100644 --- a/mobile/test/drift/main/generated/schema_v26.dart +++ b/mobile/test/drift/main/generated/schema_v26.dart @@ -9069,10 +9069,6 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - late final Index idxRemoteAssetOwnerChecksum = Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); late final Index uQRemoteAssetsOwnerChecksum = Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -9089,13 +9085,9 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - late final Index idxRemoteAssetLocalDateTimeDay = Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - late final Index idxRemoteAssetLocalDateTimeMonth = Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + late final Index idxRemoteAssetOwnerVisibilityDeletedCreated = Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final AuthUserEntity authUserEntity = AuthUserEntity(this); late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); @@ -9124,6 +9116,10 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + late final Index idxRemoteExifCity = Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); late final Index idxRemoteAlbumAssetAlbumAsset = Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -9144,6 +9140,10 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + late final Index idxAssetFaceVisiblePerson = Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); late final Index idxTrashedLocalAssetChecksum = Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', @@ -9172,13 +9172,11 @@ class DatabaseAtV26 extends GeneratedDatabase { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -9196,11 +9194,13 @@ class DatabaseAtV26 extends GeneratedDatabase { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, From f632d320f52c419697fe7e4445054e45bd4fe6fe Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 13 May 2026 00:43:15 +0600 Subject: [PATCH 58/71] fix(mobile): clear linkedRemoteAlbumId in reset() so FK refs dont dangle (#28382) * fix(mobile): clear linkedRemoteAlbumId in reset() so FK refs dont dangle reset() runs with foreign_keys off before wiping remote_* tables, so the ON DELETE SET NULL cascade on linkedRemoteAlbumId doesnt fire. local rows keep pointing at deleted remote ids. affects logout (clearLocalData calls reset()) and the server SyncResetV1 path (30 day idle, etc). after re-login, syncLinkedAlbum either silently warns or fires 400s (those are covered by #28299). null the column manually inside the same transaction. cascade still works for normal SyncAlbumDeleteV1. verified on pixel 9a with this branch built locally: logged out, deleted album from web, logged back in. without fix linkedRemoteAlbumId stayed dangling. with fix all three local rows have linkedRemoteAlbumId = NULL after the logout reset, and recovery is clean once manageLinkedAlbums runs again. * fix(mobile): always re-enable foreign_keys in reset() + simplify the update re-enable foreign_keys inside a try/finally so it always runs even if the transaction throws. without this, a failed reset would leave the connection with foreign_keys = OFF and silently disable cascades for everything after (per copilot review). also drop the where filter on the linkedRemoteAlbumId update, unconditional update-all is simpler and we wipe everything in reset anyway (per ganka review). --- .../repositories/sync_stream.repository.dart | 49 +++++++++------- .../sync_stream_repository_test.dart | 56 +++++++++++++++++++ 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index ee859cc2dd..b7593c3202 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; @@ -45,25 +46,35 @@ class SyncStreamRepository extends DriftDatabaseRepository { // foreign_keys PRAGMA is no-op within transactions // https://www.sqlite.org/pragma.html#pragma_foreign_keys await _db.customStatement('PRAGMA foreign_keys = OFF'); - await transaction(() async { - await _db.assetFaceEntity.deleteAll(); - await _db.memoryAssetEntity.deleteAll(); - await _db.memoryEntity.deleteAll(); - await _db.partnerEntity.deleteAll(); - await _db.personEntity.deleteAll(); - await _db.remoteAlbumAssetEntity.deleteAll(); - await _db.remoteAlbumEntity.deleteAll(); - await _db.remoteAlbumUserEntity.deleteAll(); - await _db.remoteAssetEntity.deleteAll(); - await _db.remoteExifEntity.deleteAll(); - await _db.stackEntity.deleteAll(); - await _db.authUserEntity.deleteAll(); - await _db.userEntity.deleteAll(); - await _db.userMetadataEntity.deleteAll(); - await _db.remoteAssetCloudIdEntity.deleteAll(); - await _db.assetEditEntity.deleteAll(); - }); - await _db.customStatement('PRAGMA foreign_keys = ON'); + try { + await transaction(() async { + // FK cascade (ON DELETE SET NULL) does not fire while foreign_keys = OFF, + // so null linkedRemoteAlbumId manually to avoid dangling pointers in local_album_entity. + await _db.localAlbumEntity.update().write( + const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)), + ); + await _db.assetFaceEntity.deleteAll(); + await _db.memoryAssetEntity.deleteAll(); + await _db.memoryEntity.deleteAll(); + await _db.partnerEntity.deleteAll(); + await _db.personEntity.deleteAll(); + await _db.remoteAlbumAssetEntity.deleteAll(); + await _db.remoteAlbumEntity.deleteAll(); + await _db.remoteAlbumUserEntity.deleteAll(); + await _db.remoteAssetEntity.deleteAll(); + await _db.remoteExifEntity.deleteAll(); + await _db.stackEntity.deleteAll(); + await _db.authUserEntity.deleteAll(); + await _db.userEntity.deleteAll(); + await _db.userMetadataEntity.deleteAll(); + await _db.remoteAssetCloudIdEntity.deleteAll(); + await _db.assetEditEntity.deleteAll(); + }); + } finally { + // re-enable FK even if the transaction throws, otherwise the connection + // would be left with foreign_keys = OFF, silently disabling cascades. + await _db.customStatement('PRAGMA foreign_keys = ON'); + } }); } catch (error, stack) { _logger.severe('Error: SyncResetV1', error, stack); diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index 39d6d9156e..4199a5b756 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -1,6 +1,10 @@ import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:openapi/api.dart'; @@ -184,4 +188,56 @@ void main() { expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set'); }); }); + + group('SyncStreamRepository - reset()', () { + test('nulls linkedRemoteAlbumId on localAlbumEntity so FK refs do not dangle', () async { + const localAlbumId = 'local-1'; + const remoteAlbumId = 'remote-1'; + + await db.remoteAlbumEntity.insertOne( + RemoteAlbumEntityCompanion.insert(id: remoteAlbumId, name: 'Movies', order: AlbumAssetOrder.desc), + ); + await db.localAlbumEntity.insertOne( + LocalAlbumEntityCompanion.insert( + id: localAlbumId, + name: 'Movies', + backupSelection: BackupSelection.selected, + linkedRemoteAlbumId: const drift.Value(remoteAlbumId), + ), + ); + + // sanity: link is set before reset + final before = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect(before.linkedRemoteAlbumId, equals(remoteAlbumId)); + + await sut.reset(); + + final after = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect( + after.linkedRemoteAlbumId, + isNull, + reason: + 'reset() runs with PRAGMA foreign_keys = OFF so the ON DELETE SET NULL cascade does not fire — the link must be nulled manually', + ); + expect(after.name, equals('Movies'), reason: 'local album row itself must be preserved'); + expect(after.backupSelection, equals(BackupSelection.selected)); + + final remoteRows = await db.remoteAlbumEntity.select().get(); + expect(remoteRows, isEmpty, reason: 'reset() still wipes remoteAlbumEntity'); + }); + + test('preserves localAlbumEntity rows that have no linkedRemoteAlbumId', () async { + const localAlbumId = 'local-unlinked'; + await db.localAlbumEntity.insertOne( + LocalAlbumEntityCompanion.insert(id: localAlbumId, name: 'Camera', backupSelection: BackupSelection.none), + ); + + await sut.reset(); + + final after = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect(after.linkedRemoteAlbumId, isNull); + expect(after.name, equals('Camera')); + expect(after.backupSelection, equals(BackupSelection.none)); + }); + }); } From 58beac8fe0e788bfea3ac55b8dd40b5badabae9e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 12 May 2026 15:21:04 -0400 Subject: [PATCH 59/71] chore: migrate mobile makefile to mise (#28390) --- .github/workflows/build-mobile.yml | 37 +++++---- .github/workflows/static_analysis.yml | 42 ++++------ .github/workflows/test.yml | 21 +++-- docs/docs/developer/pr-checklist.md | 20 ++--- docs/docs/developer/testing.md | 9 +-- mise.toml | 10 +-- mobile/makefile | 47 +++-------- mobile/mise.toml | 107 +++++++------------------- 8 files changed, 101 insertions(+), 192 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 13427cdc24..f3f254e4be 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -90,6 +90,11 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 + with: + github_token: ${{ steps.token.outputs.token }} + - name: Create the Keystore if: ${{ !github.event.pull_request.head.repo.fork }} env: @@ -114,13 +119,6 @@ jobs: mobile/.dart_tool key: build-mobile-gradle-${{ runner.os }}-main - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 - with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml - cache: true - - name: Setup Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 with: @@ -131,11 +129,10 @@ jobs: run: flutter pub get - name: Generate translation file - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart - working-directory: ./mobile + run: mise //mobile:codegen:translation - name: Generate platform APIs - run: make pigeon + run: mise //mobile:codegen:pigeon working-directory: ./mobile - name: Build Android App Bundle @@ -205,6 +202,12 @@ jobs: runs-on: macos-15 steps: + - id: token + uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0 + with: + client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + - name: Select Xcode 26 run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer @@ -214,24 +217,20 @@ jobs: ref: ${{ inputs.ref || github.sha }} persist-credentials: false - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml - cache: true + github_token: ${{ steps.token.outputs.token }} - name: Install Flutter dependencies working-directory: ./mobile run: flutter pub get - name: Generate translation files - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart - working-directory: ./mobile + run: mise //mobile:codegen:translation - name: Generate platform APIs - run: make pigeon - working-directory: ./mobile + run: mise //mobile:codegen:pigeon - name: Setup Ruby uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0 diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 70a7294424..10642fbd11 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -60,38 +60,30 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml + github_token: ${{ steps.token.outputs.token }} - name: Install dependencies - run: dart pub get + run: flutter pub get - name: Install dependencies for UI package - run: dart pub get + run: flutter pub get working-directory: ./mobile/packages/ui - name: Install dependencies for UI Showcase - run: dart pub get + run: flutter pub get working-directory: ./mobile/packages/ui/showcase - - name: Install DCM - uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1 - with: - github-token: ${{ steps.token.outputs.token }} - version: auto - working-directory: ./mobile - - - name: Generate translation file - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart + - name: Generate translation files + run: mise //mobile:codegen:translation - name: Run Build Runner - run: make build + run: mise //mobile:codegen:dart - name: Generate platform API - run: make pigeon + run: mise //mobile:codegen:pigeon - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 @@ -107,20 +99,16 @@ jobs: env: CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }} run: | - echo "ERROR: Generated files not up to date! Run 'make build' and 'make pigeon' inside the mobile directory" + echo "ERROR: Generated files not up to date! Run 'mise //mobile:codegen:dart' and 'mise //mobile:codegen:pigeon'" echo "Changed files: ${CHANGED_FILES}" exit 1 - - name: Run dart analyze - run: dart analyze --fatal-infos + - name: Run analyze + run: mise //mobile:analyze - - name: Run dart format - run: make format + - name: Run format + run: mise //mobile:format # TODO: Re-enable after upgrading custom_lint # - name: Run dart custom_lint # run: dart run custom_lint - - # TODO: Use https://github.com/CQLabs/dcm-action - - name: Run DCM - run: dcm analyze lib --fatal-style --fatal-warnings diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18a88bd617..4656ff2646 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -551,17 +551,22 @@ jobs: with: persist-credentials: false token: ${{ steps.token.outputs.token }} - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml - - name: Generate translation file - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart + github_token: ${{ steps.token.outputs.token }} + + - name: Install dependencies + run: flutter pub get working-directory: ./mobile + + - name: Generate translation files + run: mise //mobile:codegen:translation + - name: Run tests - working-directory: ./mobile - run: flutter test -j 1 + run: mise //mobile:test -j 1 + ml-unit-tests: name: Unit Test ML needs: pre-job diff --git a/docs/docs/developer/pr-checklist.md b/docs/docs/developer/pr-checklist.md index e5dc6cc1e5..c4ed44c77b 100644 --- a/docs/docs/developer/pr-checklist.md +++ b/docs/docs/developer/pr-checklist.md @@ -34,21 +34,23 @@ Run all web checks with `pnpm run check:all` Run all server checks with `pnpm run check:all` ::: -:::info Auto Fix +:::tip Auto Fix You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`. ::: -## Mobile Checks +## Mobile Checklist -The following commands must be executed from within the mobile app directory of the codebase. +- [ ] `mise //mobile:codegen` (auto-generate files using build_runner) +- [ ] `mise //mobile:lint` (static analysis via Dart Analyzer and DCM) +- [ ] `mise //mobile:format` (formatting via Dart Formatter) +- [ ] `mise //mobile:test` (unit tests) -- [ ] `make build` (auto-generate files using build_runner) -- [ ] `make analyze` (static analysis via Dart Analyzer and DCM) -- [ ] `make format` (formatting via Dart Formatter) -- [ ] `make test` (unit tests) +:::tip +Run all these commands at once with `mise //mobile:checklist` +::: -:::info Auto Fix -You can use `dart fix --apply` and `dcm fix lib` to potentially correct some issues automatically for `make analyze`. +:::tip Auto Fix +You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`. ::: ## OpenAPI diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index d7c9edcd31..219c33d1a1 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -17,15 +17,14 @@ make e2e Before you can run the tests, you need to run the following commands _once_: -- `pnpm install` (in `e2e/`) -- `pnpm run build` (in `cli/`) -- `make open-api` (in the project root `/`) +- `pnpm install` +- `pnpm --filter "@immich/*" build` +- `mise //:open-api` Once the test environment is running, the e2e tests can be run via: ```bash -cd e2e/ -pnpm test +mise //e2e:test ``` The tests check various things including: diff --git a/mise.toml b/mise.toml index a88e3bb2b1..f190490f17 100644 --- a/mise.toml +++ b/mise.toml @@ -69,17 +69,15 @@ run = "node ./dist/bin/sync-sql.js" # SDK tasks [tasks."sdk:install"] dir = "packages/sdk" -run = "pnpm install --filter @immich/sdk --frozen-lockfile" +run = "pnpm --filter @immich/sdk install --frozen-lockfile" [tasks."sdk:build"] dir = "packages/sdk" -run = "pnpm run build" +run = "pnpm build" # i18n tasks [tasks."i18n:format"] -dir = "i18n" -run = "pnpm run format" +run = "pnpm format" [tasks."i18n:format-fix"] -dir = "i18n" -run = "pnpm run format:fix" +run = "pnpm format:fix" diff --git a/mobile/makefile b/mobile/makefile index 3a0a263687..5a21287b85 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -1,55 +1,26 @@ -.PHONY: build watch create_app_icon create_splash build_release_android pigeon test analyze format +.PHONY: build watch create_app_icon create_splash build_release_android pigeon test analyze format migration translation build: - dart run build_runner build --delete-conflicting-outputs -# Remove once auto_route updated to 10.1.0 - dart format lib/routing/router.gr.dart + @printf "This command has been removed. Please use:\n\n mise codegen # or mise //:mobile:codegen:dart from another directory\n\n" >&2 && exit 1 pigeon: - dart run pigeon --input pigeon/native_sync_api.dart - dart run pigeon --input pigeon/local_image_api.dart - dart run pigeon --input pigeon/remote_image_api.dart - dart run pigeon --input pigeon/background_worker_api.dart - dart run pigeon --input pigeon/background_worker_lock_api.dart - dart run pigeon --input pigeon/connectivity_api.dart - dart run pigeon --input pigeon/network_api.dart - dart format lib/platform/native_sync_api.g.dart - dart format lib/platform/local_image_api.g.dart - dart format lib/platform/remote_image_api.g.dart - dart format lib/platform/background_worker_api.g.dart - dart format lib/platform/background_worker_lock_api.g.dart - dart format lib/platform/connectivity_api.g.dart - dart format lib/platform/network_api.g.dart + @printf "This command has been removed. Please use:\n\n mise pigeon # or mise //:mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1 -watch: - dart run build_runner watch --delete-conflicting-outputs - -create_app_icon: - flutter pub run flutter_launcher_icons:main - -create_splash: - flutter pub run flutter_native_splash:create build_release_android: - flutter build appbundle + @printf "This command has been removed. Please use:\n\n mise run build:android # or mise //:mobile:build:android from another directory\n\n" >&2 && exit 1 migration: - dart run drift_dev make-migrations + @printf "This command has been removed. Please use:\n\n mise migration # or mise //:mobile:drift:migration from another directory\n\n" >&2 && exit 1 translation: - pnpm --prefix ../i18n run format:fix - dart run easy_localization:generate -S ../i18n - dart run bin/generate_keys.dart - dart format lib/generated/codegen_loader.g.dart - dart format lib/generated/translations.g.dart + @printf "This command has been removed. Please use:\n\n mise translation # or mise //:mobile:codegen:translation from another directory\n\n" >&2 && exit 1 analyze: - dart analyze --fatal-infos - dcm analyze lib --fatal-style --fatal-warnings + @printf "This command has been removed. Please use:\n\n mise analyze # or mise //:mobile:lint from another directory\n\n" >&2 && exit 1 format: -# Ignore generated files manually until https://github.com/dart-lang/dart_style/issues/864 is resolved - dart format --set-exit-if-changed $$(find lib -name '*.dart' -not \( -name 'generated_plugin_registrant.dart' -o -name '*.g.dart' -o -name '*.drift.dart' \)) + @printf "This command has been removed. Please use:\n\n mise format # or mise //:mobile:format from another directory\n\n" >&2 && exit 1 test: - flutter test + @printf "This command has been removed. Please use:\n\n mise test # or mise //:mobile:test from another directory\n\n" >&2 && exit 1 diff --git a/mobile/mise.toml b/mobile/mise.toml index ed928f2445..89a9f0035c 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -29,12 +29,15 @@ run = "dart run build_runner watch --delete-conflicting-outputs" [tasks."codegen:pigeon"] alias = "pigeon" description = "Generate pigeon platform code" -depends = [ - "pigeon:native-sync", - "pigeon:thumbnail", - "pigeon:background-worker", - "pigeon:background-worker-lock", - "pigeon:connectivity", +run = [ + "dart run pigeon --input pigeon/native_sync_api.dart", + "dart run pigeon --input pigeon/local_image_api.dart", + "dart run pigeon --input pigeon/remote_image_api.dart", + "dart run pigeon --input pigeon/background_worker_api.dart", + "dart run pigeon --input pigeon/background_worker_lock_api.dart", + "dart run pigeon --input pigeon/connectivity_api.dart", + "dart run pigeon --input pigeon/network_api.dart", + "dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart", ] [tasks."codegen:translation"] @@ -60,13 +63,15 @@ run = "flutter pub run flutter_native_splash:create" description = "Run mobile tests" run = "flutter test" -[tasks.lint] +[tasks.analyze] +alias = "lint" description = "Analyze Dart code" depends = ["analyze:dart", "analyze:dcm"] -[tasks."lint-fix"] +[tasks."analyze-fix"] +alias = "lint-fix" description = "Auto-fix Dart code" -depends = ["analyze:fix:dart", "analyze:fix:dcm"] +depends = ["analyze-fix:dart", "analyze-fix:dcm"] [tasks.format] description = "Format Dart code" @@ -83,75 +88,6 @@ run = "dart run drift_dev make-migrations" # Internal tasks -[tasks."pigeon:native-sync"] -description = "Generate native sync API pigeon code" -hide = true -sources = ["pigeon/native_sync_api.dart"] -outputs = [ - "lib/platform/native_sync_api.g.dart", - "ios/Runner/Sync/Messages.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt", -] -run = [ - "dart run pigeon --input pigeon/native_sync_api.dart", - "dart format lib/platform/native_sync_api.g.dart", -] - -[tasks."pigeon:thumbnail"] -description = "Generate thumbnail API pigeon code" -hide = true -sources = ["pigeon/thumbnail_api.dart"] -outputs = [ - "lib/platform/thumbnail_api.g.dart", - "ios/Runner/Images/Thumbnails.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt", -] -run = [ - "dart run pigeon --input pigeon/thumbnail_api.dart", - "dart format lib/platform/thumbnail_api.g.dart", -] - -[tasks."pigeon:background-worker"] -description = "Generate background worker API pigeon code" -hide = true -sources = ["pigeon/background_worker_api.dart"] -outputs = [ - "lib/platform/background_worker_api.g.dart", - "ios/Runner/Background/BackgroundWorker.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt", -] -run = [ - "dart run pigeon --input pigeon/background_worker_api.dart", - "dart format lib/platform/background_worker_api.g.dart", -] - -[tasks."pigeon:background-worker-lock"] -description = "Generate background worker lock API pigeon code" -hide = true -sources = ["pigeon/background_worker_lock_api.dart"] -outputs = [ - "lib/platform/background_worker_lock_api.g.dart", - "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt", -] -run = [ - "dart run pigeon --input pigeon/background_worker_lock_api.dart", - "dart format lib/platform/background_worker_lock_api.g.dart", -] - -[tasks."pigeon:connectivity"] -description = "Generate connectivity API pigeon code" -hide = true -sources = ["pigeon/connectivity_api.dart"] -outputs = [ - "lib/platform/connectivity_api.g.dart", - "ios/Runner/Connectivity/Connectivity.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt", -] -run = [ - "dart run pigeon --input pigeon/connectivity_api.dart", - "dart format lib/platform/connectivity_api.g.dart", -] - [tasks."i18n:loader"] description = "Generate i18n loader" hide = true @@ -182,12 +118,23 @@ description = "Run Dart Code Metrics" hide = true run = "dcm analyze lib --fatal-style --fatal-warnings" -[tasks."analyze:fix:dart"] +[tasks."analyze-fix:dart"] description = "Auto-fix Dart analysis" hide = true run = "dart fix --apply" -[tasks."analyze:fix:dcm"] +[tasks."analyze-fix:dcm"] description = "Auto-fix Dart Code Metrics" hide = true run = "dcm fix lib" + + +[tasks.checklist] +run = [ + {task = "codegen:pigeon" }, + {task = "codegen:dart" }, + {task = "codegen:translation" }, + {task = "analyze" }, + {task = "format" }, + {task = "test" }, +] From 91ac56cef285561015385f2babe2618a487fadb4 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 02:53:25 +0700 Subject: [PATCH 60/71] refactor: move timeline config to metadata table (#28227) * migrate timeline config --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/config/app_config.dart | 27 ++++++++++++---- .../domain/models/config/timeline_config.dart | 30 +++++++++++++++++ mobile/lib/domain/models/metadata_key.dart | 11 +++++++ mobile/lib/domain/models/setting.model.dart | 3 -- mobile/lib/domain/models/store.model.dart | 32 ++++--------------- .../domain/services/local_sync.service.dart | 3 +- .../lib/domain/services/timeline.service.dart | 15 +++++---- .../repositories/metadata.repository.dart | 5 +++ .../repositories/sync_api.repository.dart | 7 ---- .../widgets/images/thumbnail_tile.widget.dart | 7 ++-- .../widgets/timeline/timeline.state.dart | 7 ++-- .../widgets/timeline/timeline.widget.dart | 10 +++--- .../infrastructure/timeline.provider.dart | 4 +-- mobile/lib/services/app_settings.service.dart | 14 -------- mobile/lib/utils/migration.dart | 9 ++++++ .../asset_list_group_settings.dart | 16 +++++----- .../asset_list_layout_settings.dart | 14 +++++--- .../asset_list_settings.dart | 12 ++++--- .../image_viewer_quality_setting.dart | 9 +----- .../settings/notification_setting.dart | 15 --------- .../domain/services/store_service_test.dart | 8 ++--- 21 files changed, 135 insertions(+), 123 deletions(-) create mode 100644 mobile/lib/domain/models/config/timeline_config.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index ed6254e168..956d9fd100 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,25 +1,40 @@ import 'package:immich_mobile/domain/models/config/cleanup_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; +import 'package:immich_mobile/domain/models/config/timeline_config.dart'; class AppConfig { final ThemeConfig theme; final CleanupConfig cleanup; final MapConfig map; + final TimelineConfig timeline; - const AppConfig({this.theme = const .new(), this.cleanup = const .new(), this.map = const .new()}); + const AppConfig({ + this.theme = const .new(), + this.cleanup = const .new(), + this.map = const .new(), + this.timeline = const .new(), + }); - AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map}) => - .new(theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, map: map ?? this.map); + AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .new( + theme: theme ?? this.theme, + cleanup: cleanup ?? this.cleanup, + map: map ?? this.map, + timeline: timeline ?? this.timeline, + ); @override bool operator ==(Object other) => identical(this, other) || - (other is AppConfig && other.theme == theme && other.cleanup == cleanup && other.map == map); + (other is AppConfig && + other.theme == theme && + other.cleanup == cleanup && + other.map == map && + other.timeline == timeline); @override - int get hashCode => Object.hash(theme, cleanup, map); + int get hashCode => Object.hash(theme, cleanup, map, timeline); @override - String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map)'; + String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)'; } diff --git a/mobile/lib/domain/models/config/timeline_config.dart b/mobile/lib/domain/models/config/timeline_config.dart new file mode 100644 index 0000000000..4b6b9d5625 --- /dev/null +++ b/mobile/lib/domain/models/config/timeline_config.dart @@ -0,0 +1,30 @@ +import 'package:immich_mobile/domain/models/timeline.model.dart'; + +class TimelineConfig { + final int tilesPerRow; + final GroupAssetsBy groupAssetsBy; + final bool storageIndicator; + + const TimelineConfig({this.tilesPerRow = 4, this.groupAssetsBy = GroupAssetsBy.day, this.storageIndicator = true}); + + TimelineConfig copyWith({int? tilesPerRow, GroupAssetsBy? groupAssetsBy, bool? storageIndicator}) => TimelineConfig( + tilesPerRow: tilesPerRow ?? this.tilesPerRow, + groupAssetsBy: groupAssetsBy ?? this.groupAssetsBy, + storageIndicator: storageIndicator ?? this.storageIndicator, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TimelineConfig && + other.tilesPerRow == tilesPerRow && + other.groupAssetsBy == groupAssetsBy && + other.storageIndicator == storageIndicator); + + @override + int get hashCode => Object.hash(tilesPerRow, groupAssetsBy, storageIndicator); + + @override + String toString() => + 'TimelineConfig(tilesPerRow: $tilesPerRow, groupAssetsBy: $groupAssetsBy, storageIndicator: $storageIndicator)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 00b5a5f675..0d75ec8be3 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; enum MetadataDomain { appConfig('config.app'), @@ -23,6 +24,16 @@ enum MetadataKey { themeDynamic(.appConfig, 'theme.dynamic', false), themeColorfulInterface(.appConfig, 'theme.colorfulInterface', true), + // Timeline + timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4), + timelineGroupAssetsBy( + .appConfig, + 'timeline.groupAssetsBy', + GroupAssetsBy.day, + _EnumCodec(GroupAssetsBy.values), + ), + timelineStorageIndicator(.appConfig, 'timeline.storageIndicator', true), + // Log logLevel(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)), diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index 2c46507331..de50ec22f4 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -1,9 +1,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { - tilesPerRow(StoreKey.tilesPerRow, 4), - groupAssetsBy(StoreKey.groupAssetsBy, 0), - showStorageIndicator(StoreKey.storageIndicator, true), loadOriginal(StoreKey.loadOriginal, false), loadOriginalVideo(StoreKey.loadOriginalVideo, false), autoPlayVideo(StoreKey.autoPlayVideo, true), diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 125866a4ee..2720c5d3e6 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -19,29 +19,17 @@ enum StoreKey { backgroundBackup._(14), sslClientCertData._(15), sslClientPasswd._(16), - // user settings from [AppSettingsEnum] below: - loadPreview._(100), - loadOriginal._(101), - tilesPerRow._(103), - dynamicLayout._(104), - groupAssetsBy._(105), uploadErrorNotificationGracePeriod._(106), - backgroundBackupTotalProgress._(107), - backgroundBackupSingleProgress._(108), - storageIndicator._(109), thumbnailCacheSize._(110), imageCacheSize._(111), albumThumbnailCacheSize._(112), selectedAlbumSortOrder._(113), advancedTroubleshooting._(114), preferRemoteImage._(116), - loopVideo._(117), selfSignedCert._(120), - ignoreIcloudAssets._(122), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), customHeaders._(127), - syncAlbums._(131), // Auto endpoint switching @@ -50,31 +38,22 @@ enum StoreKey { localEndpoint._(134), externalEndpointList._(135), - // Video settings - loadOriginalVideo._(136), manageLocalMediaAndroid._(137), - // Read-only Mode settings readonlyModeEnabled._(138), - - autoPlayVideo._(139), albumGridView._(140), + loadOriginal._(101), // Image viewer navigation settings + loopVideo._(117), + loadOriginalVideo._(136), + autoPlayVideo._(139), tapToNavigate._(141), // Experimental stuff - photoManagerCustomFilter._(1000), - betaPromptShown._(1001), - betaTimeline._(1002), enableBackup._(1003), useWifiForUploadVideos._(1004), useWifiForUploadPhotos._(1005), - needBetaMigration._(1006), - // TODO: Remove this after patching open-api - shouldResetSync._(1007), - - // Free up space syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store @@ -87,6 +66,9 @@ enum StoreKey { legacyCleanupKeepAlbumIds._(1010), legacyCleanupCutoffDaysAgo._(1011), legacyCleanupDefaultsInitialized._(1012), + legacyTilesPerRow._(103), + legacyGroupAssetsBy._(105), + legacyStorageIndicator._(109), legacyMapRelativeDate._(119), legacyMapShowFavoriteOnly._(118), legacyMapIncludeArchived._(121), diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 1d9ab1e490..34300dee3d 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -93,8 +93,7 @@ class LocalSyncService { if (CurrentPlatform.isIOS) { // On iOS, we need to full sync albums that are marked as cloud as the delta sync - // does not include changes for cloud albums. If ignoreIcloudAssets is enabled, - // remove the albums from the local database from the previous sync + // does not include changes for cloud albums. final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums(); for (final album in cloudAlbums) { final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id); diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 26e22190f4..5779ee1053 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -5,10 +5,9 @@ import 'package:collection/collection.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; -import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; @@ -40,14 +39,16 @@ enum TimelineOrigin { class TimelineFactory { final DriftTimelineRepository _timelineRepository; - final SettingsService _settingsService; + final MetadataRepository _metadataRepository; - const TimelineFactory({required DriftTimelineRepository timelineRepository, required SettingsService settingsService}) - : _timelineRepository = timelineRepository, - _settingsService = settingsService; + const TimelineFactory({ + required DriftTimelineRepository timelineRepository, + required MetadataRepository metadataRepository, + }) : _timelineRepository = timelineRepository, + _metadataRepository = metadataRepository; GroupAssetsBy get groupBy { - final group = GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)]; + final group = _metadataRepository.appConfig.timeline.groupAssetsBy; // We do not support auto grouping in the new timeline yet, fallback to day grouping return group == GroupAssetsBy.auto ? GroupAssetsBy.day : group; } diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index 2f76ee22ae..676842c8ea 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -127,6 +127,11 @@ extension on MetadataDomain { themeMode: repo._read(.mapThemeMode), withPartners: repo._read(.mapWithPartners), ), + timeline: .new( + tilesPerRow: repo._read(.timelineTilesPerRow), + groupAssetsBy: repo._read(.timelineGroupAssetsBy), + storageIndicator: repo._read(.timelineStorageIndicator), + ), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 366803ee31..d9d262e64f 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -3,9 +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/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/semver.dart'; @@ -38,7 +36,6 @@ class SyncApiRepository { final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; - final shouldReset = Store.get(StoreKey.shouldResetSync, false); final request = http.Request('POST', Uri.parse(endpoint)); request.headers.addAll(headers); request.body = jsonEncode( @@ -77,7 +74,6 @@ class SyncApiRepository { ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1, ], - reset: shouldReset, ).toJson(), ); @@ -101,9 +97,6 @@ class SyncApiRepository { throw ApiException(response.statusCode, 'Failed to get sync stream: $errorBody'); } - // Reset after successful stream start - await Store.put(StoreKey.shouldResetSync, false); - await for (final chunk in response.stream.transform(utf8.decoder)) { if (shouldAbort) { break; diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 406ca30820..8720cc4253 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -2,15 +2,14 @@ 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/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class ThumbnailTile extends ConsumerStatefulWidget { @@ -61,7 +60,7 @@ class _ThumbnailTileState extends ConsumerState { ); final bool storageIndicator = - ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator; + ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator)) && widget.showStorageIndicator; if (!isCurrentAsset) { _hideIndicators = false; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 1e1d4130f7..7b88800f22 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -1,12 +1,11 @@ import 'dart:math' as math; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; class TimelineArgs { @@ -93,7 +92,7 @@ final timelineSegmentProvider = StreamProvider.autoDispose>((ref) final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); final tileExtent = math.max(0, availableTileWidth) / columnCount; - final groupBy = args.groupBy ?? GroupAssetsBy.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; + final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy)); final timelineService = ref.watch(timelineServiceProvider); yield* timelineService.watchBuckets().map((buckets) { @@ -102,7 +101,7 @@ final timelineSegmentProvider = StreamProvider.autoDispose>((ref) tileHeight: tileExtent, columnCount: columnCount, spacing: spacing, - groupBy: groupBy, + groupBy: groupBy!, ).generate(); }); }, dependencies: [timelineServiceProvider, timelineArgsProvider]); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 6368bbeeea..8974b20d1a 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -10,7 +10,7 @@ import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; @@ -22,8 +22,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; @@ -74,7 +74,7 @@ class Timeline extends StatelessWidget { (ref) => TimelineArgs( maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight, - columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), + columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)), showStorageIndicator: showStorageIndicator, withStack: withStack, groupBy: groupBy, @@ -161,7 +161,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _scrollController = ScrollController(onAttach: _restoreAssetPosition); _eventSubscription = EventStream.shared.listen(_onEvent); - final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); + final currentTilesPerRow = ref.read(appConfigProvider.select((config) => config.timeline.tilesPerRow)); _perRow = currentTilesPerRow; _scaleFactor = 7.0 - _perRow; _baseScaleFactor = _scaleFactor; @@ -459,7 +459,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _restoreAssetIndex = targetAssetIndex; }); - ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); + ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow); } }; }, diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index 06ec0242b2..9f2fdec519 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; final timelineRepositoryProvider = Provider( @@ -29,7 +29,7 @@ final timelineServiceProvider = Provider( final timelineFactoryProvider = Provider( (ref) => TimelineFactory( timelineRepository: ref.watch(timelineRepositoryProvider), - settingsService: ref.watch(settingsProvider), + metadataRepository: ref.watch(metadataProvider), ), ); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index cbb0745275..a98912228c 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -2,23 +2,12 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { - loadPreview(StoreKey.loadPreview, "loadPreview", true), loadOriginal(StoreKey.loadOriginal, "loadOriginal", false), - tilesPerRow(StoreKey.tilesPerRow, "tilesPerRow", 4), - dynamicLayout(StoreKey.dynamicLayout, "dynamicLayout", false), - groupAssetsBy(StoreKey.groupAssetsBy, "groupBy", 0), uploadErrorNotificationGracePeriod( StoreKey.uploadErrorNotificationGracePeriod, "uploadErrorNotificationGracePeriod", 2, ), - backgroundBackupTotalProgress(StoreKey.backgroundBackupTotalProgress, "backgroundBackupTotalProgress", true), - backgroundBackupSingleProgress( - StoreKey.backgroundBackupSingleProgress, - "backgroundBackupSingleProgress", - false, - ), - storageIndicator(StoreKey.storageIndicator, "storageIndicator", true), thumbnailCacheSize(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000), imageCacheSize(StoreKey.imageCacheSize, "imageCacheSize", 350), albumThumbnailCacheSize(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200), @@ -31,13 +20,10 @@ enum AppSettingsEnum { autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), tapToNavigate(StoreKey.tapToNavigate, "tapToNavigate", false), allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), - ignoreIcloudAssets(StoreKey.ignoreIcloudAssets, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), - photoManagerCustomFilter(StoreKey.photoManagerCustomFilter, null, true), - betaTimeline(StoreKey.betaTimeline, null, true), enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index a0397e5ca4..07df7958a1 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -79,6 +80,14 @@ Future _migrateTo26(Drift drift) async { await migrator.migrateBool(StoreKey.legacyMapIncludeArchived, MetadataKey.mapIncludeArchived); await migrator.migrateEnumIndex(StoreKey.legacyMapThemeMode, MetadataKey.mapThemeMode, ThemeMode.values); await migrator.migrateBool(StoreKey.legacyMapwithPartners, MetadataKey.mapWithPartners); + // Timeline + await migrator.migrateInt(StoreKey.legacyTilesPerRow, MetadataKey.timelineTilesPerRow); + await migrator.migrateEnumIndex( + StoreKey.legacyGroupAssetsBy, + MetadataKey.timelineGroupAssetsBy, + GroupAssetsBy.values, + ); + await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator); await migrator.complete(); } diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart index 42ea3acfc0..b9f81da79e 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; @@ -15,18 +16,17 @@ class GroupSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final groupByIndex = useAppSettingsState(AppSettingsEnum.groupAssetsBy); - final groupBy = GroupAssetsBy.values[groupByIndex.value]; + final groupBy = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.groupAssetsBy))); Future updateAppSettings(GroupAssetsBy groupBy) async { - await ref.watch(appSettingsServiceProvider).setSetting(AppSettingsEnum.groupAssetsBy, groupBy.index); + await ref.read(metadataProvider).write(MetadataKey.timelineGroupAssetsBy, groupBy); ref.invalidate(appSettingsServiceProvider); } void changeGroupValue(GroupAssetsBy? value) { if (value != null) { - groupByIndex.value = value.index; - unawaited(updateAppSettings(groupBy)); + groupBy.value = value; + unawaited(updateAppSettings(value)); } } @@ -52,7 +52,7 @@ class GroupSettings extends HookConsumerWidget { value: GroupAssetsBy.auto, ), ], - groupBy: groupBy, + groupBy: groupBy.value, onRadioChanged: changeGroupValue, ), ], 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 55c8195947..20025286f4 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 @@ -1,10 +1,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; @@ -13,7 +14,10 @@ class LayoutSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tilesPerRow = useAppSettingsState(AppSettingsEnum.tilesPerRow); + final tilesPerRow = useState(ref.read(appConfigProvider.select((s) => s.timeline.tilesPerRow))); + useValueChanged(tilesPerRow.value, (_, __) { + ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, tilesPerRow.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -29,7 +33,9 @@ class LayoutSettings extends HookConsumerWidget { maxValue: 6, minValue: 2, noDivisons: 4, - onChangeEnd: (_) => ref.invalidate(appSettingsServiceProvider), + onChangeEnd: (value) { + ref.invalidate(appSettingsServiceProvider); + }, ), ], ); diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 82394bdc07..21d751c26f 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -1,10 +1,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; @@ -15,13 +16,14 @@ class AssetListSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final showStorageIndicator = useAppSettingsState(AppSettingsEnum.storageIndicator); + final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator))); final assetListSetting = [ SettingsSwitchListTile( - valueNotifier: showStorageIndicator, + valueNotifier: storageIndicator, title: 'theme_setting_asset_list_storage_indicator_title'.tr(), - onChanged: (_) { + onChanged: (value) { + ref.read(metadataProvider).write(MetadataKey.timelineStorageIndicator, value); ref.invalidate(appSettingsServiceProvider); ref.invalidate(settingsProvider); }, 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 e437b82dd4..66c28be22d 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 @@ -3,16 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class ImageViewerQualitySetting extends HookConsumerWidget { const ImageViewerQualitySetting({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final isPreview = useAppSettingsState(AppSettingsEnum.loadPreview); final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal); return Column( @@ -23,12 +22,6 @@ class ImageViewerQualitySetting extends HookConsumerWidget { icon: Icons.image_outlined, subtitle: "setting_image_viewer_help".t(context: context), ), - SettingsSwitchListTile( - valueNotifier: isPreview, - title: "setting_image_viewer_preview_title".t(context: context), - subtitle: "setting_image_viewer_preview_subtitle".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), - ), SettingsSwitchListTile( valueNotifier: isOriginal, title: "setting_image_viewer_original_title".t(context: context), diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index d9eab26bda..18a9749a71 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -8,7 +8,6 @@ import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; -import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:permission_handler/permission_handler.dart'; class NotificationSetting extends HookConsumerWidget { @@ -19,8 +18,6 @@ class NotificationSetting extends HookConsumerWidget { final permissionService = ref.watch(notificationPermissionProvider); final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod); - final totalProgressValue = useAppSettingsState(AppSettingsEnum.backgroundBackupTotalProgress); - final singleProgressValue = useAppSettingsState(AppSettingsEnum.backgroundBackupSingleProgress); final hasPermission = permissionService == PermissionStatus.granted; @@ -60,18 +57,6 @@ class NotificationSetting extends HookConsumerWidget { } }), ), - SettingsSwitchListTile( - enabled: hasPermission, - valueNotifier: totalProgressValue, - title: 'setting_notifications_total_progress_title'.tr(), - subtitle: 'setting_notifications_total_progress_subtitle'.tr(), - ), - SettingsSwitchListTile( - enabled: hasPermission, - valueNotifier: singleProgressValue, - title: 'setting_notifications_single_progress_title'.tr(), - subtitle: 'setting_notifications_single_progress_subtitle'.tr(), - ), SettingsSliderListTile( enabled: hasPermission, valueNotifier: sliderValue, diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index 8ceb1e3c9c..9f6a30eefe 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -10,7 +10,7 @@ import '../../infrastructure/repository.mock.dart'; const _kAccessToken = '#ThisIsAToken'; const _kBackgroundBackup = false; -const _kGroupAssetsBy = 2; +const _kVersion = 2; final _kBackupFailedSince = DateTime.utc(2023); void main() { @@ -31,7 +31,7 @@ void main() { (_) async => [ const StoreDto(StoreKey.accessToken, _kAccessToken), const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup), - const StoreDto(StoreKey.groupAssetsBy, _kGroupAssetsBy), + const StoreDto(StoreKey.version, _kVersion), StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince), ], ); @@ -50,7 +50,7 @@ void main() { verify(() => mockDriftStoreRepo.getAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); - expect(sut.tryGet(StoreKey.groupAssetsBy), _kGroupAssetsBy); + expect(sut.tryGet(StoreKey.version), _kVersion); expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); // Other keys should be null expect(sut.tryGet(StoreKey.currentUser), isNull); @@ -152,7 +152,7 @@ void main() { verify(() => mockDriftStoreRepo.deleteAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), isNull); expect(sut.tryGet(StoreKey.backgroundBackup), isNull); - expect(sut.tryGet(StoreKey.groupAssetsBy), isNull); + expect(sut.tryGet(StoreKey.version), isNull); expect(sut.tryGet(StoreKey.backupFailedSince), isNull); }); }); From 3e1c8aacb19f1453737ea394fa10a3156e191843 Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 12 May 2026 21:56:19 +0200 Subject: [PATCH 61/71] feat(mobile): trash/restore all (#28116) * feat(mobile): trash/restore all * chore: remove themeData variable * chore: filter query by user * refactor --------- Co-authored-by: Alex --- .../repositories/remote_asset.repository.dart | 10 ++ .../presentation/pages/drift_trash.page.dart | 92 +++++++++++++++++++ .../infrastructure/action.provider.dart | 20 ++++ .../repositories/asset_api.repository.dart | 10 ++ mobile/lib/services/action.service.dart | 12 +++ 5 files changed, 144 insertions(+) diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 6d19d17931..7d4e23c22b 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -164,6 +164,16 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); } + Future emptyTrash(String ownerId) async { + await _db.remoteAssetEntity.deleteWhere((t) => t.deletedAt.isNotNull() & t.ownerId.equals(ownerId)); + } + + Future restoreAllTrash(String ownerId) async { + await (_db.remoteAssetEntity.update()..where((t) => t.deletedAt.isNotNull() & t.ownerId.equals(ownerId))).write( + const RemoteAssetEntityCompanion(deletedAt: Value(null)), + ); + } + Future delete(List ids) { return _db.batch((batch) { for (final id in ids) { diff --git a/mobile/lib/presentation/pages/drift_trash.page.dart b/mobile/lib/presentation/pages/drift_trash.page.dart index a85f69a75e..d21b437efe 100644 --- a/mobile/lib/presentation/pages/drift_trash.page.dart +++ b/mobile/lib/presentation/pages/drift_trash.page.dart @@ -1,13 +1,18 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; @RoutePage() class DriftTrashPage extends StatelessWidget { @@ -36,6 +41,7 @@ class DriftTrashPage extends StatelessWidget { pinned: true, centerTitle: true, elevation: 0, + actions: [const _TrashKebabMenu()], ), topSliverWidgetHeight: 24, topSliverWidget: Consumer( @@ -53,3 +59,89 @@ class DriftTrashPage extends StatelessWidget { ); } } + +class _TrashKebabMenu extends ConsumerWidget { + const _TrashKebabMenu(); + + Future _confirmAndRun( + BuildContext context, + WidgetRef ref, { + required String title, + required String content, + required Future Function(String userId) action, + required String Function(int count) successMsg, + }) async { + await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: title, + content: content, + onOk: () async { + final user = ref.read(currentUserProvider); + if (user == null) { + return; + } + final result = await action(user.id); + if (!context.mounted) { + return; + } + ImmichToast.show( + context: context, + msg: result.success ? successMsg(result.count) : context.t.scaffold_body_error_occurred, + toastType: result.success ? ToastType.success : ToastType.error, + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuAnchor( + consumeOutsideTap: true, + style: MenuStyle( + backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), + surfaceTintColor: const WidgetStatePropertyAll(Colors.grey), + elevation: const WidgetStatePropertyAll(4), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)), + ), + menuChildren: [ + BaseActionButton( + label: context.t.empty_trash, + iconData: Icons.delete_forever_outlined, + onPressed: () => _confirmAndRun( + context, + ref, + title: context.t.empty_trash, + content: context.t.empty_trash_confirmation, + action: ref.read(actionProvider.notifier).emptyTrash, + successMsg: (count) => context.t.assets_permanently_deleted_count(count: count), + ), + menuItem: true, + ), + BaseActionButton( + label: context.t.restore_all, + iconData: Icons.restore_outlined, + onPressed: () => _confirmAndRun( + context, + ref, + title: context.t.restore_all, + content: context.t.assets_restore_confirmation, + action: ref.read(actionProvider.notifier).restoreAllTrash, + successMsg: (count) => context.t.assets_restored_count(count: count), + ), + menuItem: true, + ), + ], + builder: (context, controller, child) { + return IconButton( + icon: const Icon(Icons.more_vert_rounded), + onPressed: () => controller.isOpen ? controller.close() : controller.open(), + ); + }, + ); + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 0e402575a0..8b3dd7d73e 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -239,6 +239,26 @@ class ActionNotifier extends Notifier { } } + Future emptyTrash(String userId) async { + try { + final count = await _service.emptyTrash(userId); + return ActionResult(count: count, success: true); + } catch (error, stack) { + _logger.severe('Failed to empty trash', error, stack); + return ActionResult(count: 0, success: false, error: error.toString()); + } + } + + Future restoreAllTrash(String userId) async { + try { + final count = await _service.restoreAllTrash(userId); + return ActionResult(count: count, success: true); + } catch (error, stack) { + _logger.severe('Failed to restore all trash assets', error, stack); + return ActionResult(count: 0, success: false, error: error.toString()); + } + } + Future trashRemoteAndDeleteLocal(ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); final localIds = _getLocalIdsForSource(source); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 2943177d60..fdb4e3323b 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -31,6 +31,16 @@ class AssetApiRepository extends ApiRepository { await _trashApi.restoreAssets(BulkIdsDto(ids: ids)); } + Future emptyTrash() async { + final response = await _trashApi.emptyTrash(); + return response?.count ?? 0; + } + + Future restoreAllTrash() async { + final response = await _trashApi.restoreTrash(); + return response?.count ?? 0; + } + Future updateVisibility(List ids, AssetVisibilityEnum visibility) async { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility))); } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 44b070e954..4e51c32f97 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -108,6 +108,18 @@ class ActionService { await _remoteAssetRepository.restoreTrash(ids); } + Future emptyTrash(String userId) async { + final count = await _assetApiRepository.emptyTrash(); + await _remoteAssetRepository.emptyTrash(userId); + return count; + } + + Future restoreAllTrash(String userId) async { + final count = await _assetApiRepository.restoreAllTrash(); + await _remoteAssetRepository.restoreAllTrash(userId); + return count; + } + Future trashRemoteAndDeleteLocal(List remoteIds, List localIds) async { await _assetApiRepository.delete(remoteIds, false); await _remoteAssetRepository.trash(remoteIds); From 79801595dba3e57cb70cc812098059dac839077b Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 04:50:35 +0700 Subject: [PATCH 62/71] refactor: move image config to metadata table (#28228) * migrate image config --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/config/app_config.dart | 19 ++++++++++++++---- .../domain/models/config/image_config.dart | 20 +++++++++++++++++++ mobile/lib/domain/models/metadata_key.dart | 4 ++++ mobile/lib/domain/models/setting.model.dart | 2 -- mobile/lib/domain/models/store.model.dart | 6 ++---- .../repositories/metadata.repository.dart | 1 + .../widgets/images/image_provider.dart | 7 ++++--- .../widgets/images/local_image_provider.dart | 5 ++--- .../widgets/images/remote_image_provider.dart | 5 ++--- mobile/lib/services/app_settings.service.dart | 5 ----- mobile/lib/utils/migration.dart | 3 +++ .../widgets/settings/advanced_settings.dart | 6 +++++- .../image_viewer_quality_setting.dart | 9 ++++++--- 13 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 mobile/lib/domain/models/config/image_config.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index 956d9fd100..942260158b 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/models/config/cleanup_config.dart'; +import 'package:immich_mobile/domain/models/config/image_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart'; @@ -8,19 +9,28 @@ class AppConfig { final CleanupConfig cleanup; final MapConfig map; final TimelineConfig timeline; + final ImageConfig image; const AppConfig({ this.theme = const .new(), this.cleanup = const .new(), this.map = const .new(), this.timeline = const .new(), + this.image = const .new(), }); - AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .new( + AppConfig copyWith({ + ThemeConfig? theme, + CleanupConfig? cleanup, + MapConfig? map, + TimelineConfig? timeline, + ImageConfig? image, + }) => .new( theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, map: map ?? this.map, timeline: timeline ?? this.timeline, + image: image ?? this.image, ); @override @@ -30,11 +40,12 @@ class AppConfig { other.theme == theme && other.cleanup == cleanup && other.map == map && - other.timeline == timeline); + other.timeline == timeline && + other.image == image); @override - int get hashCode => Object.hash(theme, cleanup, map, timeline); + int get hashCode => Object.hash(theme, cleanup, map, timeline, image); @override - String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)'; + String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image)'; } diff --git a/mobile/lib/domain/models/config/image_config.dart b/mobile/lib/domain/models/config/image_config.dart new file mode 100644 index 0000000000..8410a9010b --- /dev/null +++ b/mobile/lib/domain/models/config/image_config.dart @@ -0,0 +1,20 @@ +class ImageConfig { + final bool preferRemote; + final bool loadOriginal; + + const ImageConfig({this.preferRemote = false, this.loadOriginal = false}); + + ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) => + ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal); + + @override + int get hashCode => Object.hash(preferRemote, loadOriginal); + + @override + String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 0d75ec8be3..c692d77f6b 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -24,6 +24,10 @@ enum MetadataKey { themeDynamic(.appConfig, 'theme.dynamic', false), themeColorfulInterface(.appConfig, 'theme.colorfulInterface', true), + // Image + imagePreferRemote(.appConfig, 'image.preferRemote', false), + imageLoadOriginal(.appConfig, 'image.loadOriginal', false), + // Timeline timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4), timelineGroupAssetsBy( diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index de50ec22f4..f7cb340ee3 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -1,10 +1,8 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { - loadOriginal(StoreKey.loadOriginal, false), loadOriginalVideo(StoreKey.loadOriginalVideo, false), autoPlayVideo(StoreKey.autoPlayVideo, true), - preferRemoteImage(StoreKey.preferRemoteImage, false), advancedTroubleshooting(StoreKey.advancedTroubleshooting, false), enableBackup(StoreKey.enableBackup, false); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 2720c5d3e6..9244eb3c52 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -20,12 +20,8 @@ enum StoreKey { sslClientCertData._(15), sslClientPasswd._(16), uploadErrorNotificationGracePeriod._(106), - thumbnailCacheSize._(110), - imageCacheSize._(111), - albumThumbnailCacheSize._(112), selectedAlbumSortOrder._(113), advancedTroubleshooting._(114), - preferRemoteImage._(116), selfSignedCert._(120), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), @@ -57,6 +53,8 @@ enum StoreKey { syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store + legacyPreferRemoteImage._(116), + legacyLoadOriginal._(101), legacyPrimaryColor._(128), legacyDynamicTheme._(129), legacyColorfulInterface._(130), diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index 676842c8ea..ef9ad6b8ab 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -132,6 +132,7 @@ extension on MetadataDomain { groupAssetsBy: repo._read(.timelineGroupAssetsBy), storageIndicator: repo._read(.timelineStorageIndicator), ), + image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index ea416d9d71..9364fdd091 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -3,9 +3,8 @@ import 'dart:ui' as ui; import 'package:async/async.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; -import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; @@ -189,4 +188,6 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai } bool _shouldUseLocalAsset(BaseAsset asset) => - asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited; + asset.hasLocal && + (!asset.hasRemote || !MetadataRepository.instance.appConfig.image.preferRemote) && + !asset.isEdited; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index ed8289a66a..6376e07405 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,9 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; @@ -105,7 +104,7 @@ class LocalFullImageProvider extends CancellableImageProvider { - loadOriginal(StoreKey.loadOriginal, "loadOriginal", false), uploadErrorNotificationGracePeriod( StoreKey.uploadErrorNotificationGracePeriod, "uploadErrorNotificationGracePeriod", 2, ), - thumbnailCacheSize(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000), - imageCacheSize(StoreKey.imageCacheSize, "imageCacheSize", 350), - albumThumbnailCacheSize(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200), selectedAlbumSortOrder(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), - preferRemoteImage(StoreKey.preferRemoteImage, null, false), loopVideo(StoreKey.loopVideo, "loopVideo", true), loadOriginalVideo(StoreKey.loadOriginalVideo, "loadOriginalVideo", false), autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 07df7958a1..e6d2143468 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -88,6 +88,9 @@ Future _migrateTo26(Drift drift) async { GroupAssetsBy.values, ); await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator); + // Image + await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote); + await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal); await migrator.complete(); } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index e2502aebae..60557aaaca 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -32,7 +32,11 @@ class AdvancedSettings extends HookConsumerWidget { final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); final levelId = useState(ref.read(systemConfigProvider).logLevel.index); - final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); + final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote); + useValueChanged( + preferRemote.value, + (_, __) => ref.read(metadataProvider).write(.imagePreferRemote, preferRemote.value), + ); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final logLevel = Level.LEVELS[levelId.value].name; 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 66c28be22d..7858033401 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 @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -12,7 +12,10 @@ class ImageViewerQualitySetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal); + final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal); + useValueChanged(isOriginal.value, (_, __) { + ref.read(metadataProvider).write(.imageLoadOriginal, isOriginal.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, From 8e3035f7836e0dab3fb4e338c216644b96f01a24 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 05:17:07 +0700 Subject: [PATCH 63/71] chore: run mobile tests in parallel (#28393) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4656ff2646..97bccbc9ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -565,7 +565,7 @@ jobs: run: mise //mobile:codegen:translation - name: Run tests - run: mise //mobile:test -j 1 + run: mise //mobile:test ml-unit-tests: name: Unit Test ML From 6ca54ee722aa9825f7df7000ed53eb1e61ab5110 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 May 2026 21:07:23 -0500 Subject: [PATCH 64/71] feat: display more info in asset viewer (#24630) * feat(mobile): more info for asset viewer * feat(mobile): more info for asset viewer --- i18n/en.json | 2 + .../motion_photo_button.widget.dart | 78 +++++++++++++++++++ .../viewer_kebab_menu.widget.dart | 2 +- .../viewer_top_app_bar.widget.dart | 49 ++++++++++-- mobile/lib/utils/action_button.utils.dart | 4 +- 5 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 6cf5bbeaee..5efd33b8ae 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1403,6 +1403,7 @@ "link_to_oauth": "Link to OAuth", "linked_oauth_account": "Linked OAuth account", "list": "List", + "live": "Live", "loading": "Loading", "loading_search_results_failed": "Loading search results failed", "local": "Local", @@ -1584,6 +1585,7 @@ "month": "Month", "monthly_title_text_date_format": "MMMM y", "more": "More", + "motion": "Motion", "move": "Move", "move_down": "Move down", "move_off_locked_folder": "Move out of locked folder", diff --git a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart new file mode 100644 index 0000000000..800af23039 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; + +class MotionPhotoPlayButton extends ConsumerWidget { + const MotionPhotoPlayButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset)); + final isPlaying = ref.watch(isPlayingMotionVideoProvider); + final showControls = ref.watch(assetViewerProvider.select((state) => state.showingControls)); + final isShowingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + + if (asset == null || !asset.isMotionPhoto || isShowingDetails) { + return const SizedBox.shrink(); + } + + return IgnorePointer( + ignoring: !showControls, + child: AnimatedOpacity( + opacity: showControls ? 1.0 : 0.0, + duration: Durations.short2, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Center( + child: _MotionButton( + isPlaying: isPlaying, + onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + ), + ), + ), + ), + ), + ); + } +} + +class _MotionButton extends StatelessWidget { + final bool isPlaying; + final VoidCallback onPressed; + + const _MotionButton({required this.isPlaying, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.grey[900]!.withValues(alpha: 0.4), + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: InkWell( + onTap: onPressed, + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Text( + CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 5a79485daf..308f6a72a3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); + final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); return MenuAnchor( consumeOutsideTap: true, diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 42d11a8063..3b158c63a8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -1,4 +1,5 @@ import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -10,11 +11,13 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/timezone.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -95,16 +98,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { ), SafeArea( bottom: false, - child: SizedBox.square( + child: SizedBox( + height: preferredSize.height, child: Theme( data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)), - child: Row( - children: [ - const _AppBarBackButton(), - const Spacer(), - if (!showingDetails && !isReadonlyModeEnabled) - if (isInLockedView) ...lockedViewActions else ...actions, - ], + child: NavigationToolbar( + centerMiddle: true, + leading: const _AppBarBackButton(), + middle: showingDetails ? null : _AssetInfoTitle(asset: asset), + trailing: !showingDetails && !isReadonlyModeEnabled + ? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions) + : null, ), ), ), @@ -139,3 +143,32 @@ class _AppBarBackButton extends ConsumerWidget { ); } } + +class _AssetInfoTitle extends ConsumerWidget { + final BaseAsset asset; + + const _AssetInfoTitle({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + DateTime dateTime = asset.createdAt.toLocal(); + final currentYear = DateTime.now().year; + final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull; + + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, _) = applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone); + } + + final isCurrentYear = dateTime.year == currentYear; + final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime); + final timeFormatted = DateFormat.jm().format(dateTime); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(dateFormatted, style: context.textTheme.labelLarge?.copyWith(color: Colors.white)), + Text(timeFormatted, style: context.textTheme.labelMedium?.copyWith(color: Colors.white70)), + ], + ); + } +} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 3f79cd498f..d527f3a59e 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -315,7 +315,7 @@ class ActionButtonBuilder { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { final visibleButtons = defaultViewerKebabMenuOrder .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .toList(); @@ -331,7 +331,7 @@ class ActionButtonBuilder { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } - result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); + result.add(type.buildButton(context, buildContext, false, true)); lastGroup = type.kebabMenuGroup; } From f4a4649bbc81d9fba4c99f9b16015331e6e8adcd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 23:49:22 -0400 Subject: [PATCH 65/71] chore(deps): update dependency canvas to v3 (#28376) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 235 +++++++++++++++++--------------------------- pnpm-workspace.yaml | 2 +- 2 files changed, 91 insertions(+), 146 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 157b0746e0..b405a1090d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: injectWorkspacePackages: true overrides: - canvas: 2.11.2 + canvas: 3.2.3 sharp: ^0.34.5 webpackbar: ^7.0.0 @@ -198,7 +198,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cli: dependencies: @@ -289,7 +289,7 @@ importers: version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest-fetch-mock: specifier: ^0.4.0 version: 0.4.5(vitest@4.1.5) @@ -663,7 +663,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) eslint: specifier: ^10.0.0 version: 10.2.1(jiti@2.6.1) @@ -717,7 +717,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) web: dependencies: @@ -973,7 +973,7 @@ importers: version: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -6182,9 +6182,9 @@ packages: caniuse-lite@1.0.30001790: resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} - canvas@2.11.2: - resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} - engines: {node: '>=6'} + canvas@3.2.3: + resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} + engines: {node: ^18.12.0 || >= 20.9.0} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6964,10 +6964,6 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} - decompress-response@4.2.1: - resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==} - engines: {node: '>=8'} - decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -7567,6 +7563,10 @@ packages: resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==} engines: {node: '>=20.0.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -7866,6 +7866,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} @@ -8591,7 +8594,7 @@ packages: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} peerDependencies: - canvas: 2.11.2 + canvas: 3.2.3 peerDependenciesMeta: canvas: optional: true @@ -9308,10 +9311,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - mimic-response@2.1.0: - resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==} - engines: {node: '>=8'} - mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -9479,6 +9478,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -9552,6 +9554,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} @@ -10499,6 +10505,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -11176,8 +11188,8 @@ packages: simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - simple-get@3.1.1: - resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} simple-icons@16.17.0: resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==} @@ -11886,6 +11898,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -15799,22 +15814,6 @@ snapshots: '@mapbox/mapbox-gl-rtl-text@0.4.0': {} - '@mapbox/node-pre-gyp@1.0.11': - dependencies: - detect-libc: 2.1.2 - https-proxy-agent: 5.0.1 - make-dir: 3.1.0 - node-fetch: 2.7.0 - nopt: 5.0.0 - npmlog: 5.0.1 - rimraf: 3.0.2 - semver: 7.7.4 - tar: 6.2.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': dependencies: detect-libc: 2.1.2 @@ -17248,7 +17247,7 @@ snapshots: svelte: 5.55.2 optionalDependencies: vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17964,7 +17963,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17979,7 +17978,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -17995,7 +17994,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -18762,24 +18761,10 @@ snapshots: caniuse-lite@1.0.30001790: {} - canvas@2.11.2: + canvas@3.2.3: dependencies: - '@mapbox/node-pre-gyp': 1.0.11 - nan: 2.26.2 - simple-get: 3.1.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - - canvas@2.11.2(encoding@0.1.13): - dependencies: - '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) - nan: 2.26.2 - simple-get: 3.1.1 - transitivePeerDependencies: - - encoding - - supports-color + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 optional: true ccount@2.0.1: {} @@ -19582,11 +19567,6 @@ snapshots: dependencies: character-entities: 2.0.2 - decompress-response@4.2.1: - dependencies: - mimic-response: 2.1.0 - optional: true - decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -20331,6 +20311,9 @@ snapshots: optionalDependencies: exiftool-vendored.exe: 13.58.0 + expand-template@2.0.3: + optional: true + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -20416,11 +20399,10 @@ snapshots: fabric@7.3.1: optionalDependencies: - canvas: 2.11.2 - jsdom: 26.1.0(canvas@2.11.2) + canvas: 3.2.3 + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - bufferutil - - encoding - supports-color - utf-8-validate @@ -20712,6 +20694,9 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: + optional: true + github-slugger@1.5.0: {} gl-matrix@3.4.4: {} @@ -21533,7 +21518,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)): + jsdom@26.1.0(canvas@3.2.3): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 @@ -21556,37 +21541,7 @@ snapshots: ws: 8.20.0 xml-name-validator: 5.0.0 optionalDependencies: - canvas: 2.11.2(encoding@0.1.13) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - - jsdom@26.1.0(canvas@2.11.2): - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.20.0 - xml-name-validator: 5.0.0 - optionalDependencies: - canvas: 2.11.2 + canvas: 3.2.3 transitivePeerDependencies: - bufferutil - supports-color @@ -22589,9 +22544,6 @@ snapshots: mimic-function@5.0.1: {} - mimic-response@2.1.0: - optional: true - mimic-response@3.1.0: {} mimic-response@4.0.0: {} @@ -22745,6 +22697,9 @@ snapshots: nanoid@5.1.9: {} + napi-build-utils@2.0.0: + optional: true + natural-compare-lite@1.4.0: {} natural-compare@1.4.0: {} @@ -22817,6 +22772,11 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abi@3.92.0: + dependencies: + semver: 7.7.4 + optional: true + node-abort-controller@3.1.1: {} node-addon-api@4.3.0: {} @@ -22837,11 +22797,6 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - optional: true - node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -23808,6 +23763,22 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + optional: true + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -24731,9 +24702,9 @@ snapshots: simple-concat@1.0.1: optional: true - simple-get@3.1.1: + simple-get@4.0.1: dependencies: - decompress-response: 4.2.1 + decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 optional: true @@ -25575,6 +25546,11 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -25984,9 +25960,9 @@ snapshots: vitest-fetch-mock@0.4.5(vitest@4.1.5): dependencies: - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -26015,7 +25991,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 24.12.2 happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2) + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - jiti - less @@ -26030,7 +26006,7 @@ snapshots: - tsx - yaml - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -26057,42 +26033,11 @@ snapshots: '@types/node': 24.12.2 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 24.12.2 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) - happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2) - transitivePeerDependencies: - - msw - - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -26119,7 +26064,7 @@ snapshots: '@types/node': 25.6.0 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2) + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 937cbd32f4..57aeb9c7bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,7 +28,7 @@ onlyBuiltDependencies: - '@tailwindcss/oxide' - bcrypt overrides: - canvas: 2.11.2 + canvas: 3.2.3 sharp: ^0.34.5 # pending docusaurus 3.10.1 webpackbar: ^7.0.0 From 6a8779764965b6c4a52cdb8564ad78bb70975314 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 23:50:23 -0400 Subject: [PATCH 66/71] chore(deps): update terraform cloudflare to v4.52.7 (#28370) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 0869dd28bc..7fdd96847c 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.52.5" - constraints = "4.52.5" + version = "4.52.7" + constraints = "4.52.7" hashes = [ - "h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=", - "h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=", - "h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=", - "h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=", - "h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=", - "h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=", - "h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=", - "h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=", - "h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=", - "h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=", - "h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=", - "h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=", - "h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=", - "h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=", - "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", - "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", - "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", - "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", - "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", - "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072", - "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661", - "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c", + "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=", + "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=", + "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=", + "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=", + "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=", + "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=", + "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=", + "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=", + "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=", + "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=", + "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=", + "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=", + "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", + "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=", + "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", + "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", + "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", + "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285", + "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", - "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", - "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", - "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", - "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", - "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", + "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", + "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", + "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", + "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", + "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", + "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", + "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", + "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", + "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 63347cf67e..7c59cdd2e3 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.52.5" + version = "4.52.7" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 0869dd28bc..7fdd96847c 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.52.5" - constraints = "4.52.5" + version = "4.52.7" + constraints = "4.52.7" hashes = [ - "h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=", - "h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=", - "h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=", - "h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=", - "h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=", - "h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=", - "h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=", - "h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=", - "h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=", - "h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=", - "h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=", - "h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=", - "h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=", - "h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=", - "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", - "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", - "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", - "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", - "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", - "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072", - "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661", - "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c", + "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=", + "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=", + "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=", + "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=", + "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=", + "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=", + "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=", + "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=", + "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=", + "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=", + "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=", + "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=", + "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", + "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=", + "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", + "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", + "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", + "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285", + "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", - "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", - "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", - "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", - "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", - "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", + "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", + "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", + "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", + "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", + "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", + "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", + "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", + "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", + "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 63347cf67e..7c59cdd2e3 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.52.5" + version = "4.52.7" } } } From e323e778cdb36696a3ac6517b5f2960112d2709b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 13 May 2026 15:27:25 +0200 Subject: [PATCH 67/71] fix: update server-commands subcommand list (#28402) --- docs/docs/administration/server-commands.md | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index cbd029296f..6938cfadd6 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -13,8 +13,11 @@ The `immich-server` docker image comes preinstalled with an administrative CLI ( | `enable-oauth-login` | Enable OAuth login | | `disable-oauth-login` | Disable OAuth login | | `list-users` | List Immich users | +| `grant-admin` | Grant admin privileges to a user (by email) | +| `revoke-admin` | Revoke admin privileges from a user (by email) | | `version` | Print Immich version | | `change-media-location` | Change database file paths to align with a new media location | +| `schema-check` | Verify database migrations and check for schema drift | ## How to run a command @@ -102,6 +105,22 @@ immich-admin list-users ] ``` +Grant Admin + +``` +immich-admin grant-admin +? Please enter the user email: user@example.com +Admin access has been granted to user@example.com +``` + +Revoke Admin + +``` +immich-admin revoke-admin +? Please enter the user email: user@example.com +Admin access has been revoked from user@example.com +``` + Print Immich Version ``` @@ -126,3 +145,12 @@ immich-admin change-media-location Database file paths updated successfully! 🎉 ... ``` + +Schema Check + +``` +immich-admin schema-check +Migrations are up to date + +No schema drift detected +``` From b6e2ce1f3534cceb6f9f802c7c735650fb3fba7b Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 13 May 2026 09:36:16 -0500 Subject: [PATCH 68/71] fix(mobile): revert drop deprecated deviceAssetId / deviceId from upload fields (#28384) (#28400) * Revert "chore(mobile): drop deprecated deviceAssetId / deviceId from upload fields (#28384)" This reverts commit 571e6a8560fbf4031b30e530196e281b4858bfb4. * chore(mobile): add note on kept deprecated upload fields --------- Co-authored-by: Santo Shakil --- mobile/lib/services/background_upload.service.dart | 4 ++++ mobile/lib/services/foreground_upload.service.dart | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index ab091f3925..b76b9dcd61 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -394,9 +394,13 @@ class BackgroundUploadService { final serverEndpoint = Store.get(StoreKey.serverEndpoint); final url = Uri.parse('$serverEndpoint/assets').toString(); final headers = ApiService.getRequestHeaders(); + final deviceId = Store.get(StoreKey.deviceId); final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); final fieldsMap = { 'filename': originalFileName ?? filename, + // deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818). + 'deviceAssetId': deviceAssetId ?? '', + 'deviceId': deviceId, 'fileCreatedAt': createdAt.toUtc().toIso8601String(), 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'isFavorite': isFavorite?.toString() ?? 'false', diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 2a3ebcb62a..3f7277579f 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.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/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -319,8 +321,12 @@ class ForegroundUploadService { } final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final deviceId = Store.get(StoreKey.deviceId); final fields = { + // deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818). + 'deviceAssetId': asset.localId!, + 'deviceId': deviceId, 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'isFavorite': asset.isFavorite.toString(), @@ -426,6 +432,9 @@ class ForegroundUploadService { final filename = p.basename(file.path); final fields = { + // deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818). + 'deviceAssetId': deviceAssetId, + 'deviceId': Store.get(StoreKey.deviceId), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'isFavorite': 'false', From 0a4ed6fd71df412b06c980d7ef1f899b0c9dfe49 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 21:36:19 +0700 Subject: [PATCH 69/71] refactor: migrate viewer config to metadata table (#28396) * refactor: app metadata * refactor to per row store * cleanup * more test * review changes * more refactor * refactor * migrate primary color * migrate dynamic theme * migrate colorfulInterface * cleanup providers * migrate cleanup * migrate mapconfig * remove unused keys * migrate timeline config * migrate image config * migrate viewer config --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/config/app_config.dart | 13 +++++-- .../domain/models/config/viewer_config.dart | 37 +++++++++++++++++++ mobile/lib/domain/models/metadata_key.dart | 6 +++ mobile/lib/domain/models/setting.model.dart | 2 - mobile/lib/domain/models/store.model.dart | 10 ++--- .../repositories/metadata.repository.dart | 6 +++ .../asset_viewer/asset_page.widget.dart | 5 +-- .../asset_viewer/video_viewer.widget.dart | 12 ++---- mobile/lib/services/app_settings.service.dart | 4 -- mobile/lib/utils/migration.dart | 5 +++ .../image_viewer_tap_to_navigate_setting.dart | 11 +++--- .../video_viewer_settings.dart | 25 ++++++++----- .../metadata_repository_test.dart | 3 -- 13 files changed, 96 insertions(+), 43 deletions(-) create mode 100644 mobile/lib/domain/models/config/viewer_config.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index 942260158b..beca1c21e7 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/config/image_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart'; +import 'package:immich_mobile/domain/models/config/viewer_config.dart'; class AppConfig { final ThemeConfig theme; @@ -10,6 +11,7 @@ class AppConfig { final MapConfig map; final TimelineConfig timeline; final ImageConfig image; + final ViewerConfig viewer; const AppConfig({ this.theme = const .new(), @@ -17,6 +19,7 @@ class AppConfig { this.map = const .new(), this.timeline = const .new(), this.image = const .new(), + this.viewer = const .new(), }); AppConfig copyWith({ @@ -25,12 +28,14 @@ class AppConfig { MapConfig? map, TimelineConfig? timeline, ImageConfig? image, + ViewerConfig? viewer, }) => .new( theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, map: map ?? this.map, timeline: timeline ?? this.timeline, image: image ?? this.image, + viewer: viewer ?? this.viewer, ); @override @@ -41,11 +46,13 @@ class AppConfig { other.cleanup == cleanup && other.map == map && other.timeline == timeline && - other.image == image); + other.image == image && + other.viewer == viewer); @override - int get hashCode => Object.hash(theme, cleanup, map, timeline, image); + int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer); @override - String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image)'; + String toString() => + 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)'; } diff --git a/mobile/lib/domain/models/config/viewer_config.dart b/mobile/lib/domain/models/config/viewer_config.dart new file mode 100644 index 0000000000..595f2bee5d --- /dev/null +++ b/mobile/lib/domain/models/config/viewer_config.dart @@ -0,0 +1,37 @@ +class ViewerConfig { + final bool loopVideo; + final bool loadOriginalVideo; + final bool autoPlayVideo; + final bool tapToNavigate; + + const ViewerConfig({ + this.loopVideo = true, + this.loadOriginalVideo = false, + this.autoPlayVideo = true, + this.tapToNavigate = false, + }); + + ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) => + ViewerConfig( + loopVideo: loopVideo ?? this.loopVideo, + loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo, + autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo, + tapToNavigate: tapToNavigate ?? this.tapToNavigate, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ViewerConfig && + other.loopVideo == loopVideo && + other.loadOriginalVideo == loadOriginalVideo && + other.autoPlayVideo == autoPlayVideo && + other.tapToNavigate == tapToNavigate); + + @override + int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate); + + @override + String toString() => + 'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index c692d77f6b..61a3cebc8a 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -28,6 +28,12 @@ enum MetadataKey { imagePreferRemote(.appConfig, 'image.preferRemote', false), imageLoadOriginal(.appConfig, 'image.loadOriginal', false), + // Viewer + viewerLoopVideo(.appConfig, 'viewer.loopVideo', true), + viewerLoadOriginalVideo(.appConfig, 'viewer.loadOriginalVideo', false), + viewerAutoPlayVideo(.appConfig, 'viewer.autoPlayVideo', true), + viewerTapToNavigate(.appConfig, 'viewer.tapToNavigate', false), + // Timeline timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4), timelineGroupAssetsBy( diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index f7cb340ee3..0dc48de3b1 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -1,8 +1,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { - loadOriginalVideo(StoreKey.loadOriginalVideo, false), - autoPlayVideo(StoreKey.autoPlayVideo, true), advancedTroubleshooting(StoreKey.advancedTroubleshooting, false), enableBackup(StoreKey.enableBackup, false); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 9244eb3c52..e52e8a0a92 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -40,12 +40,6 @@ enum StoreKey { albumGridView._(140), loadOriginal._(101), - // Image viewer navigation settings - loopVideo._(117), - loadOriginalVideo._(136), - autoPlayVideo._(139), - tapToNavigate._(141), - // Experimental stuff enableBackup._(1003), useWifiForUploadVideos._(1004), @@ -53,6 +47,10 @@ enum StoreKey { syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store + legacyLoopVideo._(117), + legacyLoadOriginalVideo._(136), + legacyAutoPlayVideo._(139), + legacyTapToNavigate._(141), legacyPreferRemoteImage._(116), legacyLoadOriginal._(101), legacyPrimaryColor._(128), diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index ef9ad6b8ab..d8c8f55898 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -133,6 +133,12 @@ extension on MetadataDomain { storageIndicator: repo._read(.timelineStorageIndicator), ), image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)), + viewer: .new( + loopVideo: repo._read(.viewerLoopVideo), + loadOriginalVideo: repo._read(.viewerLoadOriginalVideo), + autoPlayVideo: repo._read(.viewerAutoPlayVideo), + tapToNavigate: repo._read(.viewerTapToNavigate), + ), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); 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 a531917e5b..77f693f5c4 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -17,11 +17,10 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widg import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -231,7 +230,7 @@ class _AssetPageState extends ConsumerState { return; } - final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.tapToNavigate); + final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate; if (!tapToNavigate) { _viewer.toggleControls(); return; diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index bf2ab17425..97ca8ace10 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -3,21 +3,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -132,7 +128,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg final remoteId = (videoAsset as RemoteAsset).id; final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); + final isOriginalVideo = ref.read(metadataProvider).appConfig.viewer.loadOriginalVideo; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String videoUrl = videoAsset.livePhotoVideoId != null ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' @@ -165,7 +161,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg return; } - final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); + final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo; if (autoPlayVideo || widget.asset.isMotionPhoto) { await _notifier.play(); } @@ -216,7 +212,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg } await _notifier.load(source); - final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); + final loopVideo = ref.read(metadataProvider).appConfig.viewer.loopVideo; await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); await _notifier.setVolume(1); } diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index e04c200989..38d3e028cb 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -10,10 +10,6 @@ enum AppSettingsEnum { selectedAlbumSortOrder(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), - loopVideo(StoreKey.loopVideo, "loopVideo", true), - loadOriginalVideo(StoreKey.loadOriginalVideo, "loadOriginalVideo", false), - autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), - tapToNavigate(StoreKey.tapToNavigate, "tapToNavigate", false), allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index e6d2143468..8f0eb00b16 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -91,6 +91,11 @@ Future _migrateTo26(Drift drift) async { // Image await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote); await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal); + // Viewer + await migrator.migrateBool(StoreKey.legacyLoopVideo, MetadataKey.viewerLoopVideo); + await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo); + await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo); + await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate); await migrator.complete(); } 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 759162cab8..5af64b0be9 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 @@ -1,18 +1,20 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class ImageViewerTapToNavigateSetting extends HookConsumerWidget { const ImageViewerTapToNavigateSetting({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate); + final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate); + useValueChanged(tapToNavigate.value, (_, __) { + ref.read(metadataProvider).write(.viewerTapToNavigate, tapToNavigate.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -22,7 +24,6 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget { valueNotifier: tapToNavigate, title: "setting_image_navigation_enable_title".tr(), subtitle: "setting_image_navigation_enable_subtitle".tr(), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), ], ); 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 c03dcc51b4..8d62544dd4 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 @@ -1,20 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class VideoViewerSettings extends HookConsumerWidget { const VideoViewerSettings({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo); - final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo); - final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.autoPlayVideo); + final viewer = ref.read(appConfigProvider).viewer; + final useAutoPlayVideo = useState(viewer.autoPlayVideo); + final useLoopVideo = useState(viewer.loopVideo); + final useOriginalVideo = useState(viewer.loadOriginalVideo); + + useValueChanged(useAutoPlayVideo.value, (_, __) { + ref.read(metadataProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value); + }); + useValueChanged(useLoopVideo.value, (_, __) { + ref.read(metadataProvider).write(.viewerLoopVideo, useLoopVideo.value); + }); + useValueChanged(useOriginalVideo.value, (_, __) { + ref.read(metadataProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -27,19 +37,16 @@ class VideoViewerSettings extends HookConsumerWidget { valueNotifier: useAutoPlayVideo, title: "setting_video_viewer_auto_play_title".t(context: context), subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), SettingsSwitchListTile( valueNotifier: useLoopVideo, title: "setting_video_viewer_looping_title".t(context: context), subtitle: "loop_videos_description".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), SettingsSwitchListTile( valueNotifier: useOriginalVideo, title: "setting_video_viewer_original_video_title".t(context: context), subtitle: "setting_video_viewer_original_video_subtitle".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), ], ); diff --git a/mobile/test/medium/repositories/metadata_repository_test.dart b/mobile/test/medium/repositories/metadata_repository_test.dart index 32f613483d..7b185f3bec 100644 --- a/mobile/test/medium/repositories/metadata_repository_test.dart +++ b/mobile/test/medium/repositories/metadata_repository_test.dart @@ -79,7 +79,6 @@ void main() { expect(sut.appConfig.theme.mode, ThemeMode.system); await MetadataRepository.refresh(); - expect(sut.appConfig.theme.mode, ThemeMode.dark); }); @@ -90,7 +89,6 @@ void main() { expect(sut.appConfig.theme.mode, ThemeMode.dark); await MetadataRepository.refresh(); - expect(sut.appConfig.theme.mode, ThemeMode.system); }); @@ -135,5 +133,4 @@ void main() { await expectation; }); }); - } From 024f20ea26e58cb53254654950bff0871932ad27 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 13 May 2026 16:37:07 +0200 Subject: [PATCH 70/71] chore(web): use DatePicker component from UI lib (#28406) --- i18n/en.json | 5 +- .../shared-components/map/Map.svelte | 16 ++--- web/src/lib/modals/MapSettingsModal.svelte | 65 ++++++++----------- .../modals/PersonEditBirthDateModal.svelte | 27 +++----- web/src/lib/stores/preferences.store.ts | 7 +- 5 files changed, 46 insertions(+), 74 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 5efd33b8ae..697aa7f2fa 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -885,15 +885,13 @@ "cutoff_date_description": "Keep photos from the last…", "cutoff_day": "{count, plural, one {day} other {days}}", "cutoff_year": "{count, plural, one {year} other {years}}", - "daily_title_text_date": "E, MMM dd", - "daily_title_text_date_year": "E, MMM dd, yyyy", "dark": "Dark", "dark_theme": "Switch to dark theme", "date": "Date", "date_after": "Date after", "date_and_time": "Date and Time", "date_before": "Date before", - "date_format": "E, LLL d, y • h:mm a", + "date_of_birth": "Date of birth", "date_of_birth_saved": "Date of birth saved successfully", "date_range": "Date range", "day": "Day", @@ -1583,7 +1581,6 @@ "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", "model": "Model", "month": "Month", - "monthly_title_text_date_format": "MMMM y", "more": "More", "motion": "Motion", "move": "Move", diff --git a/web/src/lib/components/shared-components/map/Map.svelte b/web/src/lib/components/shared-components/map/Map.svelte index 52686bdd41..4e29506e96 100644 --- a/web/src/lib/components/shared-components/map/Map.svelte +++ b/web/src/lib/components/shared-components/map/Map.svelte @@ -212,16 +212,10 @@ }; } - try { - return { - fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined, - fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined, - }; - } catch { - $mapSettings.dateAfter = ''; - $mapSettings.dateBefore = ''; - return {}; - } + return { + fileCreatedAfter: dateAfter?.toUTC().toISO(), + fileCreatedBefore: dateBefore?.toUTC().toISO(), + }; } async function loadMapMarkers() { @@ -237,7 +231,7 @@ { isArchived: includeArchived || undefined, isFavorite: onlyFavorites || undefined, - fileCreatedAfter: fileCreatedAfter || undefined, + fileCreatedAfter, fileCreatedBefore, withPartners: withPartners || undefined, withSharedAlbums: withSharedAlbums || undefined, diff --git a/web/src/lib/modals/MapSettingsModal.svelte b/web/src/lib/modals/MapSettingsModal.svelte index 4b45def861..4dc0e72685 100644 --- a/web/src/lib/modals/MapSettingsModal.svelte +++ b/web/src/lib/modals/MapSettingsModal.svelte @@ -1,7 +1,6 @@ - {$t('birthdate_set_description')} -
- +
+ + + {$t('birthdate_set_description')} + {#if person.birthDate}
-
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 3c31bef856..016942c572 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -1,3 +1,4 @@ +import type { DateTime } from 'luxon'; import { persisted } from 'svelte-persisted-store'; import { browser } from '$app/environment'; import { defaultLang } from '$lib/constants'; @@ -26,8 +27,8 @@ export interface MapSettings { withPartners: boolean; withSharedAlbums: boolean; relativeDate: string; - dateAfter: string; - dateBefore: string; + dateAfter?: DateTime; + dateBefore?: DateTime; } const defaultMapSettings = { @@ -37,8 +38,6 @@ const defaultMapSettings = { withPartners: false, withSharedAlbums: false, relativeDate: '', - dateAfter: '', - dateBefore: '', }; const persistedObject = (key: string, defaults: T) => From fcea617313048d9da1a68a10b4325931e44b64ed Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 13 May 2026 12:07:35 -0400 Subject: [PATCH 71/71] fix: ignore icc profile make and model (#28412) --- server/src/repositories/metadata.repository.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 57c688cac2..188cc016f1 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; +import { BinaryField, DefaultReadTaskOptions, ExifTool, ReadTaskOptions, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { mimeTypes } from 'src/utils/mime-types'; @@ -89,7 +89,7 @@ export class MetadataRepository { geoTz: (lat, lon) => geotz.find(lat, lon)[0], geolocation: true, // Enable exiftool LFS to parse metadata for files larger than 2GB. - readArgs: ['-api', 'largefilesupport=1'], + readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], taskTimeoutMillis: 2 * 60 * 1000, }); @@ -107,8 +107,8 @@ export class MetadataRepository { } readTags(path: string): Promise { - const args = mimeTypes.isVideo(path) ? ['-ee'] : []; - return this.exiftool.read(path, { readArgs: args }).catch((error) => { + const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined; + return this.exiftool.read(path, options).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); return {}; }) as Promise;