Compare commits

..

7 Commits

Author SHA1 Message Date
Alex
8b6b495bf5 fix: keep owned asset in iOS shared album in Free Up Space 2026-01-28 15:43:39 -06:00
Alex
0beb1f9e7a fix: width and height migration issue (#25643)
* fix: width and height migration issue

* chore: sync stream migration tests

* lint and test

---------

Co-authored-by: bwees <brandonwees@gmail.com>
2026-01-28 15:14:50 -06:00
Mert
e07a91f9c2 fix(mobile): actually load original image (#25646)
fix decoding
2026-01-28 15:14:37 -06:00
Noel S
c6defd453b fix(mobile): set correct system-ui mode on asset viewer init (#25610)
fix: set correct systemui mode on asset viewer init
2026-01-28 15:14:23 -06:00
Daniel Dietzler
4e0e1b2c5c fix: escape handling (#25627) 2026-01-28 15:05:21 -05:00
Noel S
84c3980844 fix(mobile): show controls by default on motion photos (#25638)
fix: show controls by default on motion photos
2026-01-28 13:46:29 -06:00
Daniel Dietzler
e50579eefc fix: album card ranges (#25639) 2026-01-28 14:38:09 -05:00
54 changed files with 428 additions and 1415 deletions

View File

@@ -79,6 +79,7 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
kCGImageSourceShouldCache: false,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
func urlSession(

View File

@@ -89,7 +89,9 @@ enum StoreKey<T> {
cleanupKeepMediaType<int>._(1009),
cleanupKeepAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011),
cleanupDefaultsInitialized<bool>._(1012);
cleanupDefaultsInitialized<bool>._(1012),
syncMigrationStatus<String>._(1013);
const StoreKey._(this.id);
final int id;

View File

@@ -1,4 +1,7 @@
// ignore_for_file: constant_identifier_names
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
@@ -7,12 +10,21 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
enum SyncMigrationTask {
v20260128_ResetExifV1, // EXIF table has incorrect width and height information.
v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations.
v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets.
}
class SyncStreamService {
final Logger _logger = Logger('SyncStreamService');
@@ -22,6 +34,8 @@ class SyncStreamService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
SyncStreamService({
@@ -31,6 +45,8 @@ class SyncStreamService {
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
@@ -38,12 +54,32 @@ class SyncStreamService {
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
final serverVersion = await _api.serverInfoApi.getServerVersion();
if (serverVersion == null) {
_logger.severe("Cannot perform sync: unable to determine server version");
return false;
}
final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
final migrations = (jsonDecode(value) as List).cast<String>();
int previousLength = migrations.length;
await _runPreSyncTasks(migrations, semVer);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
}
// Start the sync stream and handle events
bool shouldReset = false;
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
@@ -51,9 +87,56 @@ class SyncStreamService {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents);
}
previousLength = migrations.length;
await _runPostSyncTasks(migrations);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
}
return true;
}
Future<void> _runPreSyncTasks(List<String> migrations, SemVer semVer) async {
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetExifV1.name);
}
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetAssetV1.name);
if (!migrations.contains(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name)) {
migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name);
}
}
}
Future<void> _runPostSyncTasks(List<String> migrations) async {
if (!migrations.contains(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name)) {
_logger.info("Running post-sync task: v20260128_CopyExifWidthHeightToAsset");
await _syncMigrationRepository.v20260128CopyExifWidthHeightToAsset();
migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name);
}
}
Future<void> _handleEvents(List<SyncEvent> events, Function() abort, Function() reset) async {
List<SyncEvent> items = [];
for (final event in events) {

View File

@@ -144,16 +144,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) async {
final iosSharedAlbumIds = _db.localAlbumEntity.selectOnly()
..addColumns([_db.localAlbumEntity.id])
..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true));
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true));
..where(_db.localAlbumAssetEntity.albumId.isInQuery(iosSharedAlbumIds));
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),

View File

@@ -19,6 +19,10 @@ class SyncApiRepository {
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
}
Future<void> deleteSyncAck(List<SyncEntityType> types) {
return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: types));
}
Future<void> streamChanges(
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
Function()? onReset,

View File

@@ -0,0 +1,24 @@
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SyncMigrationRepository extends DriftDatabaseRepository {
final Drift _db;
const SyncMigrationRepository(super.db) : _db = db;
Future<void> v20260128CopyExifWidthHeightToAsset() async {
await _db.customStatement('''
UPDATE remote_asset_entity
SET width = CASE
WHEN exif.orientation IN ('5', '6', '7', '8', '-90', '90') THEN exif.height
ELSE exif.width
END,
height = CASE
WHEN exif.orientation IN ('5', '6', '7', '8', '-90', '90') THEN exif.width
ELSE exif.height
END
FROM remote_exif_entity exif
WHERE exif.asset_id = remote_asset_entity.id
AND (exif.width IS NOT NULL OR exif.height IS NOT NULL);
''');
}
}

View File

@@ -92,7 +92,9 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
// Hide controls by default for videos and motion photos
}
// Hide controls by default for videos
if (asset.isVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
@@ -147,6 +149,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (asset != null) {
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
if (ref.read(assetViewerProvider).showingControls) {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
} else {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
}
}
@override

View File

@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -13,6 +14,8 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
syncApiRepository: ref.watch(syncApiRepositoryProvider),
@@ -21,6 +24,8 @@ final syncStreamServiceProvider = Provider(
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider),
),
);

View File

