Compare commits

..

2 Commits

Author SHA1 Message Date
Mees Frensel dc2061c52a Merge branch 'main' into chore/server-switch-exhaustive 2026-06-12 18:13:24 +02:00
Mees Frensel f09d161128 chore(server): add switch case exhaustiveness lint 2026-06-12 17:03:24 +02:00
16 changed files with 46 additions and 170 deletions
+1 -9
View File
@@ -50,7 +50,6 @@ 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
@@ -82,13 +81,7 @@ jobs:
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: 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
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
- name: Commit and tag
id: push-tag
@@ -152,7 +145,6 @@ 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
+2 -8
View File
@@ -70,10 +70,7 @@ class DeepLinkService {
if (assetRegex.hasMatch(path)) {
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
// /albums/<albumId>/photos/<assetId> 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);
return _buildAssetDeepLink(assetId, ref);
}
if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
@@ -110,19 +107,16 @@ class DeepLinkService {
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
}
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref, {String? albumId}) async {
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref) 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,
);
}
@@ -1,140 +0,0 @@
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<TimelineService> 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<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
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<AssetViewerRoute>());
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<AssetViewerRoute>());
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<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
verifyNever(() => remoteAlbumService.get(any()));
});
}
+1
View File
@@ -51,6 +51,7 @@ export default typescriptEslint.config([
'unicorn/no-array-sort': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }],
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
curly: 2,
+5 -1
View File
@@ -28,9 +28,13 @@ export class SchemaCheck extends CommandRunner {
}
case 'missing': {
console.log(` - ${migration.name} exists, but has not been applied to the database`);
console.log(` - ${migration.name} exists on disk, but has not been applied to the database`);
break;
}
case 'applied': {
break; // happy path, do nothing
}
}
}
}
+8 -1
View File
@@ -9,6 +9,7 @@ import {
PersonPathType,
RawExtractedFormat,
StorageFolder,
UserPathType,
} from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
@@ -327,13 +328,19 @@ export class StorageCore {
case AssetFileType.EncodedVideo:
case AssetFileType.Thumbnail:
case AssetFileType.Preview:
case AssetFileType.Sidecar: {
case AssetFileType.Sidecar:
case AssetPathType.EncodedVideo: {
return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath });
}
case PersonPathType.Face: {
return this.personRepository.update({ id, thumbnailPath: newPath });
}
case UserPathType.Profile: {
this.logger.warn('Unexpected path type:', pathType);
return;
}
}
}
@@ -281,17 +281,20 @@ export class MaintenanceWorkerService {
async runAction(action: SetMaintenanceModeDto) {
switch (action.action) {
case MaintenanceAction.Start: {
case MaintenanceAction.Start:
case MaintenanceAction.SelectDatabaseRestore: {
return;
}
case MaintenanceAction.End: {
return this.endMaintenance();
}
case MaintenanceAction.SelectDatabaseRestore: {
return;
case MaintenanceAction.RestoreDatabase: {
return this.runRestoreDatabase(action);
}
}
}
async runRestoreDatabase(action: SetMaintenanceModeDto) {
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!lock) {
return;
@@ -252,6 +252,9 @@ const getEnv = (): EnvData => {
vectorExtension = DatabaseExtension.VectorChord;
break;
}
case undefined: {
break;
}
}
return {
+3 -1
View File
@@ -478,8 +478,10 @@ export class MediaRepository {
case 'av1': {
return this.parseEnum(Av1Profile, profile);
}
default: {
return null;
}
}
return null;
}
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
@@ -163,6 +163,9 @@ export class DatabaseBackupService {
);
switch (bin) {
case 'pg_dump': {
break;
}
case 'pg_dumpall': {
args.push('--database');
break;
+2
View File
@@ -257,6 +257,8 @@ export class JobService extends BaseService {
}
break;
}
// no default
}
}
}
+3
View File
@@ -498,6 +498,9 @@ export class LibraryService extends BaseService {
const stat = stats[i];
const action = this.checkExistingAsset(asset, stat);
switch (action) {
case AssetSyncResult.DO_NOTHING: {
break;
}
case AssetSyncResult.OFFLINE: {
if (asset.status === AssetStatus.Trashed) {
trashedAssetIdsToOffline.push(asset.id);
+3 -1
View File
@@ -1138,7 +1138,9 @@ export class MetadataService extends BaseService {
case 3: {
return ExifOrientation.Rotate90CW;
}
default: {
return null;
}
}
return null;
}
}
+4 -4
View File
@@ -261,6 +261,10 @@
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
@@ -286,10 +290,6 @@
{/if}
</div>
{#if show.brokenAsset}
<BrokenAsset class="absolute inset-0 z-10 size-full text-xl" />
{/if}
{#if overlays}
<div class="pointer-events-none absolute inset-0">
{@render overlays()}
+1 -1
View File
@@ -69,7 +69,7 @@ export enum OpenQueryParam {
PURCHASE_SETTINGS = 'user-purchase-settings',
}
export const maximumLengthSearchPeople = 100;
export const maximumLengthSearchPeople = 1000;
// time to load the map before displaying the loading spinner
export const timeToLoadTheMap: number = 100;
@@ -44,7 +44,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
#isImageLoading = $derived.by(() => {
const quality = this.imageLoaderStatus?.quality;
if (!quality || this.imageLoaderStatus?.hasError) {
if (!quality) {
return false;
}
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';