Compare commits

...

12 Commits

Author SHA1 Message Date
shenlong-tanwen
6733c14f76 fix(mobile): handle missing extension during uploads 2025-12-21 06:37:48 +05:30
Paul Makles
a17f188e97 fix(maintenance): prevent enable/disable maintenance CLI hanging on occasion (#24713)
* fix(maintenance): prevent CLI hanging on occassion
fix(maintenance): always ack messages
fix(maintenance): ensure Redis is connected first

* chore(maintenance): validate app restart responses

* chore: mock the app restart callback

* fix: ack may not exist depending on caller

* refactor: move one shot into app.repository

* fix: send correct state in one shot

* chore: log restart event
2025-12-19 17:13:00 -05:00
Jason Rasmussen
5b80323326 refactor: library service (#24725) 2025-12-19 13:20:35 -05:00
Jason Rasmussen
1425b3da6b refactor: admin card (#24723) 2025-12-19 12:47:04 -05:00
Daniel Dietzler
3d2196b0f2 refactor: asset update medium tests (#24718) 2025-12-19 16:25:04 +00:00
github-actions
50d7956c07 chore: version v2.4.1 2025-12-19 15:03:03 +00:00
Théo
22d3fd3b92 fix(docs): add & fix missing alt text to store badge images (#24637)
* Fix email footer: add missing alt text to store badge images

* fix: apply consistent formatting using Prettier

---------

Co-authored-by: divulgacheur <contact@theopeltier.me>
2025-12-19 09:00:31 -06:00
Luis Nachtigall
a469e86b32 fix(web): search-bar usability improvements (#24705)
* fix(search): improve search type dropdown accessibility and focus management

* fix(search): fix search options button accessibility position in search bar

* fix(search): removed unnecessary selection logic
2025-12-19 14:59:41 +00:00
Timon
138c9232df chore: update ApiKeySecretModal to use monospace font (#24690)
style(web): update ApiKeySecretModal to use monospace font for readonly textarea
2025-12-19 08:58:49 -06:00
Timon
2e1f8625ec fix(web): timezone dropdown showing "No results" when seconds are set to 00 (#24662)
* Fix timezone dropdown showing "No results" when seconds are set to 00

* remove comments

* add test for #23615
2025-12-19 08:58:17 -06:00
Daniel Dietzler
f7cbb7417c fix: dateTimeOriginal timezone updates (#24712) 2025-12-19 08:42:44 -06:00
Alex
125de91c71 fix: merged video in On This Device played with incorrect dimension (#24656)
* fix: merged video in On This Device played with incorrect dimension

* chore: pr feedback
2025-12-18 20:59:58 -06:00
37 changed files with 897 additions and 437 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.104", "version": "2.2.105",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",

View File

@@ -1,4 +1,8 @@
[ [
{
"label": "v2.4.1",
"url": "https://docs.v2.4.1.archive.immich.app"
},
{ {
"label": "v2.4.0", "label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app" "url": "https://docs.v2.4.0.archive.immich.app"

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.4.0", "version": "2.4.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.4.0" version = "2.4.1"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0" requires-python = ">=3.10,<4.0"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3029, "android.injected.version.code" => 3030,
"android.injected.version.name" => "2.4.0", "android.injected.version.name" => "2.4.1",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -6,6 +6,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService { class AssetService {
final RemoteAssetRepository _remoteAssetRepository; final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
@@ -58,44 +60,48 @@ class AssetService {
} }
Future<double> getAspectRatio(BaseAsset asset) async { Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped; final dimension = asset is LocalAsset
double? width; ? await _getLocalAssetDimensions(asset)
double? height; : await _getRemoteAssetDimensions(asset as RemoteAsset);
if (asset.hasRemote) { if (dimension.width == null || dimension.height == null || dimension.height == 0) {
final exif = await getExif(asset); return 1.0;
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
} }
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) { if (width == null || height == null) {
if (asset.hasRemote) { final fetched = await _localAssetRepository.get(asset.id);
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id; width = fetched?.width?.toDouble();
final remoteAsset = await _remoteAssetRepository.get(id); height = fetched?.height?.toDouble();
width = remoteAsset?.width?.toDouble(); orientation = fetched?.orientation ?? 0;
height = remoteAsset?.height?.toDouble();
} else {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
final localAsset = await _localAssetRepository.get(id);
width = localAsset?.width?.toDouble();
height = localAsset?.height?.toDouble();
}
} }
final orientedWidth = isFlipped ? height : width; // On Android, local assets need orientation correction for 90°/270° rotations
final orientedHeight = isFlipped ? width : height; // On iOS, the Photos framework pre-corrects dimensions
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) { final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return orientedWidth / orientedHeight; return (width: width, height: height, isFlipped: isFlipped);
}
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
} }
return 1.0; final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
} }
Future<List<(String, String)>> getPlaces(String userId) { Future<List<(String, String)>> getPlaces(String userId) {

View File

@@ -271,12 +271,12 @@ class UploadService {
return null; return null;
} }
final file = await _storageRepository.getFileForAsset(asset.id); final uploadFileResult = await prepareUploadFile(asset, isLivePhoto: entity.isLivePhoto);
if (file == null) { if (uploadFileResult == null) {
return null; return null;
} }
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; final (:file, :originalFilename) = uploadFileResult;
String metadata = UploadTaskMetadata( String metadata = UploadTaskMetadata(
localAssetId: asset.id, localAssetId: asset.id,
@@ -290,7 +290,7 @@ class UploadService {
file, file,
createdAt: asset.createdAt, createdAt: asset.createdAt,
modifiedAt: asset.updatedAt, modifiedAt: asset.updatedAt,
originalFileName: originalFileName, originalFileName: originalFilename,
deviceAssetId: asset.id, deviceAssetId: asset.id,
metadata: metadata, metadata: metadata,
group: "group", group: "group",
@@ -308,8 +308,6 @@ class UploadService {
return null; return null;
} }
File? file;
/// iOS LivePhoto has two files: a photo and a video. /// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId /// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task. /// The assetId is then used as a metadata for the photo file upload task.
@@ -320,18 +318,12 @@ class UploadService {
/// The cancel operation will only cancel the video group (normal group), the photo group will not /// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded. /// be touched, as the video file is already uploaded.
if (entity.isLivePhoto) { final uploadFileResult = await prepareUploadFile(asset, isLivePhoto: entity.isLivePhoto);
file = await _storageRepository.getMotionFileForAsset(asset); if (uploadFileResult == null) {
} else {
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) {
return null; return null;
} }
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; final (:file, :originalFilename) = uploadFileResult;
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
String metadata = UploadTaskMetadata( String metadata = UploadTaskMetadata(
localAssetId: asset.id, localAssetId: asset.id,
@@ -345,7 +337,7 @@ class UploadService {
file, file,
createdAt: asset.createdAt, createdAt: asset.createdAt,
modifiedAt: asset.updatedAt, modifiedAt: asset.updatedAt,
originalFileName: originalFileName, originalFileName: originalFilename,
deviceAssetId: asset.id, deviceAssetId: asset.id,
metadata: metadata, metadata: metadata,
group: group, group: group,
@@ -362,21 +354,20 @@ class UploadService {
return null; return null;
} }
final file = await _storageRepository.getFileForAsset(asset.id); final result = await prepareUploadFile(asset);
if (file == null) { if (result == null) {
return null; return null;
} }
final fields = {'livePhotoVideoId': livePhotoVideoId}; final fields = {'livePhotoVideoId': livePhotoVideoId};
final requiresWiFi = _shouldRequireWiFi(asset); final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
return buildUploadTask( return buildUploadTask(
file, result.file,
createdAt: asset.createdAt, createdAt: asset.createdAt,
modifiedAt: asset.updatedAt, modifiedAt: asset.updatedAt,
originalFileName: originalFileName, originalFileName: result.originalFilename,
deviceAssetId: asset.id, deviceAssetId: asset.id,
fields: fields, fields: fields,
group: kBackupLivePhotoGroup, group: kBackupLivePhotoGroup,
@@ -398,6 +389,54 @@ class UploadService {
return requiresWiFi; return requiresWiFi;
} }
@visibleForTesting
Future<({File file, String originalFilename})?> prepareUploadFile(
LocalAsset asset, {
bool isLivePhoto = false,
}) async {
final file = isLivePhoto
? await _storageRepository.getMotionFileForAsset(asset)
: await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
final originalFilename = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
if (isLivePhoto) {
final livePhotoFilename = p.setExtension(originalFilename, p.extension(file.path));
return (file: file, originalFilename: livePhotoFilename);
}
final filenameExt = p.extension(originalFilename);
if (filenameExt.isNotEmpty) {
return (file: file, originalFilename: originalFilename);
}
final assetNameExt = p.extension(asset.name);
if (assetNameExt.isNotEmpty) {
final correctedFilename = p.setExtension(originalFilename, assetNameExt);
_logger.fine(
"Corrected filename $originalFilename to $correctedFilename using asset.name extension $assetNameExt",
);
return (file: file, originalFilename: correctedFilename);
}
final filePathExt = p.extension(file.path);
if (filePathExt.isEmpty) {
_logger.warning(
"Asset ${asset.id} has no file extension in any source, using original filename - $originalFilename",
);
return (file: file, originalFilename: originalFilename);
}
final correctedFilename = p.setExtension(originalFilename, filePathExt);
_logger.fine("Corrected filename $originalFilename to $correctedFilename using file path extension $filePathExt");
return (file: file, originalFilename: correctedFilename);
}
Future<UploadTask> buildUploadTask( Future<UploadTask> buildUploadTask(
File file, { File file, {
required String group, required String group,

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.4.0 - API version: 2.4.1
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.4.0+3029 version: 2.4.1+3030
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'

View File

@@ -87,6 +87,25 @@ void main() {
verify(() => mockLocalAssetRepository.get('local-1')).called(1); verify(() => mockLocalAssetRepository.get('local-1')).called(1);
}); });
test('uses fetched asset orientation when dimensions are missing on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Original asset has default orientation 0, but dimensions are missing
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
// Fetched asset has 90° orientation and proper dimensions
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
// Should flip dimensions since fetched asset has 90° orientation
expect(result, 1080 / 1920);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('returns 1.0 when dimensions are still unavailable after fetching', () async { test('returns 1.0 when dimensions are still unavailable after fetching', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null); final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -112,7 +131,9 @@ void main() {
expect(result, 1.0); expect(result, 1.0);
}); });
test('handles local asset with remoteId and uses exif from remote', () async { test('handles local asset with remoteId using local orientation not remote exif', () async {
// When a LocalAsset has a remoteId (merged), we should use local orientation
// because the width/height come from the local asset (pre-corrected on iOS)
final localAsset = TestUtils.createLocalAsset( final localAsset = TestUtils.createLocalAsset(
id: 'local-1', id: 'local-1',
remoteId: 'remote-1', remoteId: 'remote-1',
@@ -121,9 +142,24 @@ void main() {
orientation: 0, orientation: 0,
); );
final exif = const ExifInfo(orientation: '6'); final result = await sut.getAspectRatio(localAsset);
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); expect(result, 1920 / 1080);
// Should not call remote exif for LocalAsset
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
});
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 90,
);
final result = await sut.getAspectRatio(localAsset); final result = await sut.getAspectRatio(localAsset);

View File

@@ -4,6 +4,7 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@@ -16,8 +17,8 @@ import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart'; import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart'; import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart'; import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../mocks/asset_entity.mock.dart'; import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
void main() { void main() {
late UploadService sut; late UploadService sut;
@@ -165,4 +166,174 @@ void main() {
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
}); });
}); });
group('prepareUploadFile', () {
test('should keep filename with existing extension unchanged', () async {
final asset = LocalAssetStub.image1;
final mockFile = File('/tmp/123.jpg');
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
final result = await sut.prepareUploadFile(asset);
expect(result, isNotNull);
expect(result!.file.path, equals('/tmp/123.jpg'));
expect(result.originalFilename, equals('photo.jpg'));
});
test('should use asset.name extension when original filename lacks one', () async {
final asset = LocalAssetStub.image1;
final mockFile = File('/tmp/cache/123.mov');
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => '2024-10-23_17-00-30');
final result = await sut.prepareUploadFile(asset);
expect(result, isNotNull);
expect(result!.originalFilename, equals('2024-10-23_17-00-30.jpg'));
});
test('should use file path extension as final fallback', () async {
final asset = LocalAssetStub.image1.copyWith(name: 'document');
final mockFile = File('/tmp/cache/123.mov');
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'document');
final result = await sut.prepareUploadFile(asset);
expect(result, isNotNull);
expect(result!.originalFilename, equals('document.mov'));
});
test('should handle file without extension anywhere', () async {
final asset = LocalAssetStub.image1.copyWith(name: 'document');
final mockFile = File('/tmp/temp');
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'document');
final result = await sut.prepareUploadFile(asset);
expect(result, isNotNull);
expect(result!.originalFilename, equals('document'));
});
test('should preserve existing extension even if asset.name has different one', () async {
final asset = LocalAssetStub.image1;
final mockFile = File('/tmp/123.mov');
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.HEIC');
final result = await sut.prepareUploadFile(asset);
expect(result, isNotNull);
expect(result!.originalFilename, equals('photo.HEIC'));
});
test('should fall back to asset.name when getOriginalFilename returns null', () async {
final asset = LocalAssetStub.image1.copyWith(name: 'VID_1234.mp4');
final mockFile = File('/tmp/video.mov');
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final result = await sut.prepareUploadFile(asset);
expect(result, isNotNull);
expect(result!.originalFilename, equals('VID_1234.mp4')); // Uses asset.name directly
});
test('should return null when file is not found', () async {
final asset = LocalAssetStub.image1;
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => null);
final result = await sut.prepareUploadFile(asset);
expect(result, isNull);
});
});
group('getUploadTask with missing extensions', () {
test('should add extension for regular photo without extension', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/file.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => '2024-10-23_17-00-30');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('2024-10-23_17-00-30.jpg'));
});
test('should preserve existing extension for regular photo', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/file.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'MyPhoto.HEIC');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('MyPhoto.HEIC'));
});
test('should add extension for video without extension', () async {
// Create a video asset using copyWith since image2 is a video type
final asset = LocalAssetStub.image1.copyWith(id: 'video1', name: 'VID_20241023_170030', type: AssetType.video);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/video.mov');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'VID_20241023_170030');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('VID_20241023_170030.mov'));
});
});
group('getLivePhotoUploadTask with missing extensions', () {
test('should add extension when live photo filename lacks one', () async {
final asset = LocalAssetStub.image1.copyWith(name: 'IMG_1234.heic');
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/photo.heic');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_1234');
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-123');
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_1234.heic'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
});
test('should preserve extension when live photo filename has one', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/photo.heic');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'MyLivePhoto.HEIC');
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
expect(task, isNotNull);
expect(task!.fields['filename'], equals('MyLivePhoto.HEIC'));
});
});
} }