@@ -28,6 +28,7 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
@@ -88,7 +89,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
if (version < 20 && Store.isBetaTimelineEnabled) {
await _syncLocalAlbumIsIosSharedAlbum(drift);
await _backfillAssetExifWidthHeight(drift);
}
if (targetVersion >= 12) {
@@ -282,22 +282,6 @@ Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
}
}
Future<void> _backfillAssetExifWidthHeight(Drift db) async {
try {
await db.customStatement('''
UPDATE remote_exif_entity AS remote_exif
SET width = asset.width,
height = asset.height
FROM remote_asset_entity AS asset
WHERE remote_exif.asset_id = asset.id;
''');
dPrint(() => "[MIGRATION] Successfully backfilled asset exif width and height");
} catch (error) {
dPrint(() => "[MIGRATION] Error while backfilling asset exif width and height: $error");
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();

View File

@@ -4,3 +4,5 @@ import 'package:openapi/api.dart';
class MockAssetsApi extends Mock implements AssetsApi {}
class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}

View File

@@ -19,12 +19,15 @@ import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../../api.mocks.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
class _AbortCallbackWrapper {
const _AbortCallbackWrapper();
@@ -50,6 +53,9 @@ void main() {
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late MockApiService mockApi;
late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo;
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
@@ -82,6 +88,9 @@ void main() {
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService();
mockServerApi = MockServerApi();
mockSyncMigrationRepo = MockSyncMigrationRepository();
when(() => mockAbortCallbackWrapper()).thenReturn(false);
@@ -94,6 +103,12 @@ void main() {
});
when(() => mockSyncApiRepo.ack(any())).thenAnswer((_) async => {});
when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {});
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when(() => mockServerApi.getServerVersion()).thenAnswer(
(_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0),
);
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -127,6 +142,7 @@ void main() {
when(() => mockSyncStreamRepo.deletePeopleV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
when(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset()).thenAnswer(successHandler);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
@@ -135,6 +151,8 @@ void main() {
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
@@ -216,6 +234,8 @@ void main() {
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
await sut.sync();
@@ -255,6 +275,8 @@ void main() {
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
await sut.sync();
@@ -474,11 +496,7 @@ void main() {
});
final events = [
SyncStreamStub.assetModified(
id: 'remote-1',
checksum: 'checksum-trash',
ack: 'asset-remote-1-11',
),
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'),
];
await simulateEvents(events);
@@ -486,4 +504,75 @@ void main() {
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
});
});
group('SyncStreamService - Sync Migration', () {
test('ensure that <2.5.0 migrations run', () async {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
verifyInOrder([
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
]),
() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset(),
]);
// should only run on server >2.5.0
verifyNever(
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]),
);
});
test('ensure that >=2.5.0 migrations run', () async {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
await sut.sync();
verifyInOrder([
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
]),
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]),
]);
// v20260128_ResetAssetV1 writes that v20260128_CopyExifWidthHeightToAsset has been completed
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
});
test('ensure that migrations do not re-run', () async {
await Store.put(
StoreKey.syncMigrationStatus,
'["${SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name}"]',
);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
});
});
}

View File

@@ -438,6 +438,39 @@ void main() {
expect(result.assets, isEmpty);
});
test('excludes user-owned assets in iOS shared albums', () async {
await insertLocalAlbum(id: 'album-recents', name: 'Recents', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true);
await insertLocalAsset(
id: 'local-user-shared',
checksum: 'checksum-user-shared',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-user-shared', checksum: 'checksum-user-shared', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-recents', assetId: 'local-user-shared');
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-user-shared');
// User's asset only in Recents (not in iOS shared album) - should be included for removal
await insertLocalAsset(
id: 'local-user-regular',
checksum: 'checksum-user-regular',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-user-regular', checksum: 'checksum-user-regular', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-recents', assetId: 'local-user-regular');
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-user-regular');
});
test('excludes assets with null checksum (not backed up)', () async {
// Asset with null checksum cannot be matched to remote asset
await db

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
@@ -46,6 +47,8 @@ class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
class MockUploadRepository extends Mock implements UploadRepository {}
class MockSyncMigrationRepository extends Mock implements SyncMigrationRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}

103
pnpm-lock.yaml generated
View File

@@ -9,7 +9,7 @@ overrides:
canvas: 2.11.2
sharp: ^0.34.5
packageExtensionsChecksum: sha256-vheqqqBU5SU8N8ma3OjzLM07nd511Xmy+mOvgxie+Ts=
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
@@ -421,9 +421,6 @@ importers:
bcrypt:
specifier: ^6.0.0
version: 6.0.0
better-sqlite3:
specifier: ^12.6.2
version: 12.6.2
body-parser:
specifier: ^2.2.0
version: 2.2.2
@@ -608,9 +605,6 @@ importers:
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/body-parser':
specifier: ^1.19.6
version: 1.19.6
@@ -5086,9 +5080,6 @@ packages:
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -6061,10 +6052,6 @@ packages:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
better-sqlite3@12.6.2:
resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -6072,9 +6059,6 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bits-ui@2.14.4:
resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==}
engines: {node: '>=20'}
@@ -7601,10 +7585,6 @@ packages:
resolution: {integrity: sha512-CpNH1FAhIQG5AlKndlTf05mNbuFxINyzG9629ZI/CKwr+39zWo8swxpnXc3GUfUvUfxkCCxumDPy2QVmi3XJkQ==}
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'}
@@ -7720,9 +7700,6 @@ packages:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -7923,9 +7900,6 @@ 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==}
@@ -9557,9 +9531,6 @@ 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==}
@@ -9622,10 +9593,6 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-abi@3.87.0:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -10572,11 +10539,6 @@ packages:
potpack@2.1.0:
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -11283,9 +11245,6 @@ packages:
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@15.22.0:
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
engines: {node: '>=0.12.18'}
@@ -11982,9 +11941,6 @@ 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==}
@@ -17899,10 +17855,6 @@ snapshots:
dependencies:
'@types/node': 24.10.9
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 24.10.9
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -19087,23 +19039,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
better-sqlite3@12.6.2:
dependencies:
bindings: 1.5.0
node-addon-api: 8.5.0
node-gyp: 12.1.0
prebuild-install: 7.1.3
transitivePeerDependencies:
- supports-color
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
dependencies:
'@floating-ui/core': 1.7.3
@@ -20869,8 +20808,6 @@ snapshots:
optionalDependencies:
exiftool-vendored.exe: 13.45.0
expand-template@2.0.3: {}
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@@ -21048,8 +20985,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -21266,8 +21201,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0: {}
github-slugger@1.5.0: {}
gl-matrix@3.4.4: {}
@@ -23383,8 +23316,6 @@ snapshots:
nanoid@5.1.6: {}
napi-build-utils@2.0.0: {}
natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {}
@@ -23449,10 +23380,6 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
node-abi@3.87.0:
dependencies:
semver: 7.7.3
node-abort-controller@3.1.1: {}
node-addon-api@4.3.0: {}
@@ -24441,21 +24368,6 @@ snapshots:
potpack@2.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.87.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
@@ -25392,7 +25304,8 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-concat@1.0.1:
optional: true
simple-get@3.1.1:
dependencies:
@@ -25401,12 +25314,6 @@ snapshots:
simple-concat: 1.0.1
optional: true
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-icons@15.22.0: {}
simple-icons@16.4.0: {}
@@ -26243,10 +26150,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
tweetnacl@0.14.5: {}
type-check@0.4.0:

View File

@@ -29,7 +29,6 @@ onlyBuiltDependencies:
- sharp
- '@tailwindcss/oxide'
- bcrypt
- better-sqlite3
overrides:
canvas: 2.11.2
sharp: ^0.34.5
@@ -60,10 +59,6 @@ packageExtensions:
dependencies:
node-addon-api: '*'
node-gyp: '*'
better-sqlite3:
dependencies:
node-addon-api: '*'
node-gyp: '*'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true

View File

