mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 14:51:17 -08:00
Compare commits
4 Commits
fix-update
...
fix/ios-ha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6733c14f76 | ||
|
|
a17f188e97 | ||
|
|
5b80323326 | ||
|
|
1425b3da6b |
@@ -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,
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}$/),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
33
web/src/lib/components/AdminCard.svelte
Normal file
33
web/src/lib/components/AdminCard.svelte
Normal 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>
|
||||||
44
web/src/lib/modals/LibraryCreateModal.svelte
Normal file
44
web/src/lib/modals/LibraryCreateModal.svelte
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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, {});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,18 +53,8 @@
|
|||||||
<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">
|
|
||||||
<div class="flex gap-2 text-primary">
|
|
||||||
<Icon icon={mdiFolderOutline} size="1.5rem" />
|
|
||||||
<CardTitle>{$t('folders')}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<HeaderActionButton action={AddFolder} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div class="px-4 pb-7">
|
|
||||||
{#if library.importPaths.length === 0}
|
{#if library.importPaths.length === 0}
|
||||||
<EmptyPlaceholder
|
<EmptyPlaceholder
|
||||||
src={emptyFoldersUrl}
|
src={emptyFoldersUrl}
|
||||||
@@ -101,21 +80,9 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</AdminCard>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
|
||||||
<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={mdiFilterMinusOutline} size="1.5rem" />
|
|
||||||
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<HeaderActionButton action={AddExclusionPattern} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div class="px-4 pb-7">
|
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
||||||
@@ -132,9 +99,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</AdminCard>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|||||||
@@ -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,16 +128,8 @@
|
|||||||
<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">
|
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
|
||||||
<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}>
|
<Stack gap={2}>
|
||||||
<div>
|
<div>
|
||||||
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
|
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
|
||||||
@@ -163,19 +152,9 @@
|
|||||||
<Code>{user.id}</Code>
|
<Code>{user.id}</Code>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</AdminCard>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
|
||||||
</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}>
|
<Stack gap={3}>
|
||||||
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
||||||
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
||||||
@@ -187,18 +166,9 @@
|
|||||||
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
||||||
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</AdminCard>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
|
||||||
<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}
|
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||||
<Text>
|
<Text>
|
||||||
{$t('storage_usage', {
|
{$t('storage_usage', {
|
||||||
@@ -214,7 +184,6 @@
|
|||||||
{$t('unlimited')}
|
{$t('unlimited')}
|
||||||
</Text>
|
</Text>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||||
<div
|
<div
|
||||||
@@ -232,17 +201,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</CardBody>
|
</AdminCard>
|
||||||
</Card>
|
|
||||||
<Card color="secondary">
|
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
|
||||||
<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>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div class="px-4 pb-7">
|
|
||||||
<Stack gap={3}>
|
<Stack gap={3}>
|
||||||
{#each userSessions as session (session.id)}
|
{#each userSessions as session (session.id)}
|
||||||
<DeviceCard {session} />
|
<DeviceCard {session} />
|
||||||
@@ -250,9 +211,7 @@
|
|||||||
<span class="text-dark">{$t('no_devices')}</span>
|
<span class="text-dark">{$t('no_devices')}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</AdminCard>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user