View File

@@ -14268,7 +14268,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "2.4.0", "version": "2.4.1",
"contact": {} "contact": {}
}, },
"tags": [ "tags": [

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.4.0", "version": "2.4.1",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 2.4.0 * 2.4.1
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "2.4.0", "version": "2.4.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -7,14 +7,22 @@ export const ImmichFooter = () => (
<Column align="center" className="w-6/12 sm:w-full"> <Column align="center" className="w-6/12 sm:w-full">
<div> <div>
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain"> <Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain">
<Img className="max-w-full" src={`https://immich.app/img/google-play-badge.png`} /> <Img
alt="Get it on Google Play"
className="max-w-full"
src={`https://immich.app/img/google-play-badge.png`}
/>
</Link> </Link>
</div> </div>
</Column> </Column>
<Column align="center" className="w-6/12 sm:w-full"> <Column align="center" className="w-6/12 sm:w-full">
<div className="h-full p-6"> <div className="h-full p-6">
<Link href="https://apps.apple.com/sg/app/immich/id1613945652"> <Link href="https://apps.apple.com/sg/app/immich/id1613945652">
<Img src={`https://immich.app/img/ios-app-store-badge.png`} alt="Immich" className="max-w-full" /> <Img
alt="Download on the App Store"
className="max-w-full"
src={`https://immich.app/img/ios-app-store-badge.png`}
/>
</Link> </Link>
</div> </div>
</Column> </Column>

View File

@@ -37,7 +37,13 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
afterInit(websocketServer: Server) { afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server'); this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
websocketServer.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp();
});
} }
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) { clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {

View File

@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum'; import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@Injectable() @Injectable()
export class AppRepository { export class AppRepository {
@@ -17,4 +22,26 @@ export class AppRepository {
setCloseFn(fn: () => Promise<void>) { setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn; this.closeFn = fn;
} }
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
});
}
} }