@@ -6,4 +6,4 @@ if [[ "$IMMICH_ENV" == "production" ]]; then
fi
cd /usr/src/app || exit
exec pnpm --filter immich exec nest start --no-shell --debug "0.0.0.0:9230" --watch -- "$@"
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"

View File

@@ -61,7 +61,6 @@
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"chokidar": "^4.0.3",
@@ -125,7 +124,6 @@
"@types/archiver": "^7.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/body-parser": "^1.19.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",

View File

@@ -89,10 +89,8 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
}
async onModuleDestroy() {
console.log(`[${this.worker}] onModuleDestroy called - emitting AppShutdown`);
await this.eventRepository.emit('AppShutdown');
await teardownTelemetry();
console.log(`[${this.worker}] onModuleDestroy complete`);
}
}
@@ -150,8 +148,6 @@ export class ImmichAdminModule implements OnModuleDestroy {
constructor(private service: CliService) {}
async onModuleDestroy() {
console.log('[ImmichAdmin] onModuleDestroy called');
await this.service.cleanup();
console.log('[ImmichAdmin] onModuleDestroy complete');
}
}

View File

@@ -6,10 +6,6 @@ import {
PromptConfirmMoveQuestions,
PromptMediaLocationQuestions,
} from 'src/commands/media-location.command';
import {
MigrateThumbnailsCommand,
PromptConfirmMigrationQuestion,
} from 'src/commands/migrate-thumbnails.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
@@ -32,6 +28,4 @@ export const commandsAndQuestions = [
ChangeMediaLocationCommand,
PromptMediaLocationQuestions,
PromptConfirmMoveQuestions,
MigrateThumbnailsCommand,
PromptConfirmMigrationQuestion,
];

View File

@@ -1,88 +0,0 @@
import { Command, CommandRunner, InquirerService, Option, Question, QuestionSet } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'migrate-thumbnails-to-sqlite',
description: 'Migrate thumbnails from filesystem to SQLite storage',
})
export class MigrateThumbnailsCommand extends CommandRunner {
constructor(
private service: CliService,
private inquirer: InquirerService,
) {
super();
}
@Option({
flags: '-p, --path <path>',
description: 'Absolute path to the SQLite database file',
})
parsePath(value: string) {
return value;
}
@Option({
flags: '-y, --yes',
description: 'Skip confirmation prompt',
})
parseYes() {
return true;
}
async run(passedParams: string[], options: { path?: string; yes?: boolean }): Promise<void> {
try {
const sqlitePath = options.path ?? this.service.getDefaultThumbnailStoragePath();
console.log(`\nMigration settings:`);
console.log(` SQLite path: ${sqlitePath}`);
console.log(`\nThis will read all thumbnail files from the filesystem and store them in SQLite.`);
console.log(`Existing entries in SQLite will be skipped.\n`);
if (!options.yes) {
const { confirmed } = await this.inquirer.ask<{ confirmed: boolean }>('prompt-confirm-migration', {});
if (!confirmed) {
console.log('Migration cancelled.');
return;
}
}
console.log('\nStarting migration...\n');
let lastProgressUpdate = 0;
const result = await this.service.migrateThumbnailsToSqlite({
sqlitePath,
onProgress: ({ current, migrated, skipped, errors }) => {
const now = Date.now();
if (now - lastProgressUpdate > 500 || current === 1) {
lastProgressUpdate = now;
process.stdout.write(
`\rProcessed: ${current} | Migrated: ${migrated} | Skipped: ${skipped} | Errors: ${errors}`,
);
}
},
});
console.log(`\n\nMigration complete!`);
console.log(` Total processed: ${result.total}`);
console.log(` Migrated: ${result.migrated}`);
console.log(` Skipped: ${result.skipped}`);
console.log(` Errors: ${result.errors}`);
} catch (error) {
console.error(error);
console.error('Migration failed.');
}
}
}
@QuestionSet({ name: 'prompt-confirm-migration' })
export class PromptConfirmMigrationQuestion {
@Question({
message: 'Do you want to proceed with the migration? [Y/n]',
name: 'confirmed',
})
parseConfirmed(value: string): boolean {
return ['yes', 'y', ''].includes((value || 'y').toLowerCase());
}
}

View File

@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types';
import { ImmichBufferResponse, ImmichFileResponse, sendBuffer, sendFile } from 'src/utils/file';
import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets)
@@ -163,29 +163,26 @@ export class AssetMediaController {
if (viewThumbnailRes instanceof ImmichFileResponse) {
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
return;
}
if (viewThumbnailRes instanceof ImmichBufferResponse) {
await sendBuffer(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
return;
}
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {
redirPath = 'original';
redirSearchParams.delete('size');
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
redirPath = reqPath;
redirSearchParams.set('size', targetSize);
} else {
throw new Error('Invalid targetSize: ' + targetSize);
// viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {
// relative path to this.downloadAsset
redirPath = 'original';
redirSearchParams.delete('size');
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
redirPath = reqPath;
redirSearchParams.set('size', targetSize);
} else {
throw new Error('Invalid targetSize: ' + targetSize);
}
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
return res.redirect(finalRedirPath);
}
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
return res.redirect(finalRedirPath);
}
@Get(':id/video/playback')

View File

@@ -120,10 +120,6 @@ export class StorageCore {
);
}
static getThumbnailStoragePath(): string {
return join(StorageCore.getMediaLocation(), 'thumbnails.sqlite3');
}
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
}

View File

