From 43b2d04e2cd550089cd69623ed52f0f13be1e535 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:54:42 +0200 Subject: [PATCH 001/508] fix: version tests (#29032) --- e2e/src/specs/server/api/server.e2e-spec.ts | 2 +- server/src/services/version.service.spec.ts | 25 ++++++++------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 902c5302e5..46d998511d 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -95,7 +95,7 @@ describe('/server', () => { major: expect.any(Number), minor: expect.any(Number), patch: expect.any(Number), - prerelease: null, + prerelease: expect.anything(), }); }); }); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index d73edb9850..0044730cee 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,7 +1,6 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; import { defaults } from 'src/config'; -import { serverVersion } from 'src/constants'; import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { VersionService } from 'src/services/version.service'; @@ -23,16 +22,10 @@ describe(VersionService.name, () => { mocks.cron.update.mockResolvedValue(); }); - beforeAll(() => { - vitest.mock(import('src/constants.js'), async () => ({ - ...(await vitest.importActual('src/constants.js')), - serverVersion: new SemVer('v3.0.0'), - })); - }); - - afterAll(() => { - vitest.unmock(import('src/constants.js')); - }); + vitest.mock(import('src/constants.js'), async (importOriginal) => ({ + ...(await importOriginal()), + serverVersion: new SemVer('v3.0.0'), + })); it('should work', () => { expect(sut).toBeDefined(); @@ -53,7 +46,7 @@ describe(VersionService.name, () => { mocks.versionHistory.getLatest.mockResolvedValue({ id: 'version-1', createdAt: new Date(), - version: serverVersion.toString(), + version: '3.0.0', }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(mocks.versionHistory.create).not.toHaveBeenCalled(); @@ -64,7 +57,7 @@ describe(VersionService.name, () => { mocks.versionHistory.getLatest.mockResolvedValue({ id: 'version-1', createdAt: new Date(), - version: serverVersion.toString(), + version: '3.0.0', }); await sut.onBootstrap(); expect(mocks.cron.create).toHaveBeenCalledWith( @@ -121,7 +114,7 @@ describe(VersionService.name, () => { checkedAt: DateTime.utc().minus({ seconds: 60 }).toISO(), releaseVersion: '1.0.0', }); - mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString())); + mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v3.0.0')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success); expect(mocks.serverInfo.getLatestRelease).toHaveBeenCalled(); }); @@ -135,11 +128,11 @@ describe(VersionService.name, () => { }); it('should not notify if the version is equal', async () => { - mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString())); + mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v3.0.0')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VersionCheckState, { checkedAt: expect.any(String), - releaseVersion: serverVersion.toString(), + releaseVersion: 'v3.0.0', }); expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled(); }); From e31d4aa909fcbac59b714d8f1e58040178caf456 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 12 Jun 2026 12:43:19 -0400 Subject: [PATCH 002/508] fix: prerelease draft (#29034) --- .github/workflows/prepare-release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 1371b33c37..d29af8763b 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -50,6 +50,7 @@ jobs: outputs: ref: ${{ steps.push-tag.outputs.commit_long_sha }} version: ${{ steps.output.outputs.version }} + rc: ${{ steps.output.outputs.rc }} permissions: {} # No job-level permissions are needed because it uses the app-token steps: - id: token @@ -81,7 +82,13 @@ jobs: run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}" - id: output - run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT + run: | + echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT + if [[ "$IMMICH_VERSION" =~ -rc\.[0-9]+$ ]]; then + echo "rc=true" >> $GITHUB_OUTPUT + else + echo "rc=false" >> $GITHUB_OUTPUT + fi - name: Commit and tag id: push-tag @@ -145,6 +152,7 @@ jobs: uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: draft: true + prerelease: ${{ needs.bump_version.outputs.rc }} tag_name: ${{ needs.bump_version.outputs.version }} token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true From abd62d92952321f776b914c9d262a086c8c57c21 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 13 Jun 2026 01:55:26 +0600 Subject: [PATCH 003/508] fix(mobile): show like and comment options on album photo deep links (#29020) --- mobile/lib/services/deep_link.service.dart | 10 +- .../test/services/deep_link_service_test.dart | 140 ++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 mobile/test/services/deep_link_service_test.dart diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 26f2fb685b..d5947ceb8f 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -70,7 +70,10 @@ class DeepLinkService { if (assetRegex.hasMatch(path)) { final assetId = assetRegex.firstMatch(path)?.group(1) ?? ''; - return _buildAssetDeepLink(assetId, ref); + // /albums//photos/ links carry the album context, + // which drives the like/comment UI in the viewer + final albumId = albumRegex.firstMatch(path)?.group(1); + return _buildAssetDeepLink(assetId, ref, albumId: albumId); } if (albumRegex.hasMatch(path)) { final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; @@ -107,16 +110,19 @@ class DeepLinkService { return DriftMemoryRoute(memories: memories, memoryIndex: 0); } - Future _buildAssetDeepLink(String assetId, WidgetRef ref) async { + Future _buildAssetDeepLink(String assetId, WidgetRef ref, {String? albumId}) async { final asset = await _betaAssetService.getRemoteAsset(assetId); if (asset == null) { return null; } + final album = albumId != null ? await _betaRemoteAlbumService.get(albumId) : null; + AssetViewer.setAsset(ref, asset); return AssetViewerRoute( initialIndex: 0, timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink), + currentAlbum: album, ); } diff --git a/mobile/test/services/deep_link_service_test.dart b/mobile/test/services/deep_link_service_test.dart new file mode 100644 index 0000000000..ff090367ea --- /dev/null +++ b/mobile/test/services/deep_link_service_test.dart @@ -0,0 +1,140 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:immich_mobile/domain/services/memory.service.dart'; +import 'package:immich_mobile/domain/services/people.service.dart'; +import 'package:immich_mobile/domain/services/remote_album.service.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/deep_link.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTimelineFactory extends Mock implements TimelineFactory {} + +class MockAssetService extends Mock implements AssetService {} + +class MockRemoteAlbumService extends Mock implements RemoteAlbumService {} + +class MockDriftMemoryService extends Mock implements DriftMemoryService {} + +class MockDriftPeopleService extends Mock implements DriftPeopleService {} + +class MockPlatformDeepLink extends Mock implements PlatformDeepLink {} + +class MockWidgetRef extends Mock implements WidgetRef {} + +class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotifier {} + +const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; +const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd'; + +final _asset = RemoteAsset( + id: _assetId, + name: 'photo.jpg', + ownerId: 'user-1', + checksum: 'checksum-1', + type: AssetType.image, + createdAt: DateTime(2026, 6, 12), + updatedAt: DateTime(2026, 6, 12), + isEdited: false, +); + +final _album = RemoteAlbum( + id: _albumId, + name: 'Shared Album', + ownerId: 'user-1', + description: '', + createdAt: DateTime(2026, 6, 12), + updatedAt: DateTime(2026, 6, 12), + isActivityEnabled: true, + isShared: true, + order: AlbumAssetOrder.asc, + assetCount: 1, + ownerName: 'Owner', +); + +void main() { + late MockTimelineFactory timelineFactory; + late MockAssetService assetService; + late MockRemoteAlbumService remoteAlbumService; + late MockWidgetRef ref; + late List createdTimelineServices; + late DeepLinkService sut; + + setUp(() { + timelineFactory = MockTimelineFactory(); + assetService = MockAssetService(); + remoteAlbumService = MockRemoteAlbumService(); + ref = MockWidgetRef(); + createdTimelineServices = []; + + when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) { + final assets = List.from(invocation.positionalArguments[0] as List); + final timelineService = TimelineService(( + assetSource: (index, count) async => assets.skip(index).take(count).toList(), + bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]), + origin: TimelineOrigin.deepLink, + )); + createdTimelineServices.add(timelineService); + return timelineService; + }); + + when(() => ref.read(assetViewerProvider.notifier)).thenReturn(MockAssetViewerStateNotifier()); + + sut = DeepLinkService( + timelineFactory, + assetService, + remoteAlbumService, + MockDriftMemoryService(), + MockDriftPeopleService(), + null, + ); + + addTearDown(() async { + for (final timelineService in createdTimelineServices) { + await timelineService.dispose(); + } + }); + }); + + PlatformDeepLink link(String path) { + final deepLink = MockPlatformDeepLink(); + when(() => deepLink.uri).thenReturn(Uri.parse('https://my.immich.app$path')); + return deepLink; + } + + test('album photo link carries the album into the viewer route', () async { + when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset); + when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => _album); + + final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref); + + expect(route, isA()); + expect((route!.args as AssetViewerRouteArgs).currentAlbum, _album); + }); + + test('still opens the viewer when the album cannot be resolved', () async { + when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset); + when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => null); + + final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref); + + expect(route, isA()); + expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull); + }); + + test('plain photo link has no album', () async { + when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset); + + final route = await sut.handleMyImmichApp(link('/photos/$_assetId'), ref); + + expect(route, isA()); + expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull); + verifyNever(() => remoteAlbumService.get(any())); + }); +} From b21af784549f060e53be1db9e544d3375df61aa2 Mon Sep 17 00:00:00 2001 From: maxinegardenas Date: Fri, 12 Jun 2026 20:04:52 +0000 Subject: [PATCH 004/508] fix(web): correctly handle person search with more than 100 results (#29002) --- web/src/lib/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 3bb1539e0c..802f3bc63b 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -69,7 +69,7 @@ export enum OpenQueryParam { PURCHASE_SETTINGS = 'user-purchase-settings', } -export const maximumLengthSearchPeople = 1000; +export const maximumLengthSearchPeople = 100; // time to load the map before displaying the loading spinner export const timeToLoadTheMap: number = 100; From 5a3be158b97e2944f2c3cb6fa42c14d366424b2e Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:47:17 +0200 Subject: [PATCH 005/508] fix(web): show album names in duplicate review (#29080) --- .../[[assetId=id]]/DuplicateAsset.svelte | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte index 70d549cc48..830cdb7bfb 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte @@ -1,5 +1,5 @@ @@ -57,7 +59,7 @@ size="tiny" id="{option.value}-checkbox" checked={value.includes(option.value)} - {disabled} + disabled={disabled || lockedOptions.includes(option.value)} onCheckedChange={() => handleCheckboxChange(option.value)} />