View File

@@ -144,14 +144,28 @@ export class AssetService extends BaseService {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids }); await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined); const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined);
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined); const exifDto = _.omitBy(
{
latitude,
longitude,
rating,
description,
dateTimeOriginal,
},
_.isUndefined,
);
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
if (Object.keys(exifDto).length > 0) { if (Object.keys(exifDto).length > 0) {
await this.assetRepository.updateAllExif(ids, exifDto); await this.assetRepository.updateAllExif(ids, exifDto);
} }
if ((dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined) { if (
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone); (dateTimeRelative !== undefined && dateTimeRelative !== 0) ||
timeZone !== undefined ||
extractedTimeZone?.type === 'fixed'
) {
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name);
} }
if (Object.keys(assetDto).length > 0) { if (Object.keys(assetDto).length > 0) {
@@ -436,7 +450,19 @@ export class AssetService extends BaseService {
rating?: number; rating?: number;
}) { }) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
const writes = _.omitBy(
{
description,
dateTimeOriginal,
timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined,
latitude,
longitude,
rating,
},
_.isUndefined,
);
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif(
updateLockedColumns({ updateLockedColumns({

View File

@@ -89,6 +89,7 @@ describe(CliService.name, () => {
alreadyDisabled: true, alreadyDisabled: true,
}); });
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0); expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0); expect(mocks.event.emit).toHaveBeenCalledTimes(0);
}); });
@@ -99,6 +100,7 @@ describe(CliService.name, () => {
alreadyDisabled: false, alreadyDisabled: false,
}); });
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false, isMaintenanceMode: false,
}); });
@@ -114,6 +116,7 @@ describe(CliService.name, () => {
}), }),
); );
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0); expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0); expect(mocks.event.emit).toHaveBeenCalledTimes(0);
}); });
@@ -126,6 +129,7 @@ describe(CliService.name, () => {
}), }),
); );
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true, isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/), secret: expect.stringMatching(/^\w{128}$/),