@@ -140,10 +140,6 @@ export class EnvDto {
@Optional()
IMMICH_WORKERS_EXCLUDE?: string;
@Optional()
@Matches(/^\//, { message: 'IMMICH_THUMBNAIL_SQLITE_PATH must be an absolute path' })
IMMICH_THUMBNAIL_SQLITE_PATH?: string;
@IsString()
@Optional()
DB_DATABASE_NAME?: string;

View File

@@ -25,16 +25,10 @@ class Workers {
*/
restarting = false;
/**
* Whether we're in graceful shutdown
*/
shuttingDown = false;
/**
* Boot all enabled workers
*/
async bootstrap() {
this.setupSignalHandlers();
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
@@ -120,12 +114,7 @@ class Workers {
} else {
const worker = new Worker(workerFile);
kill = () => {
// Post a shutdown message to allow graceful cleanup
worker.postMessage({ type: 'shutdown' });
// Force terminate after timeout if worker doesn't exit
setTimeout(() => void worker.terminate(), 5000);
};
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
@@ -135,53 +124,17 @@ class Workers {
this.workers[name] = { kill };
}
private setupSignalHandlers() {
const shutdown = async (signal: NodeJS.Signals) => {
if (this.shuttingDown) {
return;
}
this.shuttingDown = true;
console.log(`Received ${signal}, initiating graceful shutdown...`);
const workerNames = Object.keys(this.workers) as ImmichWorker[];
for (const name of workerNames) {
console.log(`Sending ${signal} to ${name} worker`);
await this.workers[name]?.kill(signal);
}
// Give workers time to shutdown gracefully
setTimeout(() => {
console.log('Shutdown timeout reached, forcing exit');
process.exit(0);
}, 10_000);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
}
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
console.log(`${name} worker exited with code ${exitCode}`);
delete this.workers[name];
// graceful shutdown in progress
if (this.shuttingDown) {
if (Object.keys(this.workers).length === 0) {
console.log('All workers have exited, shutting down main process');
process.exit(0);
}
return;
}
// restart immich server
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
// once all workers shut down, bootstrap again
if (Object.keys(this.workers).length === 0) {
@@ -192,9 +145,11 @@ class Workers {
return;
}
// unexpected exit - shutdown the entire process
// shutdown the entire process
delete this.workers[name];
if (exitCode !== 0) {
console.error(`${name} worker exited unexpectedly`);
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');

View File

@@ -402,13 +402,4 @@ export class AssetJobRepository {
streamForMigrationJob() {
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
}
@GenerateSql({ params: [], stream: true })
streamAllThumbnailFiles() {
return this.db
.selectFrom('asset_file')
.select(['asset_file.assetId', 'asset_file.type', 'asset_file.path', 'asset_file.isEdited'])
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview])
.stream();
}
}

View File

@@ -107,10 +107,6 @@ export interface EnvData {
mediaLocation?: string;
};
thumbnailStorage: {
sqlitePath?: string;
};
workers: ImmichWorker[];
plugins: {
@@ -335,10 +331,6 @@ const getEnv = (): EnvData => {
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
},
thumbnailStorage: {
sqlitePath: dto.IMMICH_THUMBNAIL_SQLITE_PATH,
},
telemetry: {
apiPort: dto.IMMICH_API_METRICS_PORT || 8081,
microservicesPort: dto.IMMICH_MICROSERVICES_METRICS_PORT || 8082,

View File

@@ -44,7 +44,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -99,7 +98,6 @@ export const repositories = [
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
ThumbnailStorageRepository,
TrashRepository,
UserRepository,
ViewRepository,

View File

@@ -2,7 +2,7 @@ import sharp from 'sharp';
import { AssetFace } from 'src/database';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageFormat, SourceType } from 'src/enum';
import { SourceType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { MediaRepository } from 'src/repositories/media.repository';
@@ -664,51 +664,4 @@ describe(MediaRepository.name, () => {
});
});
});
describe('generateThumbnailToBuffer', () => {
it('should return a buffer instead of writing to file', async () => {
const inputBuffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toBuffer();
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
format: ImageFormat.Webp,
quality: 80,
size: 50,
colorspace: 'srgb',
processInvalidImages: false,
});
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
const metadata = await sharp(result).metadata();
expect(metadata.format).toBe('webp');
expect(metadata.width).toBeLessThanOrEqual(50);
});
it('should apply same options as generateThumbnail', async () => {
const inputBuffer = await sharp({
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
})
.png()
.toBuffer();
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
format: ImageFormat.Jpeg,
quality: 90,
size: 75,
colorspace: 'srgb',
processInvalidImages: false,
progressive: true,
});
const metadata = await sharp(result).metadata();
expect(metadata.format).toBe('jpeg');
expect(metadata.width).toBe(75);
expect(metadata.height).toBe(75);
});
});
});

View File

@@ -182,17 +182,6 @@ export class MediaRepository {
await decoded.toFile(output);
}
async generateThumbnailToBuffer(input: string | Buffer, options: GenerateThumbnailOptions): Promise<Buffer> {
const pipeline = await this.getImageDecodingPipeline(input, options);
return pipeline
.toFormat(options.format, {
quality: options.quality,
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive,
})
.toBuffer();
}
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes

View File

@@ -1,305 +0,0 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { automock } from 'test/utils';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe(ThumbnailStorageRepository.name, () => {
let sut: ThumbnailStorageRepository;
let testDbPath: string;
beforeEach(() => {
testDbPath = join(tmpdir(), `immich-test-thumbnails-${randomUUID()}.db`);
const logger = automock(LoggingRepository, { args: [undefined, { getEnv: () => ({}) }], strict: false });
sut = new ThumbnailStorageRepository(logger);
});
afterEach(() => {
sut.close();
if (existsSync(testDbPath)) {
rmSync(testDbPath);
}
});
describe('initialize', () => {
it('should create database and schema', () => {
sut.initialize(testDbPath);
expect(sut.isEnabled()).toBe(true);
const db = new Database(testDbPath, { readonly: true });
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='thumbnails'").all();
db.close();
expect(tables).toHaveLength(1);
});
it('should enable WAL mode', () => {
sut.initialize(testDbPath);
const db = new Database(testDbPath, { readonly: true });
const result = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
db.close();
expect(result.journal_mode).toBe('wal');
});
});
describe('store', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(() => {
sut.initialize(testDbPath);
});
it('should store thumbnail data', async () => {
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
expect(result!.mimeType).toBe(mimeType);
expect(result!.size).toBe(testData.length);
});
it('should replace existing thumbnail on conflict', async () => {
const newData = Buffer.from('updated thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: newData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result!.data).toEqual(newData);
expect(result!.size).toBe(newData.length);
});
it('should store with correct mime type and size', async () => {
const jpegData = Buffer.from('jpeg thumbnail data with different size');
const jpegMimeType = 'image/jpeg';
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: jpegData,
mimeType: jpegMimeType,
});
const result = await sut.get(assetId, AssetFileType.Preview, false);
expect(result!.mimeType).toBe(jpegMimeType);
expect(result!.size).toBe(jpegData.length);
});
});
describe('get', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
});
it('should return stored thumbnail data', async () => {
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
expect(result!.mimeType).toBe(mimeType);
});
it('should return null for non-existent thumbnail', async () => {
const result = await sut.get(randomUUID(), AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
it('should distinguish between edited and non-edited', async () => {
const editedData = Buffer.from('edited thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const nonEditedResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
const editedResult = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(nonEditedResult!.data).toEqual(testData);
expect(editedResult!.data).toEqual(editedData);
});
it('should distinguish between different thumbnail types', async () => {
const previewData = Buffer.from('preview data');
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: previewData,
mimeType,
});
const thumbnailResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
expect(thumbnailResult!.data).toEqual(testData);
expect(previewResult!.data).toEqual(previewData);
});
it('should fall back to non-edited when edited is requested but not found', async () => {
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
});
it('should return edited when both exist and edited is requested', async () => {
const editedData = Buffer.from('edited thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(result!.data).toEqual(editedData);
});
it('should not fall back to edited when non-edited is requested but not found', async () => {
const newAssetId = randomUUID();
const editedData = Buffer.from('edited only data');
await sut.store({
assetId: newAssetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const result = await sut.get(newAssetId, AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
});
describe('delete', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: testData,
mimeType,
});
});
it('should delete specific thumbnail', async () => {
await sut.delete(assetId, AssetFileType.Thumbnail, false);
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
it('should not affect other thumbnails for same asset', async () => {
await sut.delete(assetId, AssetFileType.Thumbnail, false);
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
expect(previewResult).not.toBeNull();
});
});
describe('deleteByAsset', () => {
const assetId = randomUUID();
const otherAssetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
await sut.store({ assetId, type: AssetFileType.Preview, isEdited: false, data: testData, mimeType });
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: true, data: testData, mimeType });
await sut.store({ assetId: otherAssetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
});
it('should delete all thumbnails for an asset', async () => {
await sut.deleteByAsset(assetId);
expect(await sut.get(assetId, AssetFileType.Thumbnail, false)).toBeNull();
expect(await sut.get(assetId, AssetFileType.Preview, false)).toBeNull();
expect(await sut.get(assetId, AssetFileType.Thumbnail, true)).toBeNull();
});
it('should not affect other assets', async () => {
await sut.deleteByAsset(assetId);
const otherAssetResult = await sut.get(otherAssetId, AssetFileType.Thumbnail, false);
expect(otherAssetResult).not.toBeNull();
});
});
describe('isEnabled', () => {
it('should return false when database is not initialized', () => {
expect(sut.isEnabled()).toBe(false);
});
it('should return true when database is initialized', () => {
sut.initialize(testDbPath);
expect(sut.isEnabled()).toBe(true);
});
});
});

