merge main

pull/23141/head
Alex 2025-10-22 13:10:50 +07:00
commit 1233277f46
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
54 changed files with 712 additions and 362 deletions

@ -54,16 +54,10 @@ jobs:
issues: write
discussions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Close issue
if: ${{ github.event_name == 'issues' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.issue.node_id }}
run: |
gh api graphql \
@ -89,7 +83,7 @@ jobs:
- name: Close discussion
if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.discussion.node_id }}
run: |
gh api graphql \

@ -5,6 +5,9 @@ on:
types:
- completed
env:
TG_NON_INTERACTIVE: 'true'
jobs:
checks:
name: Docs Deploy Checks
@ -182,7 +185,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run tf output -json'
run: 'mise run tf output -- -json'
- name: Output Cleaning
id: clean

@ -5,6 +5,9 @@ on:
permissions: {}
env:
TG_NON_INTERACTIVE: 'true'
jobs:
deploy:
name: Docs Destroy
@ -36,7 +39,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run tf destroy -refresh=false'
run: 'mise run tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0

@ -8,6 +8,7 @@ import { serverInfo } from 'src/commands/server-info';
import { version } from '../package.json';
const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
const defaultConcurrency = Math.max(1, os.cpus().length - 1);
const program = new Command()
.name('immich')
@ -66,7 +67,7 @@ program
.addOption(
new Option('-c, --concurrency <number>', 'Number of assets to upload at the same time')
.env('IMMICH_UPLOAD_CONCURRENCY')
.default(4),
.default(defaultConcurrency),
)
.addOption(
new Option('-j, --json-output', 'Output detailed information in json format')

@ -582,7 +582,7 @@ describe('/tags', () => {
expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
});
it('should remove duplicate assets only once', async () => {
it.skip('should remove duplicate assets only once', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(
{ id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },

@ -1,4 +1,5 @@
import {
JobName,
LoginResponseDto,
createStack,
deleteUserAdmin,
@ -327,6 +328,8 @@ describe('/admin/users', () => {
{ headers: asBearerAuth(user.accessToken) },
);
await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask);
const { status, body } = await request(app)
.delete(`/admin/users/${user.userId}`)
.send({ force: true })

@ -474,6 +474,7 @@
"app_bar_signout_dialog_title": "Sign out",
"app_download_links": "App Download Links",
"app_settings": "App Settings",
"app_stores": "App Stores",
"app_update_available": "App update is available",
"appears_in": "Appears in",
"apply_count": "Apply ({count, number})",
@ -745,6 +746,7 @@
"create": "Create",
"create_album": "Create album",
"create_album_page_untitled": "Untitled",
"create_api_key": "Create API key",
"create_library": "Create Library",
"create_link": "Create link",
"create_link_to_share": "Create link to share",
@ -1352,7 +1354,7 @@
"minutes": "Minutes",
"missing": "Missing",
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "You can access these options again from the Utilities page.",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"month": "Month",
"monthly_title_text_date_format": "MMMM y",
@ -1435,7 +1437,7 @@
"notifications_setting_description": "Manage notifications",
"oauth": "OAuth",
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Please create an API key and select a variant to create your Obtainium configuration link.",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",

@ -2,8 +2,8 @@
node = "22.20.0"
flutter = "3.35.6"
pnpm = "10.18.1"
terragrunt = "0.58.12"
opentofu = "1.7.1"
terragrunt = "0.91.2"
opentofu = "1.10.6"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

@ -86,7 +86,7 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (2.0.0):
- photo_manager (3.7.1):
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
@ -268,7 +268,7 @@ SPEC CHECKSUMS:
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
@ -277,9 +277,9 @@ SPEC CHECKSUMS:
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
@ -291,7 +291,7 @@ SPEC CHECKSUMS:
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

@ -30,9 +30,9 @@ import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
class BackgroundWorkerFgService {
final BackgroundWorkerFgHostApi _foregroundHostApi;
@ -94,7 +94,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
await Future.wait(
[
loadTranslations(),
workerManager.init(dynamicSpawning: true),
workerManagerPatch.init(dynamicSpawning: true),
_ref?.read(authServiceProvider).setOpenApiServiceEndpoint(),
// Initialize the file downloader
FileDownloader().configure(
@ -193,7 +193,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_logger.info("Cleaning up background worker");
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManager.dispose().catchError((_) async {
workerManagerPatch.dispose().catchError((_) async {
// Discard any errors on the dispose call
return;
}),

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
@ -40,10 +41,10 @@ import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
import 'package:worker_manager/worker_manager.dart';
void main() async {
ImmichWidgetsBinding();
@ -52,7 +53,7 @@ void main() async {
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
// Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true);
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

@ -43,7 +43,7 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image && isOwner) const EditImageActionButton(),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (isOwner) ...[
if (asset.hasRemote && isOwner && isArchived)
const UnArchiveActionButton(source: ActionSource.viewer)

@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
@ -31,7 +32,7 @@ Cancelable<T?> runInIsolateGentle<T>({
throw const InvalidIsolateUsageException();
}
return workerManager.executeGentle((cancelledChecker) async {
return workerManagerPatch.executeGentle((cancelledChecker) async {
T? result;
await runZonedGuarded(
() async {

@ -15,7 +15,7 @@ class SemVer {
}
factory SemVer.fromString(String version) {
final parts = version.split('.');
final parts = version.split("-")[0].split('.');
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
}

@ -0,0 +1,251 @@
// part of 'package:worker_manager/worker_manager.dart';
// ignore_for_file: implementation_imports, avoid_print
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:worker_manager/src/number_of_processors/processors_io.dart';
import 'package:worker_manager/src/worker/worker.dart';
import 'package:worker_manager/worker_manager.dart';
final workerManagerPatch = _Executor();
// [-2^54; 2^53] is compatible with dart2js, see core.int doc
const _minId = -9007199254740992;
const _maxId = 9007199254740992;
class Mixinable<T> {
late final itSelf = this as T;
}
mixin _ExecutorLogger on Mixinable<_Executor> {
var log = false;
@mustCallSuper
void init() {
logMessage("${itSelf._isolatesCount} workers have been spawned and initialized");
}
void logTaskAdded<R>(String uid) {
logMessage("added task with number $uid");
}
@mustCallSuper
void dispose() {
logMessage("worker_manager have been disposed");
}
@mustCallSuper
void _cancel(Task task) {
logMessage("Task ${task.id} have been canceled");
}
void logMessage(String message) {
if (log) print(message);
}
}
class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
final _queue = PriorityQueue<Task>();
final _pool = <Worker>[];
var _nextTaskId = _minId;
var _dynamicSpawning = false;
var _isolatesCount = numberOfProcessors;
@override
Future<void> init({int? isolatesCount, bool? dynamicSpawning}) async {
if (_pool.isNotEmpty) {
print("worker_manager already warmed up, init is ignored. Dispose before init");
return;
}
if (isolatesCount != null) {
if (isolatesCount < 0) {
throw Exception("isolatesCount must be greater than 0");
}
_isolatesCount = isolatesCount;
}
_dynamicSpawning = dynamicSpawning ?? false;
await _ensureWorkersInitialized();
super.init();
}
@override
Future<void> dispose() async {
_queue.clear();
for (final worker in _pool) {
worker.kill();
}
_pool.clear();
super.dispose();
}
Cancelable<R> execute<R>(Execute<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeNow<R>(ExecuteGentle<R> execution) {
final task = TaskGentle<R>(
id: "",
workPriority: WorkPriority.immediately,
execution: execution,
completer: Completer<R>(),
);
Future<void> run() async {
try {
final result = await execution(() => task.canceled);
task.complete(result, null, null);
} catch (error, st) {
task.complete(null, error, st);
}
}
run();
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
Cancelable<R> executeWithPort<R, T>(
ExecuteWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
Cancelable<R> executeGentle<R>(ExecuteGentle<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeGentleWithPort<R, T>(
ExecuteGentleWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
void _createWorkers() {
for (var i = 0; i < _isolatesCount; i++) {
_pool.add(Worker());
}
}
Future<void> _initializeWorkers() async {
await Future.wait(_pool.map((e) => e.initialize()));
}
Cancelable<R> _createCancelable<R>({
required Function execution,
WorkPriority priority = WorkPriority.immediately,
void Function(Object value)? onMessage,
}) {
if (_nextTaskId + 1 == _maxId) {
_nextTaskId = _minId;
}
final id = _nextTaskId.toString();
_nextTaskId++;
late final Task<R> task;
final completer = Completer<R>();
if (execution is Execute<R>) {
task = TaskRegular<R>(id: id, workPriority: priority, execution: execution, completer: completer);
} else if (execution is ExecuteWithPort<R>) {
task = TaskWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
} else if (execution is ExecuteGentle<R>) {
task = TaskGentle<R>(id: id, workPriority: priority, execution: execution, completer: completer);
} else if (execution is ExecuteGentleWithPort<R>) {
task = TaskGentleWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
}
_queue.add(task);
_schedule();
logTaskAdded(task.id);
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
Future<void> _ensureWorkersInitialized() async {
if (_pool.isEmpty) {
_createWorkers();
if (!_dynamicSpawning) {
await _initializeWorkers();
final poolSize = _pool.length;
final queueSize = _queue.length;
for (int i = 0; i <= min(poolSize, queueSize); i++) {
_schedule();
}
}
}
if (_pool.every((worker) => worker.taskId != null)) {
return;
}
if (_dynamicSpawning) {
final freeWorker = _pool.firstWhereOrNull(
(worker) => worker.taskId == null && !worker.initialized && !worker.initializing,
);
await freeWorker?.initialize();
_schedule();
}
}
void _schedule() {
final availableWorker = _pool.firstWhereOrNull((worker) => worker.taskId == null && worker.initialized);
if (availableWorker == null) {
_ensureWorkersInitialized();
return;
}
if (_queue.isEmpty) return;
final task = _queue.removeFirst();
availableWorker
.work(task)
.then(
(value) {
//could be completed already by cancel and it is normal.
//Assuming that worker finished with error and cleaned gracefully
task.complete(value, null, null);
},
onError: (error, st) {
task.complete(null, error, st);
},
)
.whenComplete(() {
if (_dynamicSpawning && _queue.isEmpty) availableWorker.kill();
_schedule();
});
}
@override
void _cancel(Task task) {
task.cancel();
_queue.remove(task);
final targetWorker = _pool.firstWhereOrNull((worker) => worker.taskId == task.id);
if (task is Gentle) {
targetWorker?.cancelGentle();
} else {
targetWorker?.kill();
if (!_dynamicSpawning) targetWorker?.initialize();
}
super._cancel(task);
}
}

@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: args
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.7.0"
async:
dependency: "direct main"
description:
@ -85,10 +85,10 @@ packages:
dependency: "direct main"
description:
name: background_downloader
sha256: "9ed74c55750932178f6989ba8a659687c2a102e05b70f561a1b3f047a5dda790"
sha256: a22acfa37aa06ba5cfe6eb7b1aa700c78af64770ff450c73dd3d279d7c37d4ac
url: "https://pub.dev"
source: hosted
version: "9.2.5"
version: "9.2.6"
bonsoir:
dependency: transitive
description:
@ -445,10 +445,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: "49413c8ca514dea7633e8def233b25efdf83ec8522955cc2c0e3ad802927e7c6"
sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33
url: "https://pub.dev"
source: hosted
version: "12.1.0"
version: "12.2.0"
device_info_plus_platform_interface:
dependency: transitive
description:
@ -477,26 +477,26 @@ packages:
dependency: "direct main"
description:
name: drift_flutter
sha256: "0cadbf3b8733409a6cf61d18ba2e94e149df81df7de26f48ae0695b48fd71922"
sha256: b52bd710f809db11e25259d429d799d034ba1c5224ce6a73fe8419feb980d44c
url: "https://pub.dev"
source: hosted
version: "0.2.4"
version: "0.2.6"
dynamic_color:
dependency: "direct main"
description:
name: dynamic_color
sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d
sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
version: "1.8.1"
easy_localization:
dependency: "direct main"
description:
name: easy_localization
sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12"
sha256: "2ccdf9db8fe4d9c5a75c122e6275674508fd0f0d49c827354967b8afcc56bbed"
url: "https://pub.dev"
source: hosted
version: "3.0.7+1"
version: "3.0.8"
easy_logger:
dependency: transitive
description:
@ -594,10 +594,10 @@ packages:
dependency: "direct main"
description:
name: flutter_displaymode
sha256: "42c5e9abd13d28ed74f701b60529d7f8416947e58256e6659c5550db719c57ef"
sha256: ecd44b1e902b0073b42ff5b55bf283f38e088270724cdbb7f7065ccf54aa60a8
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "0.7.0"
flutter_driver:
dependency: transitive
description: flutter
@ -607,18 +607,18 @@ packages:
dependency: "direct main"
description:
name: flutter_hooks
sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d
sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42"
url: "https://pub.dev"
source: hosted
version: "0.21.2"
version: "0.21.3+1"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.3"
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@ -660,10 +660,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: edb09c35ee9230c4b03f13dd45bb3a276d0801865f0a4650b7e2a3bba61a803a
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
url: "https://pub.dev"
source: hosted
version: "2.4.5"
version: "2.4.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -732,10 +732,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b
sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678
url: "https://pub.dev"
source: hosted
version: "2.0.17"
version: "2.2.1"
flutter_test:
dependency: "direct dev"
description: flutter
@ -745,10 +745,10 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
sha256: be464dc5b1fb7ee894f6a32d65c086ca5e177fdcf9375ac08d77495b98150f84
sha256: "166bee5989a58c66b8b62000ea65edccc7c8167bbafdbb08022638db330dd030"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "4.0.0"
flutter_web_auth_2:
dependency: "direct main"
description:
@ -799,14 +799,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
geoclue:
dependency: transitive
description:
name: geoclue
sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f
url: "https://pub.dev"
source: hosted
version: "0.1.1"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: e7ebfa04ce451daf39b5499108c973189a71a919aa53c1204effda1c5b93b822
sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516"
url: "https://pub.dev"
source: hosted
version: "14.0.0"
version: "14.0.2"
geolocator_android:
dependency: transitive
description:
@ -823,6 +831,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.9"
geolocator_linux:
dependency: transitive
description:
name: geolocator_linux
sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3
url: "https://pub.dev"
source: hosted
version: "0.2.3"
geolocator_platform_interface:
dependency: transitive
description:
@ -863,14 +879,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
gsettings:
dependency: transitive
description:
name: gsettings
sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c"
url: "https://pub.dev"
source: hosted
version: "0.2.8"
home_widget:
dependency: "direct main"
description:
name: home_widget
sha256: ad9634ef5894f3bac73f04d59e2e5151a39798f49985399fd928dadc828d974a
sha256: "908d033514a981f829fd98213909e11a428104327be3b422718aa643ac9d084a"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.8.1"
hooks_riverpod:
dependency: "direct main"
description:
@ -891,18 +915,18 @@ packages:
dependency: transitive
description:
name: html
sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec"
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.5"
version: "0.15.6"
http:
dependency: "direct main"
description:
name: http
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.5.0"
http_multi_server:
dependency: transitive
description:
@ -923,74 +947,74 @@ packages:
dependency: transitive
description:
name: image
sha256: "13d3349ace88f12f4a0d175eb5c12dcdd39d35c4c109a8a13dfeb6d0bd9e31c3"
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
version: "4.5.4"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.2.0"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9"
sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca"
url: "https://pub.dev"
source: hosted
version: "0.8.12+22"
version: "0.8.13+5"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
version: "3.1.0"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
sha256: e675c22790bcc24e9abd455deead2b7a88de4b79f7327a281812f14de1a56f58
url: "https://pub.dev"
source: hosted
version: "0.8.12+2"
version: "0.8.13+1"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa"
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1"
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.1+2"
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
version: "2.11.0"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
version: "0.2.2"
immich_mobile_immich_lint:
dependency: "direct dev"
description:
@ -1321,10 +1345,10 @@ packages:
dependency: "direct main"
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.3"
path_provider_linux:
dependency: transitive
description:
@ -1401,34 +1425,34 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "7.0.1"
photo_manager:
dependency: "direct main"
description:
name: photo_manager
sha256: "0bc7548fd3111eb93a3b0abf1c57364e40aeda32512c100085a48dade60e574f"
sha256: a0d9a7a9bc35eda02d33766412bde6d883a8b0acb86bbe37dac5f691a0894e8a
url: "https://pub.dev"
source: hosted
version: "3.6.4"
version: "3.7.1"
pigeon:
dependency: "direct dev"
description:
name: pigeon
sha256: b65acb352dc5a5f8615d074a83419388cbcc249f07c6d8c78b5bc16680a55dda
sha256: "0045b172d1da43c40cb3f58e80e04b50a65cba20b8b70dc880af04181f7758da"
url: "https://pub.dev"
source: hosted
version: "26.0.0"
version: "26.0.2"
pinput:
dependency: "direct main"
description:
name: pinput
sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a"
sha256: c41f42ee301505ae2375ec32871c985d3717bf8aee845620465b286e0140aad2
url: "https://pub.dev"
source: hosted
version: "5.0.1"
version: "5.0.2"
platform:
dependency: transitive
description:
@ -1577,18 +1601,18 @@ packages:
dependency: "direct main"
description:
name: share_handler
sha256: "76575533be04df3fecbebd3c5b5325a8271b5973131f8b8b0ab8490c395a5d37"
sha256: "0a6d007f0e44fbee27164adcd159ecbc88238864313f4e5c58161cae2180328d"
url: "https://pub.dev"
source: hosted
version: "0.0.22"
version: "0.0.25"
share_handler_android:
dependency: transitive
description:
name: share_handler_android
sha256: "124dcc914fb7ecd89076d3dc28435b98fe2129a988bf7742f7a01dcb66a95667"
sha256: caf555b933dc72783aa37fef75688c7b86bd6f7bc17d80fbf585bc42f123cc8d
url: "https://pub.dev"
source: hosted
version: "0.0.9"
version: "0.0.11"
share_handler_ios:
dependency: transitive
description:
@ -1942,10 +1966,10 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.1"
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
@ -2030,10 +2054,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.1.16"
version: "1.1.19"
vector_math:
dependency: transitive
description:
@ -2054,18 +2078,18 @@ packages:
dependency: "direct main"
description:
name: wakelock_plus
sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e"
sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b"
url: "https://pub.dev"
source: hosted
version: "1.2.10"
version: "1.3.3"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a"
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.3.0"
watcher:
dependency: transitive
description:
@ -2134,10 +2158,10 @@ packages:
dependency: "direct main"
description:
name: worker_manager
sha256: "086ed63e9b36266e851404ca90fd44e37c0f4c9bbf819e5f8d7c87f9741c0591"
sha256: "1bce9f894a0c187856f5fc0e150e7fe1facce326f048ca6172947754dac3d4f3"
url: "https://pub.dev"
source: hosted
version: "7.2.3"
version: "7.2.7"
xdg_directories:
dependency: transitive
description:
@ -2150,10 +2174,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
version: "6.6.1"
xxh3:
dependency: transitive
description:
@ -2171,5 +2195,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.6"

@ -10,41 +10,41 @@ environment:
dependencies:
app_settings: ^6.1.1
async: ^2.11.0
async: ^2.13.0
auto_route: ^9.2.0
background_downloader: ^9.2.5
background_downloader: ^9.2.6
cached_network_image: ^3.4.1
cancellation_token_http: ^2.1.0
cast: ^2.1.0
collection: ^1.18.0
collection: ^1.19.1
connectivity_plus: ^6.1.3
crop_image: ^1.0.16
crypto: ^3.0.6
device_info_plus: ^12.0.0
device_info_plus: ^12.2.0
# DB
drift: ^2.23.1
drift_flutter: ^0.2.4
dynamic_color: ^1.7.0
easy_localization: ^3.0.7+1
drift: ^2.26.0
drift_flutter: ^0.2.6
dynamic_color: ^1.8.1
easy_localization: ^3.0.8
ffi: ^2.1.4
file_picker: ^8.0.0+1
flutter:
sdk: flutter
flutter_cache_manager: ^3.4.1
flutter_displaymode: ^0.6.0
flutter_hooks: ^0.21.2
flutter_displaymode: ^0.7.0
flutter_hooks: ^0.21.3+1
flutter_local_notifications: ^17.2.1+2
flutter_secure_storage: ^9.2.4
flutter_svg: ^2.0.17
flutter_udid: ^3.0.0
flutter_svg: ^2.2.1
flutter_udid: ^4.0.0
flutter_web_auth_2: ^5.0.0-alpha.0
fluttertoast: ^8.2.12
geolocator: ^14.0.0
home_widget: ^0.8.0
geolocator: ^14.0.2
home_widget: ^0.8.1
hooks_riverpod: ^2.6.1
http: ^1.3.0
image_picker: ^1.1.2
intl: ^0.20.0
http: ^1.5.0
image_picker: ^1.2.0
intl: ^0.20.2
isar:
git:
url: https://github.com/immich-app/isar
@ -66,37 +66,37 @@ dependencies:
package_info_plus: ^8.3.0
path: ^1.9.1
path_provider: ^2.1.5
path_provider_foundation: ^2.4.1
path_provider_foundation: ^2.4.3
permission_handler: ^11.4.0
photo_manager: ^3.6.4
pinput: ^5.0.1
photo_manager: ^3.7.1
pinput: ^5.0.2
punycode: ^1.0.0
riverpod_annotation: ^2.6.1
scroll_date_picker: ^3.8.0
scrollable_positioned_list: ^0.3.8
share_handler: ^0.0.22
share_handler: ^0.0.25
share_plus: ^10.1.4
sliver_tools: ^0.2.12
socket_io_client: ^2.0.3+1
stream_transform: ^2.1.1
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.1
url_launcher: ^6.3.2
uuid: ^4.5.1
wakelock_plus: ^1.2.10
worker_manager: ^7.2.3
wakelock_plus: ^1.3.0
worker_manager: ^7.2.7
dev_dependencies:
auto_route_generator: ^9.0.0
build_runner: ^2.4.8
custom_lint: ^0.7.5
# Drift generator
drift_dev: ^2.23.1
fake_async: ^1.3.1
drift_dev: ^2.26.0
fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.3
flutter_launcher_icons: ^0.14.4
flutter_lints: ^5.0.0
flutter_native_splash: ^2.4.5
flutter_native_splash: ^2.4.7
flutter_test:
sdk: flutter
immich_mobile_immich_lint:
@ -110,7 +110,7 @@ dev_dependencies:
path: packages/isar_generator/
mocktail: ^1.0.4
# Type safe platform code
pigeon: ^26.0.0
pigeon: ^26.0.2
riverpod_generator: ^2.6.1
riverpod_lint: ^2.6.1

@ -19,7 +19,6 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
import { CliService } from 'src/services/cli.service';
@ -56,7 +55,6 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
private jobService: JobService,
private telemetryRepository: TelemetryRepository,
private authService: AuthService,
private userRepository: UserRepository,
) {
logger.setAppName(this.worker);
}

@ -17,7 +17,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { JobItem, JobSource } from 'src/types';
@ -66,8 +66,19 @@ type EventMap = {
AssetDeleteAll: [{ assetIds: string[]; userId: string }];
AssetRestoreAll: [{ assetIds: string[]; userId: string }];
/** a worker receives a job and emits this event to run it */
JobRun: [QueueName, JobItem];
/** job pre-hook */
JobStart: [QueueName, JobItem];
JobFailed: [{ job: JobItem; error: Error | any }];
/** job post-hook */
JobComplete: [QueueName, JobItem];
/** job finishes without error */
JobSuccess: [JobSuccessEvent];
/** job finishes with error */
JobError: [JobErrorEvent];
// queue events
QueueStart: [QueueStartEvent];
// session events
SessionDelete: [{ sessionId: string }];
@ -82,11 +93,43 @@ type EventMap = {
// user events
UserSignup: [{ notify: boolean; id: string; password?: string }];
UserCreate: [UserEvent];
/** user is soft deleted */
UserTrash: [UserEvent];
/** user is permanently deleted */
UserDelete: [UserEvent];
UserRestore: [UserEvent];
// websocket events
WebsocketConnect: [{ userId: string }];
};
type JobSuccessEvent = { job: JobItem; response?: JobStatus };
type JobErrorEvent = { job: JobItem; error: Error | any };
type QueueStartEvent = {
name: QueueName;
};
type UserEvent = {
name: string;
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
status: UserStatus;
email: string;
profileImagePath: string;
isAdmin: boolean;
shouldChangePassword: boolean;
avatarColor: UserAvatarColor | null;
oauthId: string;
storageLabel: string | null;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number;
profileChangedAt: Date;
};
export const serverEvents = ['ConfigUpdate'] as const;
export type ServerEvents = (typeof serverEvents)[number];

@ -89,7 +89,7 @@ export class JobRepository {
this.logger.debug(`Starting worker for queue: ${queueName}`);
this.workers[queueName] = new Worker(
queueName,
(job) => this.eventRepository.emit('JobStart', queueName, job as JobItem),
(job) => this.eventRepository.emit('JobRun', queueName, job as JobItem),
{ ...bull.config, concurrency: 1 },
);
}

@ -198,8 +198,8 @@ export class BaseService {
}
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {
const exists = await this.userRepository.getByEmail(dto.email);
if (exists) {
throw new BadRequestException('User exists');
}
@ -218,7 +218,10 @@ export class BaseService {
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
}
this.telemetryRepository.api.addToGauge(`immich.users.total`, 1);
return this.userRepository.create(payload);
const user = await this.userRepository.create(payload);
await this.eventRepository.emit('UserCreate', user);
return user;
}
}

@ -34,6 +34,7 @@ import { SyncService } from 'src/services/sync.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { SystemMetadataService } from 'src/services/system-metadata.service';
import { TagService } from 'src/services/tag.service';
import { TelemetryService } from 'src/services/telemetry.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
import { UserAdminService } from 'src/services/user-admin.service';
@ -78,6 +79,7 @@ export const services = [
SystemConfigService,
SystemMetadataService,
TagService,
TelemetryService,
TimelineService,
TrashService,
UserAdminService,

@ -222,18 +222,16 @@ describe(JobService.name, () => {
});
});
describe('onJobStart', () => {
describe('onJobRun', () => {
it('should process a successful job', async () => {
mocks.job.run.mockResolvedValue(JobStatus.Success);
await sut.onJobStart(QueueName.BackgroundTask, {
name: JobName.FileDelete,
data: { files: ['path/to/file'] },
});
const job: JobItem = { name: JobName.FileDelete, data: { files: ['path/to/file'] } };
await sut.onJobRun(QueueName.BackgroundTask, job);
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1);
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1);
expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.file_delete.success', 1);
expect(mocks.event.emit).toHaveBeenCalledWith('JobStart', QueueName.BackgroundTask, job);
expect(mocks.event.emit).toHaveBeenCalledWith('JobSuccess', { job, response: JobStatus.Success });
expect(mocks.event.emit).toHaveBeenCalledWith('JobComplete', QueueName.BackgroundTask, job);
expect(mocks.logger.error).not.toHaveBeenCalled();
});
@ -300,7 +298,7 @@ describe(JobService.name, () => {
mocks.job.run.mockResolvedValue(JobStatus.Success);
await sut.onJobStart(QueueName.BackgroundTask, item);
await sut.onJobRun(QueueName.BackgroundTask, item);
if (jobs.length > 1) {
expect(mocks.job.queueAll).toHaveBeenCalledWith(
@ -317,7 +315,7 @@ describe(JobService.name, () => {
it(`should not queue any jobs when ${item.name} fails`, async () => {
mocks.job.run.mockResolvedValue(JobStatus.Failed);
await sut.onJobStart(QueueName.BackgroundTask, item);
await sut.onJobRun(QueueName.BackgroundTask, item);
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});

@ -1,6 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ClassConstructor } from 'class-transformer';
import { snakeCase } from 'lodash';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
@ -186,7 +185,7 @@ export class JobService extends BaseService {
throw new BadRequestException(`Job is already running`);
}
this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1);
await this.eventRepository.emit('QueueStart', { name });
switch (name) {
case QueueName.VideoConversion: {
@ -243,21 +242,19 @@ export class JobService extends BaseService {
}
}
@OnEvent({ name: 'JobStart' })
async onJobStart(...[queueName, job]: ArgsOf<'JobStart'>) {
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
@OnEvent({ name: 'JobRun' })
async onJobRun(...[queueName, job]: ArgsOf<'JobRun'>) {
try {
const status = await this.jobRepository.run(job);
const jobMetric = `immich.jobs.${snakeCase(job.name)}.${status}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
if (status === JobStatus.Success || status == JobStatus.Skipped) {
await this.eventRepository.emit('JobStart', queueName, job);
const response = await this.jobRepository.run(job);
await this.eventRepository.emit('JobSuccess', { job, response });
if (response && typeof response === 'string' && [JobStatus.Success, JobStatus.Skipped].includes(response)) {
await this.onDone(job);
}
} catch (error: Error | any) {
await this.eventRepository.emit('JobFailed', { job, error });
await this.eventRepository.emit('JobError', { job, error });
} finally {
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
await this.eventRepository.emit('JobComplete', queueName, job);
}
}
@ -424,11 +421,6 @@ export class JobService extends BaseService {
}
break;
}
case JobName.UserDelete: {
this.eventRepository.clientBroadcast('on_user_delete', item.data.id);
break;
}
}
}
}

@ -78,8 +78,8 @@ export class NotificationService extends BaseService {
await this.notificationRepository.cleanup();
}
@OnEvent({ name: 'JobFailed' })
async onJobFailed({ job, error }: ArgOf<'JobFailed'>) {
@OnEvent({ name: 'JobError' })
async onJobError({ job, error }: ArgOf<'JobError'>) {
const admin = await this.userRepository.getAdmin();
if (!admin) {
return;
@ -202,6 +202,11 @@ export class NotificationService extends BaseService {
}
}
@OnEvent({ name: 'UserDelete' })
onUserDelete({ id }: ArgOf<'UserDelete'>) {
this.eventRepository.clientBroadcast('on_user_delete', id);
}
@OnEvent({ name: 'AlbumUpdate' })
async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) {
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);

@ -0,0 +1,59 @@
import { snakeCase } from 'lodash';
import { OnEvent } from 'src/decorators';
import { ImmichWorker, JobStatus } from 'src/enum';
import { ArgOf, ArgsOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
export class TelemetryService extends BaseService {
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
async onBootstrap(): Promise<void> {
const userCount = await this.userRepository.getCount();
this.telemetryRepository.api.addToGauge('immich.users.total', userCount);
}
@OnEvent({ name: 'UserCreate' })
onUserCreate() {
this.telemetryRepository.api.addToGauge(`immich.users.total`, 1);
}
@OnEvent({ name: 'UserTrash' })
onUserTrash() {
this.telemetryRepository.api.addToGauge(`immich.users.total`, -1);
}
@OnEvent({ name: 'UserRestore' })
onUserRestore() {
this.telemetryRepository.api.addToGauge(`immich.users.total`, 1);
}
@OnEvent({ name: 'JobStart' })
onJobStart(...[queueName]: ArgsOf<'JobStart'>) {
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
}
@OnEvent({ name: 'JobSuccess' })
onJobSuccess({ job, response }: ArgOf<'JobSuccess'>) {
if (response && Object.values(JobStatus).includes(response as JobStatus)) {
const jobMetric = `immich.jobs.${snakeCase(job.name)}.${response}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
}
}
@OnEvent({ name: 'JobError' })
onJobError({ job }: ArgOf<'JobError'>) {
const jobMetric = `immich.jobs.${snakeCase(job.name)}.${JobStatus.Failed}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
}
@OnEvent({ name: 'JobComplete' })
onJobComplete(...[queueName]: ArgsOf<'JobComplete'>) {
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
}
@OnEvent({ name: 'QueueStart' })
onQueueStart({ name }: ArgOf<'QueueStart'>) {
this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1);
}
}

@ -103,7 +103,8 @@ export class UserAdminService extends BaseService {
const status = force ? UserStatus.Removing : UserStatus.Deleted;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
this.telemetryRepository.api.addToGauge(`immich.users.total`, -1);
await this.eventRepository.emit('UserTrash', user);
if (force) {
await this.jobRepository.queue({ name: JobName.UserDelete, data: { id: user.id, force } });
@ -116,7 +117,7 @@ export class UserAdminService extends BaseService {
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
const user = await this.userRepository.restore(id);
this.telemetryRepository.api.addToGauge('immich.users.total', 1);
await this.eventRepository.emit('UserRestore', user);
return mapUserAdmin(user);
}

@ -3,14 +3,14 @@ import { Updateable } from 'kysely';
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { CacheControl, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
import { UserFindOptions } from 'src/repositories/user.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
@ -213,12 +213,6 @@ export class UserService extends BaseService {
};
}
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
async onBootstrap(): Promise<void> {
const userCount = await this.userRepository.getCount();
this.telemetryRepository.api.addToGauge('immich.users.total', userCount);
}
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage();
@ -234,17 +228,17 @@ export class UserService extends BaseService {
}
@OnJob({ name: JobName.UserDelete, queue: QueueName.BackgroundTask })
async handleUserDelete({ id, force }: JobOf<JobName.UserDelete>): Promise<JobStatus> {
async handleUserDelete({ id, force }: JobOf<JobName.UserDelete>) {
const config = await this.getConfig({ withCache: false });
const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) {
return JobStatus.Failed;
return;
}
// just for extra protection here
if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) {
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
return JobStatus.Skipped;
return;
}
this.logger.log(`Deleting user: ${user.id}`);
@ -266,7 +260,7 @@ export class UserService extends BaseService {
await this.albumRepository.deleteAll(user.id);
await this.userRepository.delete(user, true);
return JobStatus.Success;
await this.eventRepository.emit('UserDelete', user);
}
private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean {

@ -44,7 +44,8 @@ beforeAll(async () => {
describe(AuthService.name, () => {
describe('adminSignUp', () => {
it(`should sign up the admin`, async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
await expect(sut.adminSignUp(dto)).resolves.toEqual(

@ -3,10 +3,10 @@ import { DateTime } from 'luxon';
import { ImmichEnvironment, JobName, JobStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { UserService } from 'src/services/user.service';
@ -22,7 +22,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(UserService, {
database: db || defaultDatabase,
real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
mock: [LoggingRepository, JobRepository, TelemetryRepository],
mock: [LoggingRepository, JobRepository, EventRepository],
});
};
@ -35,7 +35,8 @@ beforeAll(async () => {
describe(UserService.name, () => {
describe('create', () => {
it('should create a user', async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual(
expect.objectContaining({ name: user.name, email: user.email }),
@ -43,14 +44,16 @@ describe(UserService.name, () => {
});
it('should reject user with duplicate email', async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
});
it('should not return password', async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = mediumFactory.userInsert({ password: 'password' });
const user = await sut.createUser({ email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined();

@ -20,7 +20,7 @@
<AppShellHeader>
<NavigationBar showUploadButton={false} noBorder />
</AppShellHeader>
<AppShellSidebar bind:open={sidebarStore.isOpen}>
<AppShellSidebar bind:open={sidebarStore.isOpen} class="border-none shadow-none">
<AdminSidebar />
</AppShellSidebar>

@ -6,22 +6,26 @@
import { t } from 'svelte-i18n';
</script>
<HStack wrap>
<p>{$t('mobile_app_download_onboarding_note')}</p>
<HStack>
<Button
size="large"
size="medium"
shape="semi-round"
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
leadingIcon={mdiLinkEdit}
fullWidth
onclick={() => modalManager.show(AppDownloadModal, {})}
leadingIcon={mdiCellphoneArrowDownVariant}
>
{$t('obtainium_configurator')}
{$t('app_stores')}
</Button>
<Button
size="large"
size="medium"
shape="semi-round"
onclick={() => modalManager.show(AppDownloadModal, {})}
leadingIcon={mdiCellphoneArrowDownVariant}
fullWidth
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
leadingIcon={mdiLinkEdit}
>
{$t('app_download_links')}
{$t('obtainium_configurator')}
</Button>
</HStack>
<p>{$t('mobile_app_download_onboarding_note')}</p>

@ -40,7 +40,11 @@
};
</script>
<div in:fade={{ duration: 250 }} out:fade={{ duration: 100 }} class="flex flex-col rounded-lg text-xs p-2 gap-1">
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 100 }}
class="flex flex-col rounded-xl text-xs p-2 gap-1 border border-gray-300 dark:border-subtle bg-primary/10"
>
<div class="flex items-center gap-2">
<div class="flex items-center justify-center">
{#if uploadAsset.state === UploadState.PENDING}
@ -91,12 +95,13 @@
</div>
{#if uploadAsset.state === UploadState.STARTED}
<div class="text-black relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 dark:bg-gray-700">
<div class="h-[15px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`}></div>
<p class="absolute top-0 h-full w-full text-center text-primary text-[10px]">
{#if uploadAsset.message}
<div class="text-black relative mt-[5px] h-[18px] w-full rounded-md bg-gray-300 dark:bg-gray-700">
<div class="h-[18px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`}></div>
<p class="absolute top-0.5 h-full w-full text-center text-white text-[10px]">
{#if uploadAsset.message === $t('asset_hashing')}
{uploadAsset.message}
{:else}
{uploadAsset.message}
{uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s
{/if}
</p>

@ -52,7 +52,7 @@
{#if showDetail}
<div
in:scale={{ duration: 250, easing: quartInOut }}
class="w-[300px] rounded-lg border bg-gray-100 p-4 text-sm shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-white"
class="w-[325px] rounded-xl border border-gray-200 dark:border-subtle p-4 text-sm shadow-xs bg-subtle"
>
<div class="place-item-center mb-4 flex justify-between">
<div class="flex flex-col gap-1">

@ -1,5 +1,5 @@
<script lang="ts">
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge } from '@immich/ui';
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onClose: () => void;
@ -9,35 +9,29 @@
<Modal title={$t('app_download_links')} size="large" {onClose}>
<ModalBody>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="fdroid-link">
F-Droid
</label>
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
<img class="pt-2 pr-10" alt="Get it on F-Droid" src={fdroidBadge} />
</a>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="play-store-link">
Google Play
</label>
<div class="sm:grid sm:grid-cols-2 gap-5">
<div class="flex flex-col place-items-start">
<Text>Google Play</Text>
<a
href="https://play.google.com/store/apps/details?id=app.alextran.immich"
target="_blank"
id="play-store-link"
>
<img alt="Get it on Google Play" src={playStoreBadge} />
<img class="w-[200px] mt-2" alt="Get it on Google Play" src={playStoreBadge} />
</a>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="app-store-link">
App Store
</label>
<div class="flex flex-col place-items-start">
<Text>App Store</Text>
<a href="https://apps.apple.com/us/app/immich/id1613945652" target="_blank" id="app-store-link">
<img class="pt-2 pr-5" alt="Download on the App Store" src={appStoreBadge} width="90%" />
<img class="w-[200px] mt-2" alt="Download on the App Store" src={appStoreBadge} />
</a>
</div>
<div class="flex flex-col place-items-start">
<Text>F-Droid</Text>
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
<img class="w-[200px] mt-2" alt="Get it on F-Droid" src={fdroidBadge} />
</a>
</div>
</div>

@ -1,14 +1,13 @@
<script lang="ts">
import { ConfirmModal, Input } from '@immich/ui';
import { ConfirmModal, Field, Textarea } from '@immich/ui';
import { mdiText } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
onClose: (description?: string) => void;
}
};
let { onClose }: Props = $props();
let description = $state('');
</script>
@ -20,11 +19,8 @@
onClose={(confirmed) => (confirmed ? onClose(description) : onClose())}
>
{#snippet promptSnippet()}
<div class="flex flex-col text-start gap-2">
<div class="flex flex-col">
<label for="description">{$t('description')}</label>
<Input class="immich-form-input" id="description" bind:value={description} />
</div>
</div>
<Field label={$t('description')}>
<Textarea bind:value={description} grow />
</Field>
{/snippet}
</ConfirmModal>

@ -4,7 +4,7 @@
import { SettingInputFieldType } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { createApiKey, Permission } from '@immich/sdk';
import { Button, Modal, ModalBody, obtainiumBadge } from '@immich/ui';
import { Button, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
let inputUrl = $state(location.origin);
let inputApiKey = $state('');
@ -31,64 +31,53 @@
let { onClose }: Props = $props();
</script>
<Modal title={$t('obtainium_configurator')} size="large" {onClose}>
<Modal title={$t('obtainium_configurator')} size="medium" {onClose}>
<ModalBody>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
<div>
<label
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
for="obtainium-configurator"
>
Obtainium
</label>
<div id="obtainium-configurator">
<form>
<div class="mt-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
</div>
<div class="mt-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('api_key')}
bind:value={inputApiKey}
/>
</div>
<div class="">
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
</div>
<div class="mt-2">
<SettingSelect
label={$t('app_architecture_variant')}
bind:value={archVariant}
options={[
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
{ value: 'release', text: 'universal' },
{ value: 'x86_64-release', text: 'x86_64' },
]}
/>
</div>
</form>
<div>
<Text color="muted" size="small">
{$t('obtainium_configurator_instructions')}
</Text>
<form class="mt-4">
<div class="mt-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
</div>
</div>
<div class="content-center">
{#if inputUrl && inputApiKey && archVariant}
<a
href={obtainiumLink}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="obtainium-link"
>
<img class="pt-2 pr-5" alt="Get it on Obtainium" src={obtainiumBadge} />
</a>
{:else}
<p class="immich-form-label pb-2 text-sm" id="obtainium-link">
{$t('obtainium_configurator_instructions')}
</p>
{/if}
</div>
<div class="mt-2 flex gap-2 place-items-center place-content-center">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('api_key')} bind:value={inputApiKey} />
<div class="translate-y-[3px]">
<Button size="small" onclick={() => handleCreate()}>{$t('create_api_key')}</Button>
</div>
</div>
<SettingSelect
label={$t('app_architecture_variant')}
bind:value={archVariant}
options={[
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
{ value: 'release', text: 'universal' },
{ value: 'x86_64-release', text: 'x86_64' },
]}
/>
</form>
{#if inputUrl && inputApiKey && archVariant}
<div class="content-center">
<hr />
<div class="flex place-items-center place-content-center">
<a
href={obtainiumLink}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="obtainium-link"
>
<img class="pt-2 pr-5 h-[80px]" alt="Get it on Obtainium" src={obtainiumBadge} />
</a>
</div>
</div>
{/if}
</div>
</ModalBody>
</Modal>

@ -3,57 +3,51 @@
import { serverConfig } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteUserAdmin, type UserAdminResponseDto, type UserResponseDto } from '@immich/sdk';
import { Checkbox, ConfirmModal, Label } from '@immich/ui';
import { Alert, Checkbox, ConfirmModal, Field, Input, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
user: UserResponseDto;
onClose: (user?: UserAdminResponseDto) => void;
}
};
let { user, onClose }: Props = $props();
let forceDelete = $state(false);
let deleteButtonDisabled = $state(false);
let userIdInput: string = '';
let force = $state(false);
let email = $state('');
let disabled = $derived(force && email !== user.email);
const handleDeleteUser = async () => {
try {
const result = await deleteUserAdmin({
id: user.id,
userAdminDeleteDto: { force: forceDelete },
});
const handleClose = async (confirmed: boolean) => {
if (!confirmed) {
onClose();
return;
}
try {
const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: { force } });
onClose(result);
} catch (error) {
handleError(error, $t('errors.unable_to_delete_user'));
}
};
const handleConfirm = (e: Event) => {
userIdInput = (e.target as HTMLInputElement).value;
deleteButtonDisabled = userIdInput != user.email;
};
</script>
<ConfirmModal
title={$t('delete_user')}
confirmText={forceDelete ? $t('permanently_delete') : $t('delete')}
onClose={(confirmed) => (confirmed ? handleDeleteUser() : onClose())}
disabled={deleteButtonDisabled}
confirmText={force ? $t('permanently_delete') : $t('delete')}
onClose={handleClose}
{disabled}
>
{#snippet promptSnippet()}
<div class="flex flex-col gap-4">
{#if forceDelete}
<p>
<Text>
{#if force}
<FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }}>
{#snippet children({ message })}
<b>{message}</b>
{/snippet}
</FormatMessage>
</p>
{:else}
<p>
{:else}
<FormatMessage
key="admin.user_delete_delay"
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
@ -62,34 +56,20 @@
<b>{message}</b>
{/snippet}
</FormatMessage>
</p>
{/if}
{/if}
</Text>
<div class="flex justify-center items-center gap-2">
<Checkbox
id="queue-user-deletion-checkbox"
color="secondary"
bind:checked={forceDelete}
onCheckedChange={() => (deleteButtonDisabled = forceDelete)}
/>
<div class="flex items-center gap-2">
<Checkbox id="queue-user-deletion-checkbox" color="secondary" bind:checked={force} />
<Label label={$t('admin.user_delete_immediately_checkbox')} for="queue-user-deletion-checkbox" />
</div>
{#if forceDelete}
<p class="text-danger">{$t('admin.force_delete_user_warning')}</p>
<p class="immich-form-label text-sm" id="confirm-user-desc">
{$t('admin.confirm_email_below', { values: { email: user.email } })}
</p>
{#if force}
<Alert color="danger" icon={false}>{$t('admin.force_delete_user_warning')}</Alert>
<input
class="immich-form-input w-full pb-2"
id="confirm-user-id"
aria-describedby="confirm-user-desc"
name="confirm-user-id"
type="text"
oninput={handleConfirm}
/>
<Field label={$t('admin.confirm_email_below', { values: { email: user.email } })}>
<Input bind:value={email} />
</Field>
{/if}
</div>
{/snippet}

@ -364,7 +364,7 @@
{#each userSessions as session (session.id)}
<DeviceCard {session} />
{:else}
<span class="text-subtle">No mobile devices</span>
<span class="text-dark">No mobile devices</span>
{/each}
</Stack>
</div>

@ -90,7 +90,7 @@
component: OnboardingMobileApp,
role: OnboardingRole.USER,
title: $t('mobile_app'),
icon: mdiCellphoneArrowDownVariant, // or you can use mdiCellphone
icon: mdiCellphoneArrowDownVariant,
},
]);
@ -167,7 +167,7 @@
style="width: {(onboardingProgress / onboardingStepCount) * 100}%"
></div>
</div>
<div class="py-8 flex place-content-center place-items-center m-auto">
<div class="py-8 flex place-content-center place-items-center m-auto w-[min(100%,_800px)]">
<OnboardingCard
title={onboardingSteps[index].title}
icon={onboardingSteps[index].icon}