View File

@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
@Injectable() @Injectable()
@@ -55,8 +55,7 @@ export class CliService extends BaseService {
const state = { isMaintenanceMode: false as const }; const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
await this.appRepository.sendOneShotAppRestart(state);
sendOneShotAppRestart(state);
return { return {
alreadyDisabled: false, alreadyDisabled: false,
@@ -89,7 +88,7 @@ export class CliService extends BaseService {
secret, secret,
}); });
sendOneShotAppRestart({ await this.appRepository.sendOneShotAppRestart({
isMaintenanceMode: true, isMaintenanceMode: true,
}); });

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
@@ -31,7 +32,10 @@ export class MaintenanceService extends BaseService {
} }
@OnEvent({ name: 'AppRestart', server: true }) @OnEvent({ name: 'AppRestart', server: true })
onRestart(): void { onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp(); this.appRepository.exitApp();
} }

View File

@@ -1,55 +1,6 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Server as SocketIO } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl( export async function createMaintenanceLoginUrl(
baseUrl: string, baseUrl: string,

View File

@@ -0,0 +1,90 @@
import { Kysely } from 'kysely';
import { AssetRepository } from 'src/repositories/asset.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(AssetRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AssetRepository.name, () => {
describe('upsertExif', () => {
it('should append to locked columns', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({
assetId: asset.id,
dateTimeOriginal: '2023-11-19T18:11:00',
lockedProperties: ['dateTimeOriginal'],
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
await sut.upsertExif(
{ assetId: asset.id, lockedProperties: ['description'] },
{ lockedPropertiesBehavior: 'append' },
);
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
});
it('should deduplicate locked columns', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({
assetId: asset.id,
dateTimeOriginal: '2023-11-19T18:11:00',
lockedProperties: ['dateTimeOriginal', 'description'],
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
await sut.upsertExif(
{ assetId: asset.id, lockedProperties: ['description'] },
{ lockedPropertiesBehavior: 'append' },
);
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
});
});
});

View File

@@ -270,13 +270,13 @@ describe(AssetService.name, () => {
}); });
describe('update', () => { describe('update', () => {
it('should update dateTimeOriginal', async () => { it('should automatically lock lockable columns', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue(); ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const auth = factory.auth({ user }); const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' }); await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
await expect( await expect(
ctx.database ctx.database
@@ -285,7 +285,14 @@ describe(AssetService.name, () => {
.where('assetId', '=', asset.id) .where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(), .executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null }); ).resolves.toEqual({ lockedProperties: null });
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
await sut.update(auth, asset.id, {
latitude: 42,
longitude: 42,
rating: 3,
description: 'foo',
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
});
await expect( await expect(
ctx.database ctx.database
@@ -293,16 +300,83 @@ describe(AssetService.name, () => {
.select('lockedProperties') .select('lockedProperties')
.where('assetId', '=', asset.id) .where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(), .executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] }); ).resolves.toEqual({
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
});
});
it('should update dateTimeOriginal', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00' });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }), exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
}),
);
});
it('should update dateTimeOriginal with time zone', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
}), }),
); );
}); });
}); });
describe('updateAll', () => { describe('updateAll', () => {
it('should automatically lock lockable columns', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null });
await sut.updateAll(auth, {
ids: [asset.id],
latitude: 42,
description: 'foo',
longitude: 42,
rating: 3,
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
});
});
it('should relatively update assets', async () => { it('should relatively update assets', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue(); ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@@ -313,13 +387,6 @@ describe(AssetService.name, () => {
await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 }); await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
exifInfo: expect.objectContaining({ exifInfo: expect.objectContaining({
@@ -328,5 +395,39 @@ describe(AssetService.name, () => {
}), }),
); );
}); });
it('should update dateTimeOriginal', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00' });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
}),
);
});
it('should update dateTimeOriginal with time zone', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
}),
);
});
}); });
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "2.4.0", "version": "2.4.1",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import { Card, CardBody, CardHeader, CardTitle, Icon, type ActionItem, type IconLike } from '@immich/ui';
import type { Snippet } from 'svelte';
type Props = {
icon: IconLike;
title: string;
headerAction?: ActionItem;
children?: Snippet;
};
const { icon, title, headerAction, children }: Props = $props();
</script>
<Card color="secondary">
<CardHeader>
<div class="flex w-full justify-between items-center px-4 py-2">
<div class="flex gap-2 text-primary">
<Icon {icon} size="1.5rem" />
<CardTitle>{title}</CardTitle>
</div>
{#if headerAction}
<HeaderActionButton action={headerAction} />
{/if}
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
{@render children?.()}
</div>
</CardBody>
</Card>

View File

@@ -308,18 +308,6 @@
/> />
</div> </div>
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
{#if searchStore.isSearchEnabled} {#if searchStore.isSearchEnabled}
<div <div
id={searchTypeId} id={searchTypeId}
@@ -327,7 +315,7 @@
class:max-md:hidden={value} class:max-md:hidden={value}
class:end-28={value.length > 0} class:end-28={value.length > 0}
> >
<div class="relative"> <div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
<Button <Button
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer" class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
onclick={toggleSearchTypeDropdown} onclick={toggleSearchTypeDropdown}
@@ -340,11 +328,11 @@
{#if showSearchTypeDropdown} {#if showSearchTypeDropdown}
<div <div
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999" class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
> >
{#each searchTypes as searchType (searchType.value)} {#each searchTypes as searchType (searchType.value)}
<button <button
type="button" type="button"
tabindex="0"
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}" {currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
onclick={() => selectSearchType(searchType.value)} onclick={() => selectSearchType(searchType.value)}
@@ -358,6 +346,18 @@
</div> </div>
{/if} {/if}
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
{#if showClearIcon} {#if showClearIcon}
<div class="absolute inset-y-0 end-0 flex items-center pe-2"> <div class="absolute inset-y-0 end-0 flex items-center pe-2">
<IconButton <IconButton

View File

@@ -15,7 +15,7 @@
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small"> <Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
<ModalBody> <ModalBody>
<Text size="small" class="mb-4">{$t('api_key_description')}</Text> <Text size="small" class="mb-4">{$t('api_key_description')}</Text>
<Textarea bind:value={secret} readonly /> <Textarea bind:value={secret} readonly class="font-mono" />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>

View File

@@ -71,6 +71,34 @@ describe('DateSelectionModal component', () => {
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
test('does not fall back to UTC when datetime-local value has no seconds', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00' } });
await fireEvent.blur(getDateInput());
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
await fireEvent.focus(getTimeZoneInput());
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
});
test('does not fall back to UTC when datetime-local value has no milliseconds', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00:00' } });
await fireEvent.blur(getDateInput());
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
await fireEvent.focus(getTimeZoneInput());
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
});
describe('when date is in daylight saving time', () => { describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01'); const dstDate = DateTime.fromISO('2024-07-01');

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { handleCreateLibrary } from '$lib/services/library.service';
import { user } from '$lib/stores/user.store';
import { searchUsersAdmin } from '@immich/sdk';
import { FormModal, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
let { onClose }: Props = $props();
let ownerId: string = $state($user.id);
let userOptions: { value: string; text: string }[] = $state([]);
onMount(async () => {
const users = await searchUsersAdmin({});
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
});
const onSubmit = async () => {
const success = await handleCreateLibrary({ ownerId });
if (success) {
onClose();
}
};
</script>
<FormModal
title={$t('create_library')}
icon={mdiFolderSync}
{onClose}
size="small"
{onSubmit}
submitText={$t('create')}
>
<SettingSelect label={$t('owner')} bind:value={ownerId} options={userOptions} name="user" />
<Text color="warning" size="small">{$t('admin.note_cannot_be_changed_later')}</Text>
</FormModal>

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { user } from '$lib/stores/user.store';
import { searchUsersAdmin } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
onClose: (ownerId?: string) => void;
}
let { onClose }: Props = $props();
let ownerId: string = $state($user.id);
let userOptions: { value: string; text: string }[] = $state([]);
onMount(async () => {
const users = await searchUsersAdmin({});
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
});
const onsubmit = (event: Event) => {
event.preventDefault();
onClose(ownerId);
};
</script>
<Modal title={$t('select_library_owner')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="select-library-owner-form">
<p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p>
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="select-library-owner-form">{$t('create')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -75,8 +75,15 @@ function zoneOptionForDate(zone: string, date: string) {
// Ignore milliseconds: // Ignore milliseconds:
// - milliseconds are not relevant for TZ calculations // - milliseconds are not relevant for TZ calculations
// - browsers strip insignificant .000 making string comparison with milliseconds more fragile. // - browsers strip insignificant .000 making string comparison with milliseconds more fragile.
//
// Also, some browsers emit `datetime-local` values without seconds when seconds are 00,
// e.g. `2024-01-01T00:00` instead of `2024-01-01T00:00:00.000`.
// In that case we must compare with minute precision (otherwise every zone looks "invalid").
const dateInTimezone = DateTime.fromISO(date, { zone }); const dateInTimezone = DateTime.fromISO(date, { zone });
const exists = date.replace(/\.\d+/, '') === dateInTimezone.toFormat("yyyy-MM-dd'T'HH:mm:ss"); const withoutMillis = date.replace(/\.\d+/, '');
const hasSeconds = /T\d{2}:\d{2}:\d{2}$/.test(withoutMillis);
const compareFormat = hasSeconds ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyy-MM-dd'T'HH:mm";
const exists = withoutMillis === dateInTimezone.toFormat(compareFormat);
const valid = dateInTimezone.isValid && exists; const valid = dateInTimezone.isValid && exists;
return { return {
value: zone, value: zone,

View File

@@ -1,12 +1,12 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import LibraryCreateModal from '$lib/modals/LibraryCreateModal.svelte';
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte'; import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte'; import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte'; import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte'; import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte'; import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { import {
@@ -17,6 +17,7 @@ import {
runQueueCommandLegacy, runQueueCommandLegacy,
scanLibrary, scanLibrary,
updateLibrary, updateLibrary,
type CreateLibraryDto,
type LibraryResponseDto, type LibraryResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { modalManager, toastManager, type ActionItem } from '@immich/ui';
@@ -37,7 +38,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
title: $t('create_library'), title: $t('create_library'),
type: $t('command'), type: $t('command'),
icon: mdiPlusBoxOutline, icon: mdiPlusBoxOutline,
onAction: () => handleCreateLibrary(), onAction: () => handleShowLibraryCreateModal(),
shortcuts: { shift: true, key: 'n' }, shortcuts: { shift: true, key: 'n' },
}; };
@@ -152,20 +153,17 @@ export const handleViewLibrary = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`); await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
}; };
export const handleCreateLibrary = async () => { export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
const ownerId = await modalManager.show(LibraryUserPickerModal, {});
if (!ownerId) {
return;
}
try { try {
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } }); const library = await createLibrary({ createLibraryDto: dto });
eventManager.emit('LibraryCreate', createdLibrary); eventManager.emit('LibraryCreate', library);
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } })); toastManager.success($t('admin.library_created', { values: { library: library.name } }));
return true;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_create_library')); handleError(error, $t('errors.unable_to_create_library'));
return false;
} }
}; };
@@ -359,3 +357,7 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
handleError(error, $t('errors.unable_to_update_library')); handleError(error, $t('errors.unable_to_update_library'));
} }
}; };
export const handleShowLibraryCreateModal = async () => {
await modalManager.show(LibraryCreateModal, {});
};

View File

@@ -4,18 +4,18 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { getLibrariesActions, handleCreateLibrary, handleViewLibrary } from '$lib/services/library.service'; import { getLibrariesActions, handleShowLibraryCreateModal, handleViewLibrary } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units'; import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, getUserAdmin, type LibraryResponseDto } from '@immich/sdk'; import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext } from '@immich/ui'; import { Button, CommandPaletteContext } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
interface Props { type Props = {
data: PageData; data: PageData;
} };
let { data }: Props = $props(); let { data }: Props = $props();
@@ -23,15 +23,11 @@
let statistics = $state(data.statistics); let statistics = $state(data.statistics);
let owners = $state(data.owners); let owners = $state(data.owners);
const handleLibraryAdd = async (library: LibraryResponseDto) => { const onLibraryCreate = async (library: LibraryResponseDto) => {
statistics[library.id] = await getLibraryStatistics({ id: library.id });
owners[library.id] = await getUserAdmin({ id: library.ownerId });
libraries.push(library);
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`); await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
}; };
const handleLibraryUpdate = async (library: LibraryResponseDto) => { const onLibraryUpdate = async (library: LibraryResponseDto) => {
const index = libraries.findIndex(({ id }) => id === library.id); const index = libraries.findIndex(({ id }) => id === library.id);
if (index === -1) { if (index === -1) {
@@ -42,7 +38,7 @@
statistics[library.id] = await getLibraryStatistics({ id: library.id }); statistics[library.id] = await getLibraryStatistics({ id: library.id });
}; };
const handleDeleteLibrary = ({ id }: { id: string }) => { const onLibraryDelete = ({ id }: { id: string }) => {
libraries = libraries.filter((library) => library.id !== id); libraries = libraries.filter((library) => library.id !== id);
delete statistics[id]; delete statistics[id];
delete owners[id]; delete owners[id];
@@ -51,11 +47,7 @@
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries)); const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
</script> </script>
<OnEvents <OnEvents {onLibraryCreate} {onLibraryUpdate} {onLibraryDelete} />
onLibraryCreate={handleLibraryAdd}
onLibraryUpdate={handleLibraryUpdate}
onLibraryDelete={handleDeleteLibrary}
/>
<CommandPaletteContext commands={[Create, ScanAll]} /> <CommandPaletteContext commands={[Create, ScanAll]} />
@@ -106,7 +98,11 @@
</tbody> </tbody>
</table> </table>
{:else} {:else}
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={handleCreateLibrary} class="mt-10 mx-auto" /> <EmptyPlaceholder
text={$t('no_libraries_message')}
onClick={handleShowLibraryCreateModal}
class="mt-10 mx-auto"
/>
{/if} {/if}
</div> </div>
</section> </section>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg'; import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte'; import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte'; import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
@@ -15,18 +15,7 @@
getLibraryFolderActions, getLibraryFolderActions,
} from '$lib/services/library.service'; } from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units'; import { getBytesWithUnit } from '$lib/utils/byte-units';
import { import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
Card,
CardBody,
CardHeader,
CardTitle,
Code,
CommandPaletteContext,
Container,
Heading,
Icon,
modalManager,
} from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js'; import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -64,77 +53,53 @@
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} /> <ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} /> <ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
</div> </div>
<Card color="secondary">
<CardHeader> <AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
<div class="flex w-full justify-between items-center px-4 py-2"> {#if library.importPaths.length === 0}
<div class="flex gap-2 text-primary"> <EmptyPlaceholder
<Icon icon={mdiFolderOutline} size="1.5rem" /> src={emptyFoldersUrl}
<CardTitle>{$t('folders')}</CardTitle> text={$t('admin.library_folder_description')}
</div> fullWidth
<HeaderActionButton action={AddFolder} /> onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
</div> />
</CardHeader> {:else}
<CardBody> <table class="w-full">
<div class="px-4 pb-7"> <tbody>
{#if library.importPaths.length === 0} {#each library.importPaths as folder (folder)}
<EmptyPlaceholder {@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
src={emptyFoldersUrl} <tr class="h-12">
text={$t('admin.library_folder_description')} <td>
fullWidth <Code>{folder}</Code>
onClick={() => modalManager.show(LibraryFolderAddModal, { library })} </td>
/> <td class="flex gap-2 justify-end">
{:else} <TableButton action={Edit} />
<table class="w-full"> <TableButton action={Delete} />
<tbody> </td>
{#each library.importPaths as folder (folder)} </tr>
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)} {/each}
<tr class="h-12"> </tbody>
<td> </table>
<Code>{folder}</Code> {/if}
</td> </AdminCard>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} /> <AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
<TableButton action={Delete} /> <table class="w-full">
</td> <tbody>
</tr> {#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{/each} {@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
</tbody> <tr class="h-12">
</table> <td>
{/if} <Code>{exclusionPattern}</Code>
</div> </td>
</CardBody> <td class="flex gap-2 justify-end">
</Card> <TableButton action={Edit} />
<Card color="secondary"> <TableButton action={Delete} />
<CardHeader> </td>
<div class="flex w-full justify-between items-center px-4 py-2"> </tr>
<div class="flex gap-2 text-primary"> {/each}
<Icon icon={mdiFilterMinusOutline} size="1.5rem" /> </tbody>
<CardTitle>{$t('exclusion_pattern')}</CardTitle> </table>
</div> </AdminCard>
<HeaderActionButton action={AddExclusionPattern} />
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
<table class="w-full">
<tbody>
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
<tr class="h-12">
<td>
<Code>{exclusionPattern}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</CardBody>
</Card>
</div> </div>
</Container> </Container>
</AdminPageLayout> </AdminPageLayout>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte'; import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte'; import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
@@ -15,10 +16,6 @@
import { import {
Alert, Alert,
Badge, Badge,
Card,
CardBody,
CardHeader,
CardTitle,
Code, Code,
CommandPaletteContext, CommandPaletteContext,
Container, Container,
@@ -131,128 +128,90 @@
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} /> <ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
</div> </div>
</div> </div>
<div>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2 px-4 py-2 text-primary">
<Icon icon={mdiAccountOutline} size="1.5rem" />
<CardTitle>{$t('profile')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
<Stack gap={2}>
<div>
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
<Text>{user.name}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
<Text>{user.email}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<Text>{userCreatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{user.id}</Code>
</div>
</Stack>
</div>
</CardBody>
</Card>
</div>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2 px-4 py-2 text-primary">
<Icon icon={mdiFeatureSearchOutline} size="1.5rem" />
<CardTitle>{$t('features')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-4">
<Stack gap={3}>
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
</Stack>
</div>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2 px-4 py-2 text-primary">
<Icon icon={mdiChartPieOutline} size="1.5rem" />
<CardTitle>{$t('storage_quota')}</CardTitle>
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-4">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<Text>
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
</Text>
{:else}
<Text class="flex items-center gap-1">
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
{$t('unlimited')}
</Text>
{/if}
</div>
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} <AdminCard icon={mdiAccountOutline} title={$t('profile')}>
<div <Stack gap={2}>
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full" <div>
title={$t('storage_usage', { <Heading tag="h3" size="tiny">{$t('name')}</Heading>
values: { <Text>{user.name}</Text>
used: getByteUnitString(usedBytes, $locale, 3), </div>
available: getByteUnitString(availableBytes, $locale, 3), <div>
}, <Heading tag="h3" size="tiny">{$t('email')}</Heading>
})} <Text>{user.email}</Text>
> </div>
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p> <div>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700"> <Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div> <Text>{userCreatedAtDateAndTime}</Text>
</div> </div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{user.id}</Code>
</div>
</Stack>
</AdminCard>
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
<Stack gap={3}>
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
</Stack>
</AdminCard>
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<Text>
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
</Text>
{:else}
<Text class="flex items-center gap-1">
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
{$t('unlimited')}
</Text>
{/if}
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<div
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
>
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
</div> </div>
{/if}
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2 px-4 py-2 text-primary">
<Icon icon={mdiDevices} size="1.5rem" />
<CardTitle>{$t('authorized_devices')}</CardTitle>
</div> </div>
</CardHeader> {/if}
<CardBody> </AdminCard>
<div class="px-4 pb-7">
<Stack gap={3}> <AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
{#each userSessions as session (session.id)} <Stack gap={3}>
<DeviceCard {session} /> {#each userSessions as session (session.id)}
{:else} <DeviceCard {session} />
<span class="text-dark">{$t('no_devices')}</span> {:else}
{/each} <span class="text-dark">{$t('no_devices')}</span>
</Stack> {/each}
</div> </Stack>
</CardBody> </AdminCard>
</Card>
</div> </div>
</Container> </Container>
</div> </div>