View File

@@ -1,160 +0,0 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Database, { Database as DatabaseType, Statement } from 'better-sqlite3';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
export interface ThumbnailData {
assetId: string;
type: AssetFileType;
isEdited: boolean;
data: Buffer;
mimeType: string;
}
export interface ThumbnailResult {
data: Buffer;
mimeType: string;
size: number;
}
interface ThumbnailRow {
data: Buffer;
mime_type: string;
size: number;
}
@Injectable()
export class ThumbnailStorageRepository implements OnModuleDestroy {
private database: DatabaseType | null = null;
private insertStatement: Statement | null = null;
private selectStatement: Statement | null = null;
private deleteStatement: Statement | null = null;
private deleteByAssetStatement: Statement | null = null;
constructor(private logger: LoggingRepository) {
this.logger.setContext(ThumbnailStorageRepository.name);
}
initialize(databasePath: string): void {
this.database = new Database(databasePath);
this.database.pragma('page_size = 32768');
this.database.pragma('journal_mode = WAL');
this.database.pragma('synchronous = NORMAL');
this.database.pragma('cache_size = -131072');
this.database.pragma('mmap_size = 2147483648');
this.database.pragma('temp_store = MEMORY');
this.database.pragma('wal_autocheckpoint = 10000');
this.database.exec(`
CREATE TABLE IF NOT EXISTS thumbnails (
asset_id TEXT NOT NULL,
type TEXT NOT NULL,
is_edited INTEGER NOT NULL DEFAULT 0,
data BLOB NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
PRIMARY KEY (asset_id, type, is_edited)
)
`);
this.insertStatement = this.database.prepare(`
INSERT OR REPLACE INTO thumbnails (asset_id, type, is_edited, data, mime_type, size)
VALUES (?, ?, ?, ?, ?, ?)
`);
this.selectStatement = this.database.prepare(`
SELECT data, mime_type, size FROM thumbnails
WHERE asset_id = ? AND type = ? AND is_edited = ?
`);
this.deleteStatement = this.database.prepare(`
DELETE FROM thumbnails WHERE asset_id = ? AND type = ? AND is_edited = ?
`);
this.deleteByAssetStatement = this.database.prepare(`
DELETE FROM thumbnails WHERE asset_id = ?
`);
this.logger.log(`SQLite thumbnail storage initialized at ${databasePath}`);
}
isEnabled(): boolean {
return this.database !== null;
}
store(thumbnail: ThumbnailData): void {
if (!this.insertStatement) {
throw new Error('SQLite thumbnail storage not initialized');
}
const isEditedInt = thumbnail.isEdited ? 1 : 0;
this.insertStatement.run(
thumbnail.assetId,
thumbnail.type,
isEditedInt,
thumbnail.data,
thumbnail.mimeType,
thumbnail.data.length,
);
}
get(assetId: string, type: AssetFileType, isEdited: boolean): ThumbnailResult | null {
if (!this.selectStatement) {
return null;
}
const isEditedInt = isEdited ? 1 : 0;
let result = this.selectStatement.get(assetId, type, isEditedInt) as ThumbnailRow | undefined;
if (!result && isEdited) {
result = this.selectStatement.get(assetId, type, 0) as ThumbnailRow | undefined;
}
if (!result) {
return null;
}
return {
data: result.data,
mimeType: result.mime_type,
size: result.size,
};
}
delete(assetId: string, type: AssetFileType, isEdited: boolean): void {
if (!this.deleteStatement) {
return;
}
const isEditedInt = isEdited ? 1 : 0;
this.deleteStatement.run(assetId, type, isEditedInt);
}
deleteByAsset(assetId: string): void {
if (!this.deleteByAssetStatement) {
return;
}
this.deleteByAssetStatement.run(assetId);
}
close(): void {
if (this.database) {
this.logger.log('Closing SQLite thumbnail storage database');
this.database.pragma('wal_checkpoint(TRUNCATE)');
this.database.close();
this.database = null;
this.insertStatement = null;
this.selectStatement = null;
this.deleteStatement = null;
this.deleteByAssetStatement = null;
this.logger.log('SQLite thumbnail storage database closed');
}
}
onModuleDestroy(): void {
this.logger.log('onModuleDestroy called - closing SQLite thumbnail storage');
this.close();
}
}

View File

@@ -14,7 +14,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichBufferResponse, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
@@ -762,128 +762,6 @@ describe(AssetMediaService.name, () => {
);
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
});
describe('with SQLite storage', () => {
it('should return ImmichBufferResponse when thumbnail exists in SQLite', async () => {
const thumbnailData = Buffer.from('thumbnail-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: thumbnailData,
mimeType: 'image/webp',
size: thumbnailData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichBufferResponse({
data: thumbnailData,
contentType: 'image/webp',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_thumbnail.webp',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
expect(mocks.asset.getForThumbnail).not.toHaveBeenCalled();
});
it('should fall back to filesystem when thumbnail not in SQLite', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue(null);
mocks.asset.getForThumbnail.mockResolvedValue({
...assetStub.image,
path: '/uploads/user-id/thumbs/path.jpg',
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/thumbs/path.jpg',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
});
it('should handle edited thumbnails from SQLite', async () => {
const thumbnailData = Buffer.from('edited-thumbnail-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: thumbnailData,
mimeType: 'image/webp',
size: thumbnailData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }),
).resolves.toEqual(
new ImmichBufferResponse({
data: thumbnailData,
contentType: 'image/webp',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_thumbnail.webp',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
});
it('should handle preview size from SQLite', async () => {
const previewData = Buffer.from('preview-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: previewData,
mimeType: 'image/jpeg',
size: previewData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).resolves.toEqual(
new ImmichBufferResponse({
data: previewData,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_preview.jpg',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Preview, false);
});
it('should skip SQLite lookup when storage is disabled', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
mocks.asset.getForThumbnail.mockResolvedValue({
...assetStub.image,
path: '/uploads/user-id/thumbs/path.jpg',
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/thumbs/path.jpg',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg',
}),
);
expect(mocks.thumbnailStorage.get).not.toHaveBeenCalled();
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
});
});
});
describe('playbackVideo', () => {

View File

@@ -37,12 +37,7 @@ import { UploadFile, UploadRequest } from 'src/types';
import { requireUploadAccess } from 'src/utils/access';
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import {
getFilenameExtension,
getFileNameWithoutExtension,
ImmichBufferResponse,
ImmichFileResponse,
} from 'src/utils/file';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
@@ -224,7 +219,7 @@ export class AssetMediaService extends BaseService {
auth: AuthDto,
id: string,
dto: AssetMediaOptionsDto,
): Promise<ImmichFileResponse | ImmichBufferResponse | AssetMediaRedirectResponse> {
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
if (dto.size === AssetMediaSize.Original) {
@@ -236,30 +231,20 @@ export class AssetMediaService extends BaseService {
}
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
const isEdited = dto.edited ?? false;
if (this.thumbnailStorageRepository.isEnabled()) {
const thumbnail = this.thumbnailStorageRepository.get(id, size, isEdited);
if (thumbnail) {
// this.logger.log(`Thumbnail served from SQLite: assetId=${id}, type=${size}`);
const extension = mimeTypes.toExtension(thumbnail.mimeType) || '';
const fileName = `${id}_${size}${extension}`;
return new ImmichBufferResponse({
data: thumbnail.data,
contentType: thumbnail.mimeType,
cacheControl: CacheControl.PrivateWithCache,
fileName,
});
}
}
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(id, size, isEdited);
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
id,
size,
dto.edited ?? false,
);
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
// use original file for web supported images
return { targetSize: 'original' };
}
if (dto.size === AssetMediaSize.FULLSIZE && !path) {
// downgrade to preview if fullsize is not available.
// e.g. disabled or not yet (re)generated
return { targetSize: AssetMediaSize.PREVIEW };
}

View File

@@ -51,7 +51,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -109,7 +108,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
ThumbnailStorageRepository,
TrashRepository,
UserRepository,
VersionHistoryRepository,
@@ -169,7 +167,6 @@ export class BaseService {
protected systemMetadataRepository: SystemMetadataRepository,
protected tagRepository: TagRepository,
protected telemetryRepository: TelemetryRepository,
protected thumbnailStorageRepository: ThumbnailStorageRepository,
protected trashRepository: TrashRepository,
protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository,

View File

@@ -1,14 +1,12 @@
import { Injectable } from '@nestjs/common';
import { isAbsolute } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { AssetFileType, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
import { mimeTypes } from 'src/utils/mime-types';
@Injectable()
export class CliService extends BaseService {
@@ -188,88 +186,4 @@ export class CliService extends BaseService {
cleanup() {
return this.databaseRepository.shutdown();
}
getDefaultThumbnailStoragePath(): string {
const envData = this.configRepository.getEnv();
if (envData.thumbnailStorage.sqlitePath) {
return envData.thumbnailStorage.sqlitePath;
}
const mediaLocation = envData.storage.mediaLocation ?? this.detectMediaLocation();
StorageCore.setMediaLocation(mediaLocation);
return StorageCore.getThumbnailStoragePath();
}
private detectMediaLocation(): string {
const candidates = ['/data', '/usr/src/app/upload'];
for (const candidate of candidates) {
if (this.storageRepository.existsSync(candidate)) {
return candidate;
}
}
return '/usr/src/app/upload';
}
async migrateThumbnailsToSqlite(options: {
sqlitePath: string;
onProgress: (progress: { current: number; migrated: number; skipped: number; errors: number }) => void;
}): Promise<{ total: number; migrated: number; skipped: number; errors: number }> {
const { sqlitePath, onProgress } = options;
if (!isAbsolute(sqlitePath)) {
throw new Error('SQLite path must be an absolute path');
}
this.thumbnailStorageRepository.initialize(sqlitePath);
let current = 0;
let migrated = 0;
let skipped = 0;
let errors = 0;
for await (const file of this.assetJobRepository.streamAllThumbnailFiles()) {
current++;
try {
const existingData = this.thumbnailStorageRepository.get(
file.assetId,
file.type as AssetFileType,
file.isEdited,
);
if (existingData) {
skipped++;
onProgress({ current, migrated, skipped, errors });
continue;
}
const fileExists = await this.storageRepository.checkFileExists(file.path);
if (!fileExists) {
skipped++;
onProgress({ current, migrated, skipped, errors });
continue;
}
const data = await this.storageRepository.readFile(file.path);
const mimeType = mimeTypes.lookup(file.path) || 'image/jpeg';
this.thumbnailStorageRepository.store({
assetId: file.assetId,
type: file.type as AssetFileType,
isEdited: file.isEdited,
data,
mimeType,
});
migrated++;
} catch (error) {
errors++;
this.logger.error(`Failed to migrate thumbnail for asset ${file.assetId}: ${error}`);
}
onProgress({ current, migrated, skipped, errors });
}
return { total: current, migrated, skipped, errors };
}
}

View File

@@ -1128,82 +1128,6 @@ describe(MediaService.name, () => {
expect.stringContaining('fullsize.jpeg'),
);
});
describe('with SQLite storage enabled', () => {
beforeEach(() => {
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
});
it('should store thumbnail in SQLite when enabled', async () => {
const thumbnailBuffer = Buffer.from('thumbnail-data');
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnailToBuffer).toHaveBeenCalledTimes(2);
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
assetId: assetStub.image.id,
type: AssetFileType.Thumbnail,
isEdited: false,
data: thumbnailBuffer,
mimeType: 'image/webp',
});
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
assetId: assetStub.image.id,
type: AssetFileType.Preview,
isEdited: false,
data: thumbnailBuffer,
mimeType: 'image/jpeg',
});
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should not write to filesystem when SQLite is enabled', async () => {
const thumbnailBuffer = Buffer.from('thumbnail-data');
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
});
it('should store with correct mime type for JPEG preview', async () => {
const thumbnailBuffer = Buffer.from('preview-data');
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { format: ImageFormat.Jpeg } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith(
expect.objectContaining({
type: AssetFileType.Preview,
mimeType: 'image/jpeg',
}),
);
});
});
describe('with SQLite storage disabled', () => {
beforeEach(() => {
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
});
it('should continue using filesystem when SQLite is disabled', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
expect(mocks.media.generateThumbnailToBuffer).not.toHaveBeenCalled();
expect(mocks.thumbnailStorage.store).not.toHaveBeenCalled();
});
});
});
describe('handleAssetEditThumbnailGeneration', () => {

View File

@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AssetFileType,
@@ -35,7 +35,6 @@ import {
ImageDimensions,
JobItem,
JobOf,
RawImageInfo,
VideoFormat,
VideoInterfaces,
VideoStreamInfo,
@@ -321,17 +320,8 @@ export class MediaService extends BaseService {
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage;
// generate final images
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
if (this.thumbnailStorageRepository.isEnabled()) {
return this.generateImageThumbnailsToSqlite(asset, data, image, thumbnailOptions, useEdits, fullsizeDimensions);
} else {
console.log('not enabled')
}
// generate final images to filesystem
const promises = [
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
@@ -386,6 +376,9 @@ export class MediaService extends BaseService {
await Promise.all(promises);
}
const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
return {
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
thumbhash: outputs[0] as Buffer,
@@ -393,42 +386,6 @@ export class MediaService extends BaseService {
};
}
private async generateImageThumbnailsToSqlite(
asset: ThumbnailAsset,
data: Buffer,
image: SystemConfig['image'],
thumbnailOptions: { colorspace: Colorspace; processInvalidImages: boolean; raw: RawImageInfo; edits: AssetEditActionItem[] },
useEdits: boolean,
fullsizeDimensions: ImageDimensions,
) {
const [thumbnailBuffer, previewBuffer, thumbhash] = await Promise.all([
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.thumbnail, ...thumbnailOptions }),
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.preview, ...thumbnailOptions }),
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
]);
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.Thumbnail,
isEdited: useEdits,
data: thumbnailBuffer,
mimeType: `image/${image.thumbnail.format}`,
});
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.Preview,
isEdited: useEdits,
data: previewBuffer,
mimeType: `image/${image.preview.format}`,
});
return {
files: [] as UpsertFileOptions[],
thumbhash,
fullsizeDimensions,
};
}
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });

View File

@@ -45,7 +45,6 @@ export class StorageService extends BaseService {
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
async onBootstrap() {
StorageCore.setMediaLocation(this.detectMediaLocation());
this.initializeThumbnailStorage();
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags =
@@ -134,12 +133,6 @@ export class StorageService extends BaseService {
});
}
private initializeThumbnailStorage(): void {
const { thumbnailStorage } = this.configRepository.getEnv();
const path = thumbnailStorage.sqlitePath ?? StorageCore.getThumbnailStoragePath();
this.thumbnailStorageRepository.initialize(path);
}
@OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask })
async handleDeleteFiles(job: JobOf<JobName.FileDelete>): Promise<JobStatus> {
const { files } = job;

View File

@@ -30,17 +30,6 @@ export class ImmichFileResponse {
Object.assign(this, response);
}
}
export class ImmichBufferResponse {
public readonly data!: Buffer;
public readonly contentType!: string;
public readonly cacheControl!: CacheControl;
public readonly fileName?: string;
constructor(response: ImmichBufferResponse) {
Object.assign(this, response);
}
}
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
@@ -93,40 +82,6 @@ export const sendFile = async (
}
};
export const sendBuffer = async (
res: Response,
next: NextFunction,
handler: () => Promise<ImmichBufferResponse> | ImmichBufferResponse,
logger: LoggingRepository,
): Promise<void> => {
try {
const file = await handler();
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
if (cacheControlHeader) {
res.set('Cache-Control', cacheControlHeader);
}
res.header('Content-Type', file.contentType);
res.header('Content-Length', file.data.length.toString());
if (file.fileName) {
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
}
res.send(file.data);
} catch (error: Error | any) {
if (isConnectionAborted(error) || res.headersSent) {
return;
}
if (error instanceof HttpException === false) {
logger.error(`Unable to send buffer: ${error}`, error.stack);
}
res.header('Cache-Control', 'none');
next(error);
}
};
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length });
};

View File

@@ -12,7 +12,6 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
app.enableShutdownHooks();
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {

View File

@@ -11,7 +11,6 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
app.enableShutdownHooks();
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {

View File

@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core';
import { isMainThread, parentPort } from 'node:worker_threads';
import { isMainThread } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
@@ -16,7 +16,6 @@ export async function bootstrap() {
}
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
app.enableShutdownHooks();
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(AppRepository).setCloseFn(() => app.close());
@@ -30,24 +29,13 @@ export async function bootstrap() {
await (host ? app.listen(0, host) : app.listen(0));
logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `);
return app;
}
if (!isMainThread) {
bootstrap()
.then((app) => {
parentPort?.on('message', (message) => {
if (message?.type === 'shutdown') {
console.log('Microservices worker received shutdown message');
void app.close();
}
});
})
.catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
throw error;
});
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
throw error;
});
}

View File

@@ -84,10 +84,6 @@ const envData: EnvData = {
ignoreMountCheckErrors: false,
},
thumbnailStorage: {
sqlitePath: undefined,
},
telemetry: {
apiPort: 8081,
microservicesPort: 8082,

View File

@@ -5,7 +5,6 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbnailToBuffer: vitest.fn().mockResolvedValue(Buffer.from('')),
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),

View File

@@ -1,15 +0,0 @@
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
export const newThumbnailStorageRepositoryMock = (): Mocked<RepositoryInterface<ThumbnailStorageRepository>> => {
return {
initialize: vitest.fn(),
isEnabled: vitest.fn().mockReturnValue(false),
store: vitest.fn(),
get: vitest.fn(),
delete: vitest.fn(),
deleteByAsset: vitest.fn(),
close: vitest.fn(),
};
};

View File

@@ -60,7 +60,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -82,7 +81,6 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newThumbnailStorageRepositoryMock } from 'test/repositories/thumbnail-storage.repository.mock';
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { assert, Mock, Mocked, vitest } from 'vitest';
@@ -257,7 +255,6 @@ export type ServiceOverrides = {
systemMetadata: SystemMetadataRepository;
tag: TagRepository;
telemetry: TelemetryRepository;
thumbnailStorage: ThumbnailStorageRepository;
trash: TrashRepository;
user: UserRepository;
versionHistory: VersionHistoryRepository;
@@ -335,7 +332,6 @@ export const getMocks = () => {
// eslint-disable-next-line no-sparse-arrays
tag: automock(TagRepository, { args: [, loggerMock], strict: false }),
telemetry: newTelemetryRepositoryMock(),
thumbnailStorage: newThumbnailStorageRepositoryMock(),
trash: automock(TrashRepository),
user: automock(UserRepository, { strict: false }),
versionHistory: automock(VersionHistoryRepository),
@@ -401,7 +397,6 @@ export const newTestService = <T extends BaseService>(
overrides.systemMetadata || (mocks.systemMetadata as As<SystemMetadataRepository>),
overrides.tag || (mocks.tag as As<TagRepository>),
overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository),
overrides.thumbnailStorage || (mocks.thumbnailStorage as As<ThumbnailStorageRepository>),
overrides.trash || (mocks.trash as As<TrashRepository>),
overrides.user || (mocks.user as As<UserRepository>),
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),

View File

@@ -97,7 +97,6 @@
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });

View File

@@ -141,43 +141,41 @@
}
};
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
return shortcuts;
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />

View File

@@ -3,6 +3,7 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
@@ -55,7 +56,7 @@
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
let sharedLinks: SharedLinkResponseDto[] = $state([]);

View File

@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
@@ -25,7 +26,7 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -39,7 +40,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
const isOwned = get(user).id === album.ownerId;
const Share: ActionItem = {
@@ -66,7 +67,16 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
};
return { Share, AddUsers, CreateSharedLink };
const Close: ActionItem = {
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: () => goto(Route.albums()),
$if: () => viewMode === AlbumPageViewMode.VIEW,
shortcuts: { key: 'Escape' },
};
return { Share, AddUsers, CreateSharedLink, Close };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, timeToSeconds } from './date-time';
import { getAlbumDateRange, getShortDateRange, timeToSeconds } from './date-time';
describe('converting time to seconds', () => {
it('parses hh:mm:ss correctly', () => {
@@ -49,6 +49,43 @@ describe('converting time to seconds', () => {
});
});
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
});
afterAll(() => {
vi.unstubAllEnvs();
});
it('should correctly return month if start and end date are within the same month', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years', () => {
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
});
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';

View File

@@ -19,28 +19,30 @@ export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
}
export const getShortDateRange = (startDate: string | Date, endDate: string | Date) => {
startDate = startDate instanceof Date ? startDate : new Date(startDate);
endDate = endDate instanceof Date ? endDate : new Date(endDate);
export const getShortDateRange = (startTimestamp: string, endTimestamp: string) => {
const userLocale = get(locale);
const endDateLocalized = endDate.toLocaleString(userLocale, {
let startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
let endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
if (userLocale) {
startDate = startDate.setLocale(userLocale);
endDate = endDate.setLocale(userLocale);
}
const endDateLocalized = endDate.toLocaleString({
month: 'short',
year: 'numeric',
// The API returns the date in UTC. If the earliest asset was taken on Jan 1st at 1am,
// we expect the album to start in January, even if the local timezone is UTC-5 for instance.
timeZone: 'UTC',
});
if (startDate.getFullYear() === endDate.getFullYear()) {
if (startDate.getMonth() === endDate.getMonth()) {
if (startDate.year === endDate.year) {
if (startDate.month === endDate.month) {
// Same year and month.
// e.g.: aug. 2024
return endDateLocalized;
} else {
// Same year but different month.
// e.g.: jul. - sept. 2024
const startMonthLocalized = startDate.toLocaleString(userLocale, {
const startMonthLocalized = startDate.toLocaleString({
month: 'short',
});
return `${startMonthLocalized} - ${endDateLocalized}`;
@@ -48,7 +50,7 @@ export const getShortDateRange = (startDate: string | Date, endDate: string | Da
} else {
// Different year.
// e.g.: feb. 2021 - sept. 2024
const startDateLocalized = startDate.toLocaleString(userLocale, {
const startDateLocalized = startDate.toLocaleString({
month: 'short',
year: 'numeric',
});

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, goto, onNavigate } from '$app/navigation';
import { goto, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ActionButton from '$lib/components/ActionButton.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
@@ -52,13 +52,7 @@
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import {
isAlbumsRoute,
isPeopleRoute,
isSearchRoute,
navigate,
type AssetGridRouteSearchParams,
} from '$lib/utils/navigation';
import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { AlbumUserRole, AssetVisibility, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import {
@@ -91,7 +85,6 @@
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let backUrl: string = $state(Route.albums());
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -100,25 +93,6 @@
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname;
const route = from?.route?.id;
if (isSearchRoute(route)) {
url = from?.url.href;
}
if (isAlbumsRoute(route) || isPeopleRoute(route)) {
url = Route.albums();
}
backUrl = url || Route.albums();
if (backUrl === Route.sharedLinks()) {
backUrl = history.state?.backUrl || Route.albums();
}
});
const handleFavorite = async () => {
try {
await activityManager.toggleLike();
@@ -158,7 +132,6 @@
cancelMultiselect(assetInteraction);
return;
}
await goto(backUrl);
return;
};
@@ -305,7 +278,7 @@
const onAlbumDelete = async ({ id }: AlbumResponseDto) => {
if (id === album.id) {
await goto(backUrl);
await goto(Route.albums());
viewMode = AlbumPageViewMode.VIEW;
}
};
@@ -332,7 +305,7 @@
};
const { Cast } = $derived(getGlobalActions($t));
const { Share } = $derived(getAlbumActions($t, album));
const { Share, Close } = $derived(getAlbumActions($t, album, viewMode));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
</script>
@@ -346,7 +319,7 @@
onAlbumUserDelete={refreshAlbum}
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
/>
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload]} />
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload, Close]} />
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: Route.albums() }}>
<div class="relative w-full shrink">
@@ -512,7 +485,7 @@
</AssetSelectControlBar>
{:else}
{#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
{#snippet trailing()}
<ActionButton action={Cast} />

View File

@@ -33,7 +33,6 @@
import { Route } from '$lib/route';
import { getPersonActions } from '$lib/services/person.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
@@ -61,7 +60,6 @@
let { data }: Props = $props();
let numberOfAssets = $derived(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore;
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = $derived({ visibility: AssetVisibility.Timeline, personId: data.person.id });
@@ -106,16 +104,13 @@
});
const handleEscape = async () => {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
return;
} else {
await goto(previousRoute);
return;
}
await goto(previousRoute);
return;
};
const updateAssetCount = async () => {