Compare commits

...

10 Commits

Author SHA1 Message Date
Alex Tran
471a60dcb0 Added explicit type for job count 2022-10-06 12:43:02 -05:00
Alex Tran
46994c3355 Up version for release 2022-10-06 12:11:12 -05:00
Alex Tran
642811869c Fixed staging action runs only in PR 2022-10-06 11:38:56 -05:00
Alex Tran
3be4697487 Added docker build and push with PR number as tag for easy testing in production environment 2022-10-06 11:34:27 -05:00
Fynn Petersen-Frey
a3aca4acb5 feat(mobile) Run background service after being killed (#789) 2022-10-06 11:32:45 -05:00
Alex
7587f858ae feat(server/web) Add manual job trigger mechanism to the web (#767) 2022-10-06 11:25:54 -05:00
bo0tzz
854c214bc0 Fix: Use boolean comparison for DISABLE_REVERSE_GEOCODING config (#787) 2022-10-05 15:18:57 -05:00
Fynn Petersen-Frey
5dfce4db34 feat(mobile): background backup progress notifications (#781)
* settings to configure upload progress notifications (none/standard/detailed)
* use native Android notifications to show progress information
* e.g. 50% (30/60) assets
* e.g. Uploading asset XYZ - 25% (2/8MB)
* no longer show errors if canceled by system (losing network)
2022-10-05 09:59:35 -05:00
Zack Pollard
95467fa3c1 Merge pull request #785 from bivainis/patch-1
chore: fix github action name
2022-10-05 12:38:46 +01:00
Gediminas Bivainis
4ec3453558 chore: fix github action name 2022-10-05 12:19:11 +02:00
93 changed files with 3320 additions and 366 deletions

View File

@@ -2,8 +2,6 @@ name: Build and Push Docker Image - Staging
on: on:
workflow_dispatch: workflow_dispatch:
push:
branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
@@ -38,6 +36,7 @@ jobs:
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }} push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
tags: | tags: |
altran1502/immich-server:staging altran1502/immich-server:staging
altran1502/immich-server:${{ github.event.pull_request.number }}
build_and_push_machine_learning_staging: build_and_push_machine_learning_staging:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -67,6 +66,7 @@ jobs:
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }} push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
tags: | tags: |
altran1502/immich-machine-learning:staging altran1502/immich-machine-learning:staging
altran1502/immich-machine-learning:${{ github.event.pull_request.number }}
build_and_push_web_staging: build_and_push_web_staging:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -96,6 +96,7 @@ jobs:
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }} push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
tags: | tags: |
altran1502/immich-web:staging altran1502/immich-web:staging
altran1502/immich-web:${{ github.event.pull_request.number }}
build_and_push_nginx_staging: build_and_push_nginx_staging:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -124,3 +125,4 @@ jobs:
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }} push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
tags: | tags: |
altran1502/immich-proxy:staging altran1502/immich-proxy:staging
altran1502/immich-proxy:${{ github.event.pull_request.number }}

View File

@@ -15,7 +15,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Run Immich Server 2E2 Test - name: Run Immich Server E2E Test
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
server-unit-tests: server-unit-tests:

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich" xmlns:tools="http://schemas.android.com/tools">
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"> <application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
@@ -12,12 +12,15 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" /> <meta-data android:name="flutterEmbedding" android:value="2" />
<!-- Disables default WorkManager initialization to use our custom initialization -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

View File

@@ -1,25 +0,0 @@
package app.alextran.immich
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Catches the event when either the system or the user kills the app
* (does not apply on force close!)
*/
class AppClearedService() : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
return START_NOT_STICKY;
}
override fun onTaskRemoved(rootIntent: Intent) {
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
stopSelf();
}
}

View File

@@ -38,14 +38,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!! val ctx = context!!
when(call.method) { when (call.method) {
"enable" -> { "enable" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit() .edit()
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.apply() .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true) result.success(true)
} }

View File

@@ -1,5 +1,6 @@
package app.alextran.immich package app.alextran.immich
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
@@ -47,6 +48,8 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L private var timeBackupStarted: Long = 0L
private var notificationBuilder: NotificationCompat.Builder? = null
private var notificationDetailBuilder: NotificationCompat.Builder? = null
override fun startWork(): ListenableFuture<ListenableWorker.Result> { override fun startWork(): ListenableFuture<ListenableWorker.Result> {
@@ -61,16 +64,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
// Create a Notification channel if necessary // Create a Notification channel if necessary
createChannel() createChannel()
} }
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes // normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely // foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user // requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user) // or by the system learning that immich is important to the user)
setForegroundAsync(createForegroundInfo(title)) val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
} else { .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showBackgroundInfo(title) showInfo(getInfoBuilder(title, indeterminate=true).build())
} }
engine = FlutterEngine(ctx) engine = FlutterEngine(ctx)
@@ -154,18 +155,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
"updateNotification" -> { "updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String val title = args.get(0) as String?
val content = args.get(1) as String val content = args.get(1) as String?
if (isIgnoringBatteryOptimizations) { val progress = args.get(2) as Int
setForegroundAsync(createForegroundInfo(title, content)) val max = args.get(3) as Int
} else { val indeterminate = args.get(4) as Boolean
showBackgroundInfo(title, content) val isDetail = args.get(5) as Boolean
val onlyIfFG = args.get(6) as Boolean
if (!onlyIfFG || isIgnoringBatteryOptimizations) {
showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail)
} }
} }
"showError" -> { "showError" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String val title = args.get(0) as String
val content = args.get(1) as String val content = args.get(1) as String?
val individualTag = args.get(2) as String? val individualTag = args.get(2) as String?
showError(title, content, individualTag) showError(title, content, individualTag)
} }
@@ -182,13 +186,12 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
} }
private fun showError(title: String, content: String, individualTag: String?) { private fun showError(title: String, content: String?, individualTag: String?) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
.setContentTitle(title) .setContentTitle(title)
.setTicker(title) .setTicker(title)
.setContentText(content) .setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.build() .build()
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
} }
@@ -197,38 +200,54 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
notificationManager.cancel(NOTIFICATION_ERROR_ID) notificationManager.cancel(NOTIFICATION_ERROR_ID)
} }
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun clearBackgroundNotification() { private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
notificationManager.cancel(NOTIFICATION_DETAIL_ID)
} }
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo { private fun showInfo(notification: Notification, isDetail: Boolean = false) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
.setContentTitle(title) if (isIgnoringBatteryOptimizations) {
.setTicker(title) setForegroundAsync(ForegroundInfo(id, notification))
.setContentText(content) } else {
.setSmallIcon(R.mipmap.ic_launcher) notificationManager.notify(id, notification)
.setOngoing(true) }
.build() }
return ForegroundInfo(NOTIFICATION_ID, notification)
} private fun getInfoBuilder(
title: String? = null,
content: String? = null,
isDetail: Boolean = false,
progress: Int = 0,
max: Int = 0,
indeterminate: Boolean = false,
): NotificationCompat.Builder {
var builder = if(isDetail) notificationDetailBuilder else notificationBuilder
if (builder == null) {
builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
if (isDetail) {
notificationDetailBuilder = builder
} else {
notificationBuilder = builder
}
}
if (title != null) {
builder.setTicker(title).setContentTitle(title)
}
if (content != null) {
builder.setContentText(content)
}
return builder.setProgress(max, progress, indeterminate)
}
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun createChannel() { private fun createChannel() {
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW) val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(foreground) notificationManager.createNotificationChannel(foreground)
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT) val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
notificationManager.createNotificationChannel(error) notificationManager.createNotificationChannel(error)
} }
@@ -244,6 +263,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_ERROR_ID = 2
private const val NOTIFICATION_DETAIL_ID = 3
private const val ONE_MINUTE = 60000L private const val ONE_MINUTE = 60000L
/** /**

View File

@@ -46,9 +46,6 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
* @param context Android Context * @param context Android Context
*/ */
fun enable(context: Context, immediate: Boolean = false) { fun enable(context: Context, immediate: Boolean = false) {
// migration to remove any old active background task
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP) enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
Log.d(TAG, "enabled ContentObserverWorker") Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) { if (immediate) {
@@ -123,8 +120,10 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work) WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
} }
private fun startBackupWorker(context: Context, delayMilliseconds: Long) { fun startBackupWorker(context: Context, delayMilliseconds: Long) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
return
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true) val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false) val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds) BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)

View File

@@ -0,0 +1,19 @@
package app.alextran.immich
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
class ImmichApp : Application() {
override fun onCreate() {
super.onCreate()
val config = Configuration.Builder().build()
WorkManager.initialize(this, config)
// always start BackupWorker after WorkManager init; this fixes the following bug:
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.
// As a workaround, we also run a backup check when initializing the application
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
}
}

View File

@@ -5,21 +5,11 @@ import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle import android.os.Bundle
import android.content.Intent import android.content.Intent
class MainActivity: FlutterActivity() { class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.getPlugins().add(BackgroundServicePlugin()) flutterEngine.plugins.add(BackgroundServicePlugin())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
startService(Intent(getBaseContext(), AppClearedService::class.java));
} catch (e: Exception) {
// startService must not be called when app is in background (crashes app)
// there is nothing we can do
}
} }
} }

View File

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

View File

@@ -0,0 +1,2 @@
* Fixed run background service after being killed
* Added background backup progress notifications

View File

@@ -134,6 +134,10 @@
"setting_notifications_notify_never": "never", "setting_notifications_notify_never": "never",
"setting_notifications_subtitle": "Adjust your notification preferences", "setting_notifications_subtitle": "Adjust your notification preferences",
"setting_notifications_title": "Notifications", "setting_notifications_title": "Notifications",
"setting_notifications_total_progress_title": "Show background backup total progress",
"setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
"setting_notifications_single_progress_title": "Show background backup detail progress",
"setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset",
"setting_pages_app_bar_settings": "Settings", "setting_pages_app_bar_settings": "Settings",
"share_add": "Add", "share_add": "Add",
"share_add_photos": "Add photos", "share_add_photos": "Add photos",

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.30.2" version_number: "1.31.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -27,11 +27,11 @@ final backgroundServiceProvider = Provider(
/// Background backup service /// Background backup service
class BackgroundService { class BackgroundService {
static const String _portNameLock = "immichLock"; static const String _portNameLock = "immichLock";
BackgroundService();
static const MethodChannel _foregroundChannel = static const MethodChannel _foregroundChannel =
MethodChannel('immich/foregroundChannel'); MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel = static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel'); MethodChannel('immich/backgroundChannel');
static final NumberFormat numberFormat = NumberFormat("###0.##");
bool _isBackgroundInitialized = false; bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken; CancellationToken? _cancellationToken;
bool _canceledBySystem = false; bool _canceledBySystem = false;
@@ -40,6 +40,10 @@ class BackgroundService {
SendPort? _waitingIsolate; SendPort? _waitingIsolate;
ReceivePort? _rp; ReceivePort? _rp;
bool _errorGracePeriodExceeded = true; bool _errorGracePeriodExceeded = true;
int _uploadedAssetsCount = 0;
int _assetsToUploadCount = 0;
int _lastDetailProgressUpdate = 0;
String _lastPrintedProgress = "";
bool get isBackgroundInitialized { bool get isBackgroundInitialized {
return _isBackgroundInitialized; return _isBackgroundInitialized;
@@ -125,22 +129,29 @@ class BackgroundService {
} }
/// Updates the notification shown by the background service /// Updates the notification shown by the background service
Future<bool> _updateNotification({ Future<bool?> _updateNotification({
required String title, String? title,
String? content, String? content,
int progress = 0,
int max = 0,
bool indeterminate = false,
bool isDetail = false,
bool onlyIfFG = false,
}) async { }) async {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return true; return true;
} }
try { try {
if (_isBackgroundInitialized) { if (_isBackgroundInitialized) {
return await _backgroundChannel return _backgroundChannel.invokeMethod<bool>(
.invokeMethod('updateNotification', [title, content]); 'updateNotification',
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
);
} }
} catch (error) { } catch (error) {
debugPrint("[_updateNotification] failed to communicate with plugin"); debugPrint("[_updateNotification] failed to communicate with plugin");
} }
return Future.value(false); return false;
} }
/// Shows a new priority notification /// Shows a new priority notification
@@ -274,6 +285,7 @@ class BackgroundService {
case "onAssetsChanged": case "onAssetsChanged":
final Future<bool> translationsLoaded = loadTranslations(); final Future<bool> translationsLoaded = loadTranslations();
try { try {
_clearErrorNotifications();
final bool hasAccess = await acquireLock(); final bool hasAccess = await acquireLock();
if (!hasAccess) { if (!hasAccess) {
debugPrint("[_callHandler] could not acquire lock, exiting"); debugPrint("[_callHandler] could not acquire lock, exiting");
@@ -313,19 +325,23 @@ class BackgroundService {
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey)); apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
BackupService backupService = BackupService(apiService); BackupService backupService = BackupService(apiService);
AppSettingsService settingsService = AppSettingsService();
final Box<HiveBackupAlbums> box = final Box<HiveBackupAlbums> box =
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
if (backupAlbumInfo == null) { if (backupAlbumInfo == null) {
_clearErrorNotifications();
return true; return true;
} }
await PhotoManager.setIgnorePermissionCheck(true); await PhotoManager.setIgnorePermissionCheck(true);
do { do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo); final bool backupOk = await _runBackup(
backupService,
settingsService,
backupAlbumInfo,
);
if (backupOk) { if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince); await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put( await box.put(
@@ -346,9 +362,14 @@ class BackgroundService {
Future<bool> _runBackup( Future<bool> _runBackup(
BackupService backupService, BackupService backupService,
AppSettingsService settingsService,
HiveBackupAlbums backupAlbumInfo, HiveBackupAlbums backupAlbumInfo,
) async { ) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
final bool notifyTotalProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
final bool notifySingleProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
if (_canceledBySystem) { if (_canceledBySystem) {
return false; return false;
@@ -372,22 +393,29 @@ class BackgroundService {
} }
if (toUpload.isEmpty) { if (toUpload.isEmpty) {
_clearErrorNotifications();
return true; return true;
} }
_assetsToUploadCount = toUpload.length;
_uploadedAssetsCount = 0;
_updateNotification(
title: "backup_background_service_in_progress_notification".tr(),
content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
progress: 0,
max: notifyTotalProgress ? _assetsToUploadCount : 0,
indeterminate: !notifyTotalProgress,
onlyIfFG: !notifyTotalProgress,
);
_cancellationToken = CancellationToken(); _cancellationToken = CancellationToken();
final bool ok = await backupService.backupAsset( final bool ok = await backupService.backupAsset(
toUpload, toUpload,
_cancellationToken!, _cancellationToken!,
_onAssetUploaded, notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
_onProgress, notifySingleProgress ? _onProgress : (sent, total) {},
_onSetCurrentBackupAsset, notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
_onBackupError, _onBackupError,
); );
if (ok) { if (!ok && !_cancellationToken!.isCancelled) {
_clearErrorNotifications();
} else {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(), content: "backup_background_service_backup_failed_message".tr(),
@@ -396,16 +424,43 @@ class BackgroundService {
return ok; return ok;
} }
void _onAssetUploaded(String deviceAssetId, String deviceId) { String _formatAssetBackupProgress() {
debugPrint("Uploaded $deviceAssetId from $deviceId"); final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
} }
void _onProgress(int sent, int total) {} void _onAssetUploaded(String deviceAssetId, String deviceId) {
debugPrint("Uploaded $deviceAssetId from $deviceId");
_uploadedAssetsCount++;
_updateNotification(
progress: _uploadedAssetsCount,
max: _assetsToUploadCount,
content: _formatAssetBackupProgress(),
);
}
void _onProgress(int sent, int total) {
final int now = Timeline.now;
// limit updates to 10 per second (or Android drops important notifications)
if (now > _lastDetailProgressUpdate + 100000) {
final String msg = _humanReadableBytesProgress(sent, total);
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
if (msg != _lastPrintedProgress) {
_lastDetailProgressUpdate = now;
_lastPrintedProgress = msg;
_updateNotification(
progress: sent,
max: total,
isDetail: true,
content: msg,
);
}
}
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) { void _onBackupError(ErrorUploadAsset errorAssetInfo) {
_showErrorNotification( _showErrorNotification(
title: "Upload failed", title: "backup_background_service_upload_failure_notification"
content: "backup_background_service_upload_failure_notification"
.tr(args: [errorAssetInfo.fileName]), .tr(args: [errorAssetInfo.fileName]),
individualTag: errorAssetInfo.id, individualTag: errorAssetInfo.id,
); );
@@ -413,14 +468,17 @@ class BackgroundService {
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
_updateNotification( _updateNotification(
title: "backup_background_service_in_progress_notification".tr(), title: "backup_background_service_current_upload_notification"
content: "backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]), .tr(args: [currentUploadAsset.fileName]),
content: "",
isDetail: true,
progress: 0,
max: 0,
); );
} }
bool _isErrorGracePeriodExceeded() { bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
final int value = AppSettingsService() final int value = appSettingsService
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod); .getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
if (value == 0) { if (value == 0) {
return true; return true;
@@ -445,6 +503,26 @@ class BackgroundService {
assert(false, "Invalid value"); assert(false, "Invalid value");
return true; return true;
} }
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
String unit = "KB"; // Kilobyte
if (bytesTotal >= 0x40000000) {
unit = "GB"; // Gigabyte
bytes >>= 20;
bytesTotal >>= 20;
} else if (bytesTotal >= 0x100000) {
unit = "MB"; // Megabyte
bytes >>= 10;
bytesTotal >>= 10;
} else if (bytesTotal < 0x400) {
return "$bytes / $bytesTotal B";
}
final int percent = (bytes * 100) ~/ bytesTotal;
final String done = numberFormat.format(bytes / 1024.0);
final String total = numberFormat.format(bytesTotal / 1024.0);
return "$percent% ($done/$total$unit)";
}
} }
/// entry point called by Kotlin/Java code; needs to be a top-level function /// entry point called by Kotlin/Java code; needs to be a top-level function

View File

@@ -6,7 +6,11 @@ enum AppSettingsEnum<T> {
themeMode<String>("themeMode", "system"), // "light","dark","system" themeMode<String>("themeMode", "system"), // "light","dark","system"
tilesPerRow<int>("tilesPerRow", 4), tilesPerRow<int>("tilesPerRow", 4),
uploadErrorNotificationGracePeriod<int>( uploadErrorNotificationGracePeriod<int>(
"uploadErrorNotificationGracePeriod", 2), "uploadErrorNotificationGracePeriod",
2,
),
backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
storageIndicator<bool>("storageIndicator", true), storageIndicator<bool>("storageIndicator", true),
thumbnailCacheSize<int>("thumbnailCacheSize", 10000), thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
imageCacheSize<int>("imageCacheSize", 350), imageCacheSize<int>("imageCacheSize", 350),

View File

@@ -15,12 +15,20 @@ class NotificationSetting extends HookConsumerWidget {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
final sliderValue = useState(0.0); final sliderValue = useState(0.0);
final totalProgressValue =
useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
final singleProgressValue =
useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
useEffect( useEffect(
() { () {
sliderValue.value = appSettingService sliderValue.value = appSettingService
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod) .getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
.toDouble(); .toDouble();
totalProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
singleProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
return null; return null;
}, },
[], [],
@@ -42,6 +50,22 @@ class NotificationSetting extends HookConsumerWidget {
), ),
).tr(), ).tr(),
children: [ children: [
_buildSwitchListTile(
context,
appSettingService,
totalProgressValue,
AppSettingsEnum.backgroundBackupTotalProgress,
title: 'setting_notifications_total_progress_title'.tr(),
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
),
_buildSwitchListTile(
context,
appSettingService,
singleProgressValue,
AppSettingsEnum.backgroundBackupSingleProgress,
title: 'setting_notifications_single_progress_title'.tr(),
subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
),
ListTile( ListTile(
isThreeLine: false, isThreeLine: false,
dense: true, dense: true,
@@ -53,7 +77,9 @@ class NotificationSetting extends HookConsumerWidget {
value: sliderValue.value, value: sliderValue.value,
onChanged: (double v) => sliderValue.value = v, onChanged: (double v) => sliderValue.value = v,
onChangeEnd: (double v) => appSettingService.setSetting( onChangeEnd: (double v) => appSettingService.setSetting(
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()), AppSettingsEnum.uploadErrorNotificationGracePeriod,
v.toInt(),
),
max: 5.0, max: 5.0,
divisions: 5, divisions: 5,
label: formattedValue, label: formattedValue,
@@ -65,6 +91,28 @@ class NotificationSetting extends HookConsumerWidget {
} }
} }
SwitchListTile _buildSwitchListTile(
BuildContext context,
AppSettingsService appSettingService,
ValueNotifier<bool> valueNotifier,
AppSettingsEnum settingsEnum, {
required String title,
String? subtitle,
}) {
return SwitchListTile(
key: Key(settingsEnum.name),
value: valueNotifier.value,
onChanged: (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme.of(context).primaryColor,
dense: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: subtitle != null ? Text(subtitle) : null,
);
}
String _formatSliderValue(double v) { String _formatSliderValue(double v) {
if (v == 0.0) { if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr(); return 'setting_notifications_notify_immediately'.tr();

View File

@@ -8,6 +8,7 @@ doc/AdminSignupResponseDto.md
doc/AlbumApi.md doc/AlbumApi.md
doc/AlbumCountResponseDto.md doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md doc/AlbumResponseDto.md
doc/AllJobStatusResponseDto.md
doc/AssetApi.md doc/AssetApi.md
doc/AssetCountByTimeBucket.md doc/AssetCountByTimeBucket.md
doc/AssetCountByTimeBucketResponseDto.md doc/AssetCountByTimeBucketResponseDto.md
@@ -33,6 +34,12 @@ doc/DeviceTypeEnum.md
doc/ExifResponseDto.md doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.md doc/GetAssetByTimeBucketDto.md
doc/GetAssetCountByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md
doc/JobApi.md
doc/JobCommand.md
doc/JobCommandDto.md
doc/JobCounts.md
doc/JobId.md
doc/JobStatusResponseDto.md
doc/LoginCredentialDto.md doc/LoginCredentialDto.md
doc/LoginResponseDto.md doc/LoginResponseDto.md
doc/LogoutResponseDto.md doc/LogoutResponseDto.md
@@ -59,6 +66,7 @@ lib/api/album_api.dart
lib/api/asset_api.dart lib/api/asset_api.dart
lib/api/authentication_api.dart lib/api/authentication_api.dart
lib/api/device_info_api.dart lib/api/device_info_api.dart
lib/api/job_api.dart
lib/api/server_info_api.dart lib/api/server_info_api.dart
lib/api/user_api.dart lib/api/user_api.dart
lib/api_client.dart lib/api_client.dart
@@ -74,6 +82,7 @@ lib/model/add_users_dto.dart
lib/model/admin_signup_response_dto.dart lib/model/admin_signup_response_dto.dart
lib/model/album_count_response_dto.dart lib/model/album_count_response_dto.dart
lib/model/album_response_dto.dart lib/model/album_response_dto.dart
lib/model/all_job_status_response_dto.dart
lib/model/asset_count_by_time_bucket.dart lib/model/asset_count_by_time_bucket.dart
lib/model/asset_count_by_time_bucket_response_dto.dart lib/model/asset_count_by_time_bucket_response_dto.dart
lib/model/asset_count_by_user_id_response_dto.dart lib/model/asset_count_by_user_id_response_dto.dart
@@ -96,6 +105,11 @@ lib/model/device_type_enum.dart
lib/model/exif_response_dto.dart lib/model/exif_response_dto.dart
lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_by_time_bucket_dto.dart
lib/model/get_asset_count_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart
lib/model/job_command.dart
lib/model/job_command_dto.dart
lib/model/job_counts.dart
lib/model/job_id.dart
lib/model/job_status_response_dto.dart
lib/model/login_credential_dto.dart lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart lib/model/logout_response_dto.dart

View File

@@ -97,6 +97,9 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info | *DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info |
*DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info | *DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**getJobStatus**](doc//JobApi.md#getjobstatus) | **GET** /jobs/{jobId} |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
@@ -117,6 +120,7 @@ Class | Method | HTTP request | Description
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md) - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
- [AlbumResponseDto](doc//AlbumResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md)
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md) - [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md) - [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
- [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md) - [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
@@ -139,6 +143,11 @@ Class | Method | HTTP request | Description
- [ExifResponseDto](doc//ExifResponseDto.md) - [ExifResponseDto](doc//ExifResponseDto.md)
- [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md) - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
- [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md) - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
- [JobCommand](doc//JobCommand.md)
- [JobCommandDto](doc//JobCommandDto.md)
- [JobCounts](doc//JobCounts.md)
- [JobId](doc//JobId.md)
- [JobStatusResponseDto](doc//JobStatusResponseDto.md)
- [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginCredentialDto](doc//LoginCredentialDto.md)
- [LoginResponseDto](doc//LoginResponseDto.md) - [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md)

View File

@@ -0,0 +1,22 @@
# openapi.model.AllJobStatusResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**thumbnailGenerationQueueCount** | [**JobCounts**](JobCounts.md) | |
**metadataExtractionQueueCount** | [**JobCounts**](JobCounts.md) | |
**videoConversionQueueCount** | [**JobCounts**](JobCounts.md) | |
**machineLearningQueueCount** | [**JobCounts**](JobCounts.md) | |
**isThumbnailGenerationActive** | **bool** | |
**isMetadataExtractionActive** | **bool** | |
**isVideoConversionActive** | **bool** | |
**isMachineLearningActive** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,15 @@
# openapi.model.CreateJobDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**jobType** | [**JobType**](JobType.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -8,13 +8,13 @@ import 'package:openapi/api.dart';
## Properties ## Properties
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**id** | **String** | | [optional] **id** | **int** | | [optional]
**fileSizeInByte** | **int** | | [optional]
**make** | **String** | | [optional] **make** | **String** | | [optional]
**model** | **String** | | [optional] **model** | **String** | | [optional]
**imageName** | **String** | | [optional] **imageName** | **String** | | [optional]
**exifImageWidth** | **num** | | [optional] **exifImageWidth** | **num** | | [optional]
**exifImageHeight** | **num** | | [optional] **exifImageHeight** | **num** | | [optional]
**fileSizeInByte** | **num** | | [optional]
**orientation** | **String** | | [optional] **orientation** | **String** | | [optional]
**dateTimeOriginal** | [**DateTime**](DateTime.md) | | [optional] **dateTimeOriginal** | [**DateTime**](DateTime.md) | | [optional]
**modifyDate** | [**DateTime**](DateTime.md) | | [optional] **modifyDate** | [**DateTime**](DateTime.md) | | [optional]

View File

@@ -0,0 +1,155 @@
# openapi.api.JobApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getAllJobsStatus**](JobApi.md#getalljobsstatus) | **GET** /jobs |
[**getJobStatus**](JobApi.md#getjobstatus) | **GET** /jobs/{jobId} |
[**sendJobCommand**](JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
# **getAllJobsStatus**
> AllJobStatusResponseDto getAllJobsStatus()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = JobApi();
try {
final result = api_instance.getAllJobsStatus();
print(result);
} catch (e) {
print('Exception when calling JobApi->getAllJobsStatus: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**AllJobStatusResponseDto**](AllJobStatusResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getJobStatus**
> JobStatusResponseDto getJobStatus(jobId)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = JobApi();
final jobId = ; // JobId |
try {
final result = api_instance.getJobStatus(jobId);
print(result);
} catch (e) {
print('Exception when calling JobApi->getJobStatus: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**jobId** | [**JobId**](.md)| |
### Return type
[**JobStatusResponseDto**](JobStatusResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **sendJobCommand**
> num sendJobCommand(jobId, jobCommandDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = JobApi();
final jobId = ; // JobId |
final jobCommandDto = JobCommandDto(); // JobCommandDto |
try {
final result = api_instance.sendJobCommand(jobId, jobCommandDto);
print(result);
} catch (e) {
print('Exception when calling JobApi->sendJobCommand: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**jobId** | [**JobId**](.md)| |
**jobCommandDto** | [**JobCommandDto**](JobCommandDto.md)| |
### Return type
**num**
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -0,0 +1,14 @@
# openapi.model.JobCommand
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,15 @@
# openapi.model.JobCommandDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**command** | [**JobCommand**](JobCommand.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,19 @@
# openapi.model.JobCounts
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**active** | **int** | |
**completed** | **int** | |
**failed** | **int** | |
**delayed** | **int** | |
**waiting** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,14 @@
# openapi.model.JobId
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,16 @@
# openapi.model.JobStatusResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**isActive** | **bool** | |
**queueCount** | [**Object**](.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,14 @@
# openapi.model.JobType
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -31,6 +31,7 @@ part 'api/album_api.dart';
part 'api/asset_api.dart'; part 'api/asset_api.dart';
part 'api/authentication_api.dart'; part 'api/authentication_api.dart';
part 'api/device_info_api.dart'; part 'api/device_info_api.dart';
part 'api/job_api.dart';
part 'api/server_info_api.dart'; part 'api/server_info_api.dart';
part 'api/user_api.dart'; part 'api/user_api.dart';
@@ -39,6 +40,7 @@ part 'model/add_users_dto.dart';
part 'model/admin_signup_response_dto.dart'; part 'model/admin_signup_response_dto.dart';
part 'model/album_count_response_dto.dart'; part 'model/album_count_response_dto.dart';
part 'model/album_response_dto.dart'; part 'model/album_response_dto.dart';
part 'model/all_job_status_response_dto.dart';
part 'model/asset_count_by_time_bucket.dart'; part 'model/asset_count_by_time_bucket.dart';
part 'model/asset_count_by_time_bucket_response_dto.dart'; part 'model/asset_count_by_time_bucket_response_dto.dart';
part 'model/asset_count_by_user_id_response_dto.dart'; part 'model/asset_count_by_user_id_response_dto.dart';
@@ -61,6 +63,11 @@ part 'model/device_type_enum.dart';
part 'model/exif_response_dto.dart'; part 'model/exif_response_dto.dart';
part 'model/get_asset_by_time_bucket_dto.dart'; part 'model/get_asset_by_time_bucket_dto.dart';
part 'model/get_asset_count_by_time_bucket_dto.dart'; part 'model/get_asset_count_by_time_bucket_dto.dart';
part 'model/job_command.dart';
part 'model/job_command_dto.dart';
part 'model/job_counts.dart';
part 'model/job_id.dart';
part 'model/job_status_response_dto.dart';
part 'model/login_credential_dto.dart'; part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart'; part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart'; part 'model/logout_response_dto.dart';

View File

@@ -0,0 +1,159 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobApi {
JobApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /jobs' operation and returns the [Response].
Future<Response> getAllJobsStatusWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/jobs';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<AllJobStatusResponseDto?> getAllJobsStatus() async {
final response = await getAllJobsStatusWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AllJobStatusResponseDto',) as AllJobStatusResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /jobs/{jobId}' operation and returns the [Response].
/// Parameters:
///
/// * [JobId] jobId (required):
Future<Response> getJobStatusWithHttpInfo(JobId jobId,) async {
// ignore: prefer_const_declarations
final path = r'/jobs/{jobId}'
.replaceAll('{jobId}', jobId.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [JobId] jobId (required):
Future<JobStatusResponseDto?> getJobStatus(JobId jobId,) async {
final response = await getJobStatusWithHttpInfo(jobId,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'JobStatusResponseDto',) as JobStatusResponseDto;
}
return null;
}
/// Performs an HTTP 'PUT /jobs/{jobId}' operation and returns the [Response].
/// Parameters:
///
/// * [JobId] jobId (required):
///
/// * [JobCommandDto] jobCommandDto (required):
Future<Response> sendJobCommandWithHttpInfo(JobId jobId, JobCommandDto jobCommandDto,) async {
// ignore: prefer_const_declarations
final path = r'/jobs/{jobId}'
.replaceAll('{jobId}', jobId.toString());
// ignore: prefer_final_locals
Object? postBody = jobCommandDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [JobId] jobId (required):
///
/// * [JobCommandDto] jobCommandDto (required):
Future<num?> sendJobCommand(JobId jobId, JobCommandDto jobCommandDto,) async {
final response = await sendJobCommandWithHttpInfo(jobId, jobCommandDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'num',) as num;
}
return null;
}
}

View File

@@ -202,6 +202,8 @@ class ApiClient {
return AlbumCountResponseDto.fromJson(value); return AlbumCountResponseDto.fromJson(value);
case 'AlbumResponseDto': case 'AlbumResponseDto':
return AlbumResponseDto.fromJson(value); return AlbumResponseDto.fromJson(value);
case 'AllJobStatusResponseDto':
return AllJobStatusResponseDto.fromJson(value);
case 'AssetCountByTimeBucket': case 'AssetCountByTimeBucket':
return AssetCountByTimeBucket.fromJson(value); return AssetCountByTimeBucket.fromJson(value);
case 'AssetCountByTimeBucketResponseDto': case 'AssetCountByTimeBucketResponseDto':
@@ -246,6 +248,16 @@ class ApiClient {
return GetAssetByTimeBucketDto.fromJson(value); return GetAssetByTimeBucketDto.fromJson(value);
case 'GetAssetCountByTimeBucketDto': case 'GetAssetCountByTimeBucketDto':
return GetAssetCountByTimeBucketDto.fromJson(value); return GetAssetCountByTimeBucketDto.fromJson(value);
case 'JobCommand':
return JobCommandTypeTransformer().decode(value);
case 'JobCommandDto':
return JobCommandDto.fromJson(value);
case 'JobCounts':
return JobCounts.fromJson(value);
case 'JobId':
return JobIdTypeTransformer().decode(value);
case 'JobStatusResponseDto':
return JobStatusResponseDto.fromJson(value);
case 'LoginCredentialDto': case 'LoginCredentialDto':
return LoginCredentialDto.fromJson(value); return LoginCredentialDto.fromJson(value);
case 'LoginResponseDto': case 'LoginResponseDto':

View File

@@ -64,6 +64,12 @@ String parameterToString(dynamic value) {
if (value is DeviceTypeEnum) { if (value is DeviceTypeEnum) {
return DeviceTypeEnumTypeTransformer().encode(value).toString(); return DeviceTypeEnumTypeTransformer().encode(value).toString();
} }
if (value is JobCommand) {
return JobCommandTypeTransformer().encode(value).toString();
}
if (value is JobId) {
return JobIdTypeTransformer().encode(value).toString();
}
if (value is ThumbnailFormat) { if (value is ThumbnailFormat) {
return ThumbnailFormatTypeTransformer().encode(value).toString(); return ThumbnailFormatTypeTransformer().encode(value).toString();
} }

View File

@@ -0,0 +1,167 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AllJobStatusResponseDto {
/// Returns a new [AllJobStatusResponseDto] instance.
AllJobStatusResponseDto({
required this.thumbnailGenerationQueueCount,
required this.metadataExtractionQueueCount,
required this.videoConversionQueueCount,
required this.machineLearningQueueCount,
required this.isThumbnailGenerationActive,
required this.isMetadataExtractionActive,
required this.isVideoConversionActive,
required this.isMachineLearningActive,
});
JobCounts thumbnailGenerationQueueCount;
JobCounts metadataExtractionQueueCount;
JobCounts videoConversionQueueCount;
JobCounts machineLearningQueueCount;
bool isThumbnailGenerationActive;
bool isMetadataExtractionActive;
bool isVideoConversionActive;
bool isMachineLearningActive;
@override
bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
other.thumbnailGenerationQueueCount == thumbnailGenerationQueueCount &&
other.metadataExtractionQueueCount == metadataExtractionQueueCount &&
other.videoConversionQueueCount == videoConversionQueueCount &&
other.machineLearningQueueCount == machineLearningQueueCount &&
other.isThumbnailGenerationActive == isThumbnailGenerationActive &&
other.isMetadataExtractionActive == isMetadataExtractionActive &&
other.isVideoConversionActive == isVideoConversionActive &&
other.isMachineLearningActive == isMachineLearningActive;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(thumbnailGenerationQueueCount.hashCode) +
(metadataExtractionQueueCount.hashCode) +
(videoConversionQueueCount.hashCode) +
(machineLearningQueueCount.hashCode) +
(isThumbnailGenerationActive.hashCode) +
(isMetadataExtractionActive.hashCode) +
(isVideoConversionActive.hashCode) +
(isMachineLearningActive.hashCode);
@override
String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueueCount=$thumbnailGenerationQueueCount, metadataExtractionQueueCount=$metadataExtractionQueueCount, videoConversionQueueCount=$videoConversionQueueCount, machineLearningQueueCount=$machineLearningQueueCount, isThumbnailGenerationActive=$isThumbnailGenerationActive, isMetadataExtractionActive=$isMetadataExtractionActive, isVideoConversionActive=$isVideoConversionActive, isMachineLearningActive=$isMachineLearningActive]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'thumbnailGenerationQueueCount'] = thumbnailGenerationQueueCount;
_json[r'metadataExtractionQueueCount'] = metadataExtractionQueueCount;
_json[r'videoConversionQueueCount'] = videoConversionQueueCount;
_json[r'machineLearningQueueCount'] = machineLearningQueueCount;
_json[r'isThumbnailGenerationActive'] = isThumbnailGenerationActive;
_json[r'isMetadataExtractionActive'] = isMetadataExtractionActive;
_json[r'isVideoConversionActive'] = isVideoConversionActive;
_json[r'isMachineLearningActive'] = isMachineLearningActive;
return _json;
}
/// Returns a new [AllJobStatusResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AllJobStatusResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AllJobStatusResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AllJobStatusResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AllJobStatusResponseDto(
thumbnailGenerationQueueCount: JobCounts.fromJson(json[r'thumbnailGenerationQueueCount'])!,
metadataExtractionQueueCount: JobCounts.fromJson(json[r'metadataExtractionQueueCount'])!,
videoConversionQueueCount: JobCounts.fromJson(json[r'videoConversionQueueCount'])!,
machineLearningQueueCount: JobCounts.fromJson(json[r'machineLearningQueueCount'])!,
isThumbnailGenerationActive: mapValueOfType<bool>(json, r'isThumbnailGenerationActive')!,
isMetadataExtractionActive: mapValueOfType<bool>(json, r'isMetadataExtractionActive')!,
isVideoConversionActive: mapValueOfType<bool>(json, r'isVideoConversionActive')!,
isMachineLearningActive: mapValueOfType<bool>(json, r'isMachineLearningActive')!,
);
}
return null;
}
static List<AllJobStatusResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AllJobStatusResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AllJobStatusResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AllJobStatusResponseDto> mapFromJson(dynamic json) {
final map = <String, AllJobStatusResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AllJobStatusResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AllJobStatusResponseDto-objects as value to a dart map
static Map<String, List<AllJobStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AllJobStatusResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AllJobStatusResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'thumbnailGenerationQueueCount',
'metadataExtractionQueueCount',
'videoConversionQueueCount',
'machineLearningQueueCount',
'isThumbnailGenerationActive',
'isMetadataExtractionActive',
'isVideoConversionActive',
'isMachineLearningActive',
};
}

View File

@@ -76,72 +76,69 @@ class AssetResponseDto {
SmartInfoResponseDto? smartInfo; SmartInfoResponseDto? smartInfo;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
identical(this, other) || other.type == type &&
other is AssetResponseDto && other.id == id &&
other.type == type && other.deviceAssetId == deviceAssetId &&
other.id == id && other.ownerId == ownerId &&
other.deviceAssetId == deviceAssetId && other.deviceId == deviceId &&
other.ownerId == ownerId && other.originalPath == originalPath &&
other.deviceId == deviceId && other.resizePath == resizePath &&
other.originalPath == originalPath && other.createdAt == createdAt &&
other.resizePath == resizePath && other.modifiedAt == modifiedAt &&
other.createdAt == createdAt && other.isFavorite == isFavorite &&
other.modifiedAt == modifiedAt && other.mimeType == mimeType &&
other.isFavorite == isFavorite && other.duration == duration &&
other.mimeType == mimeType && other.webpPath == webpPath &&
other.duration == duration && other.encodedVideoPath == encodedVideoPath &&
other.webpPath == webpPath && other.exifInfo == exifInfo &&
other.encodedVideoPath == encodedVideoPath && other.smartInfo == smartInfo;
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(type.hashCode) + (type.hashCode) +
(id.hashCode) + (id.hashCode) +
(deviceAssetId.hashCode) + (deviceAssetId.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(deviceId.hashCode) + (deviceId.hashCode) +
(originalPath.hashCode) + (originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) + (resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) + (createdAt.hashCode) +
(modifiedAt.hashCode) + (modifiedAt.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) + (mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) + (duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) + (webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode); (smartInfo == null ? 0 : smartInfo!.hashCode);
@override @override
String toString() => String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
_json[r'type'] = type; _json[r'type'] = type;
_json[r'id'] = id; _json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId; _json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId; _json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId; _json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath; _json[r'originalPath'] = originalPath;
if (resizePath != null) { if (resizePath != null) {
_json[r'resizePath'] = resizePath; _json[r'resizePath'] = resizePath;
} else { } else {
_json[r'resizePath'] = null; _json[r'resizePath'] = null;
} }
_json[r'createdAt'] = createdAt; _json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt; _json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite; _json[r'isFavorite'] = isFavorite;
if (mimeType != null) { if (mimeType != null) {
_json[r'mimeType'] = mimeType; _json[r'mimeType'] = mimeType;
} else { } else {
_json[r'mimeType'] = null; _json[r'mimeType'] = null;
} }
_json[r'duration'] = duration; _json[r'duration'] = duration;
if (webpPath != null) { if (webpPath != null) {
_json[r'webpPath'] = webpPath; _json[r'webpPath'] = webpPath;
} else { } else {
@@ -175,13 +172,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys. // Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null. // Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
// assert(() { assert(() {
// requiredKeys.forEach((key) { requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.'); assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.'); assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// }); });
// return true; return true;
// }()); }());
return AssetResponseDto( return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!, type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -205,10 +202,7 @@ class AssetResponseDto {
return null; return null;
} }
static List<AssetResponseDto>? listFromJson( static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <AssetResponseDto>[]; final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
@@ -236,18 +230,12 @@ class AssetResponseDto {
} }
// maps a json object with a list of AssetResponseDto-objects as value to a dart map // maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson( static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AssetResponseDto>>{}; final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson( final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -274,3 +262,4 @@ class AssetResponseDto {
'encodedVideoPath', 'encodedVideoPath',
}; };
} }

View File

@@ -0,0 +1,111 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CreateJobDto {
/// Returns a new [CreateJobDto] instance.
CreateJobDto({
required this.jobType,
});
JobType jobType;
@override
bool operator ==(Object other) => identical(this, other) || other is CreateJobDto &&
other.jobType == jobType;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(jobType.hashCode);
@override
String toString() => 'CreateJobDto[jobType=$jobType]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'jobType'] = jobType;
return _json;
}
/// Returns a new [CreateJobDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CreateJobDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "CreateJobDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "CreateJobDto[$key]" has a null value in JSON.');
});
return true;
}());
return CreateJobDto(
jobType: JobType.fromJson(json[r'jobType'])!,
);
}
return null;
}
static List<CreateJobDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <CreateJobDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CreateJobDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CreateJobDto> mapFromJson(dynamic json) {
final map = <String, CreateJobDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CreateJobDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CreateJobDto-objects as value to a dart map
static Map<String, List<CreateJobDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CreateJobDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CreateJobDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'jobType',
};
}

View File

@@ -14,12 +14,12 @@ class ExifResponseDto {
/// Returns a new [ExifResponseDto] instance. /// Returns a new [ExifResponseDto] instance.
ExifResponseDto({ ExifResponseDto({
this.id, this.id,
this.fileSizeInByte,
this.make, this.make,
this.model, this.model,
this.imageName, this.imageName,
this.exifImageWidth, this.exifImageWidth,
this.exifImageHeight, this.exifImageHeight,
this.fileSizeInByte,
this.orientation, this.orientation,
this.dateTimeOriginal, this.dateTimeOriginal,
this.modifyDate, this.modifyDate,
@@ -35,7 +35,9 @@ class ExifResponseDto {
this.country, this.country,
}); });
String? id; int? id;
int? fileSizeInByte;
String? make; String? make;
@@ -47,8 +49,6 @@ class ExifResponseDto {
num? exifImageHeight; num? exifImageHeight;
num? fileSizeInByte;
String? orientation; String? orientation;
DateTime? dateTimeOriginal; DateTime? dateTimeOriginal;
@@ -78,12 +78,12 @@ class ExifResponseDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto && bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto &&
other.id == id && other.id == id &&
other.fileSizeInByte == fileSizeInByte &&
other.make == make && other.make == make &&
other.model == model && other.model == model &&
other.imageName == imageName && other.imageName == imageName &&
other.exifImageWidth == exifImageWidth && other.exifImageWidth == exifImageWidth &&
other.exifImageHeight == exifImageHeight && other.exifImageHeight == exifImageHeight &&
other.fileSizeInByte == fileSizeInByte &&
other.orientation == orientation && other.orientation == orientation &&
other.dateTimeOriginal == dateTimeOriginal && other.dateTimeOriginal == dateTimeOriginal &&
other.modifyDate == modifyDate && other.modifyDate == modifyDate &&
@@ -102,12 +102,12 @@ class ExifResponseDto {
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(id == null ? 0 : id!.hashCode) + (id == null ? 0 : id!.hashCode) +
(fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
(make == null ? 0 : make!.hashCode) + (make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) + (model == null ? 0 : model!.hashCode) +
(imageName == null ? 0 : imageName!.hashCode) + (imageName == null ? 0 : imageName!.hashCode) +
(exifImageWidth == null ? 0 : exifImageWidth!.hashCode) + (exifImageWidth == null ? 0 : exifImageWidth!.hashCode) +
(exifImageHeight == null ? 0 : exifImageHeight!.hashCode) + (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) +
(fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode) + (orientation == null ? 0 : orientation!.hashCode) +
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
(modifyDate == null ? 0 : modifyDate!.hashCode) + (modifyDate == null ? 0 : modifyDate!.hashCode) +
@@ -123,7 +123,7 @@ class ExifResponseDto {
(country == null ? 0 : country!.hashCode); (country == null ? 0 : country!.hashCode);
@override @override
String toString() => 'ExifResponseDto[id=$id, make=$make, model=$model, imageName=$imageName, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, fileSizeInByte=$fileSizeInByte, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]'; String toString() => 'ExifResponseDto[id=$id, fileSizeInByte=$fileSizeInByte, make=$make, model=$model, imageName=$imageName, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
@@ -132,6 +132,11 @@ class ExifResponseDto {
} else { } else {
_json[r'id'] = null; _json[r'id'] = null;
} }
if (fileSizeInByte != null) {
_json[r'fileSizeInByte'] = fileSizeInByte;
} else {
_json[r'fileSizeInByte'] = null;
}
if (make != null) { if (make != null) {
_json[r'make'] = make; _json[r'make'] = make;
} else { } else {
@@ -157,11 +162,6 @@ class ExifResponseDto {
} else { } else {
_json[r'exifImageHeight'] = null; _json[r'exifImageHeight'] = null;
} }
if (fileSizeInByte != null) {
_json[r'fileSizeInByte'] = fileSizeInByte;
} else {
_json[r'fileSizeInByte'] = null;
}
if (orientation != null) { if (orientation != null) {
_json[r'orientation'] = orientation; _json[r'orientation'] = orientation;
} else { } else {
@@ -249,7 +249,8 @@ class ExifResponseDto {
}()); }());
return ExifResponseDto( return ExifResponseDto(
id: mapValueOfType<String>(json, r'id'), id: mapValueOfType<int>(json, r'id'),
fileSizeInByte: mapValueOfType<int>(json, r'fileSizeInByte'),
make: mapValueOfType<String>(json, r'make'), make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'), model: mapValueOfType<String>(json, r'model'),
imageName: mapValueOfType<String>(json, r'imageName'), imageName: mapValueOfType<String>(json, r'imageName'),
@@ -259,9 +260,6 @@ class ExifResponseDto {
exifImageHeight: json[r'exifImageHeight'] == null exifImageHeight: json[r'exifImageHeight'] == null
? null ? null
: num.parse(json[r'exifImageHeight'].toString()), : num.parse(json[r'exifImageHeight'].toString()),
fileSizeInByte: json[r'fileSizeInByte'] == null
? null
: num.parse(json[r'fileSizeInByte'].toString()),
orientation: mapValueOfType<String>(json, r'orientation'), orientation: mapValueOfType<String>(json, r'orientation'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''), dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''),
modifyDate: mapDateTime(json, r'modifyDate', ''), modifyDate: mapDateTime(json, r'modifyDate', ''),

View File

@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobCommand {
/// Instantiate a new enum with the provided [value].
const JobCommand._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const start = JobCommand._(r'start');
static const stop = JobCommand._(r'stop');
/// List of all possible values in this [enum][JobCommand].
static const values = <JobCommand>[
start,
stop,
];
static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value);
static List<JobCommand>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobCommand>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = JobCommand.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [JobCommand] to String,
/// and [decode] dynamic data back to [JobCommand].
class JobCommandTypeTransformer {
factory JobCommandTypeTransformer() => _instance ??= const JobCommandTypeTransformer._();
const JobCommandTypeTransformer._();
String encode(JobCommand data) => data.value;
/// Decodes a [dynamic value][data] to a JobCommand.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
JobCommand? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data.toString()) {
case r'start': return JobCommand.start;
case r'stop': return JobCommand.stop;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [JobCommandTypeTransformer] instance.
static JobCommandTypeTransformer? _instance;
}

View File

@@ -0,0 +1,111 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobCommandDto {
/// Returns a new [JobCommandDto] instance.
JobCommandDto({
required this.command,
});
JobCommand command;
@override
bool operator ==(Object other) => identical(this, other) || other is JobCommandDto &&
other.command == command;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(command.hashCode);
@override
String toString() => 'JobCommandDto[command=$command]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'command'] = command;
return _json;
}
/// Returns a new [JobCommandDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static JobCommandDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "JobCommandDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "JobCommandDto[$key]" has a null value in JSON.');
});
return true;
}());
return JobCommandDto(
command: JobCommand.fromJson(json[r'command'])!,
);
}
return null;
}
static List<JobCommandDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobCommandDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = JobCommandDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, JobCommandDto> mapFromJson(dynamic json) {
final map = <String, JobCommandDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = JobCommandDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of JobCommandDto-objects as value to a dart map
static Map<String, List<JobCommandDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<JobCommandDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = JobCommandDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'command',
};
}

View File

@@ -0,0 +1,143 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobCounts {
/// Returns a new [JobCounts] instance.
JobCounts({
required this.active,
required this.completed,
required this.failed,
required this.delayed,
required this.waiting,
});
int active;
int completed;
int failed;
int delayed;
int waiting;
@override
bool operator ==(Object other) => identical(this, other) || other is JobCounts &&
other.active == active &&
other.completed == completed &&
other.failed == failed &&
other.delayed == delayed &&
other.waiting == waiting;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(active.hashCode) +
(completed.hashCode) +
(failed.hashCode) +
(delayed.hashCode) +
(waiting.hashCode);
@override
String toString() => 'JobCounts[active=$active, completed=$completed, failed=$failed, delayed=$delayed, waiting=$waiting]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'active'] = active;
_json[r'completed'] = completed;
_json[r'failed'] = failed;
_json[r'delayed'] = delayed;
_json[r'waiting'] = waiting;
return _json;
}
/// Returns a new [JobCounts] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static JobCounts? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "JobCounts[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "JobCounts[$key]" has a null value in JSON.');
});
return true;
}());
return JobCounts(
active: mapValueOfType<int>(json, r'active')!,
completed: mapValueOfType<int>(json, r'completed')!,
failed: mapValueOfType<int>(json, r'failed')!,
delayed: mapValueOfType<int>(json, r'delayed')!,
waiting: mapValueOfType<int>(json, r'waiting')!,
);
}
return null;
}
static List<JobCounts>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobCounts>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = JobCounts.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, JobCounts> mapFromJson(dynamic json) {
final map = <String, JobCounts>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = JobCounts.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of JobCounts-objects as value to a dart map
static Map<String, List<JobCounts>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<JobCounts>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = JobCounts.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'active',
'completed',
'failed',
'delayed',
'waiting',
};
}

View File

@@ -0,0 +1,91 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobId {
/// Instantiate a new enum with the provided [value].
const JobId._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const thumbnailGeneration = JobId._(r'thumbnail-generation');
static const metadataExtraction = JobId._(r'metadata-extraction');
static const videoConversion = JobId._(r'video-conversion');
static const machineLearning = JobId._(r'machine-learning');
/// List of all possible values in this [enum][JobId].
static const values = <JobId>[
thumbnailGeneration,
metadataExtraction,
videoConversion,
machineLearning,
];
static JobId? fromJson(dynamic value) => JobIdTypeTransformer().decode(value);
static List<JobId>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobId>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = JobId.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [JobId] to String,
/// and [decode] dynamic data back to [JobId].
class JobIdTypeTransformer {
factory JobIdTypeTransformer() => _instance ??= const JobIdTypeTransformer._();
const JobIdTypeTransformer._();
String encode(JobId data) => data.value;
/// Decodes a [dynamic value][data] to a JobId.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
JobId? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data.toString()) {
case r'thumbnail-generation': return JobId.thumbnailGeneration;
case r'metadata-extraction': return JobId.metadataExtraction;
case r'video-conversion': return JobId.videoConversion;
case r'machine-learning': return JobId.machineLearning;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [JobIdTypeTransformer] instance.
static JobIdTypeTransformer? _instance;
}

View File

@@ -0,0 +1,119 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobStatusResponseDto {
/// Returns a new [JobStatusResponseDto] instance.
JobStatusResponseDto({
required this.isActive,
required this.queueCount,
});
bool isActive;
Object queueCount;
@override
bool operator ==(Object other) => identical(this, other) || other is JobStatusResponseDto &&
other.isActive == isActive &&
other.queueCount == queueCount;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(isActive.hashCode) +
(queueCount.hashCode);
@override
String toString() => 'JobStatusResponseDto[isActive=$isActive, queueCount=$queueCount]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'isActive'] = isActive;
_json[r'queueCount'] = queueCount;
return _json;
}
/// Returns a new [JobStatusResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static JobStatusResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "JobStatusResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "JobStatusResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return JobStatusResponseDto(
isActive: mapValueOfType<bool>(json, r'isActive')!,
queueCount: mapValueOfType<Object>(json, r'queueCount')!,
);
}
return null;
}
static List<JobStatusResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobStatusResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = JobStatusResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, JobStatusResponseDto> mapFromJson(dynamic json) {
final map = <String, JobStatusResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = JobStatusResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of JobStatusResponseDto-objects as value to a dart map
static Map<String, List<JobStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<JobStatusResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = JobStatusResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'isActive',
'queueCount',
};
}

View File

@@ -0,0 +1,91 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobType {
/// Instantiate a new enum with the provided [value].
const JobType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const THUMBNAIL_GENERATION = JobType._(r'THUMBNAIL_GENERATION');
static const METADATA_EXTRACTION = JobType._(r'METADATA_EXTRACTION');
static const VIDEO_CONVERSION = JobType._(r'VIDEO_CONVERSION');
static const CHECKSUM_GENERATION = JobType._(r'CHECKSUM_GENERATION');
/// List of all possible values in this [enum][JobType].
static const values = <JobType>[
THUMBNAIL_GENERATION,
METADATA_EXTRACTION,
VIDEO_CONVERSION,
CHECKSUM_GENERATION,
];
static JobType? fromJson(dynamic value) => JobTypeTypeTransformer().decode(value);
static List<JobType>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = JobType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [JobType] to String,
/// and [decode] dynamic data back to [JobType].
class JobTypeTypeTransformer {
factory JobTypeTypeTransformer() => _instance ??= const JobTypeTypeTransformer._();
const JobTypeTypeTransformer._();
String encode(JobType data) => data.value;
/// Decodes a [dynamic value][data] to a JobType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
JobType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data.toString()) {
case r'THUMBNAIL_GENERATION': return JobType.THUMBNAIL_GENERATION;
case r'METADATA_EXTRACTION': return JobType.METADATA_EXTRACTION;
case r'VIDEO_CONVERSION': return JobType.VIDEO_CONVERSION;
case r'CHECKSUM_GENERATION': return JobType.CHECKSUM_GENERATION;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [JobTypeTypeTransformer] instance.
static JobTypeTypeTransformer? _instance;
}

View File

@@ -0,0 +1,52 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AllJobStatusResponseDto
void main() {
// final instance = AllJobStatusResponseDto();
group('test AllJobStatusResponseDto', () {
// bool isThumbnailGenerationActive
test('to test the property `isThumbnailGenerationActive`', () async {
// TODO
});
// Object thumbnailGenerationQueueCount
test('to test the property `thumbnailGenerationQueueCount`', () async {
// TODO
});
// bool isMetadataExtractionActive
test('to test the property `isMetadataExtractionActive`', () async {
// TODO
});
// Object metadataExtractionQueueCount
test('to test the property `metadataExtractionQueueCount`', () async {
// TODO
});
// bool isVideoConversionActive
test('to test the property `isVideoConversionActive`', () async {
// TODO
});
// Object videoConversionQueueCount
test('to test the property `videoConversionQueueCount`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for CreateJobDto
void main() {
// final instance = CreateJobDto();
group('test CreateJobDto', () {
// JobType jobType
test('to test the property `jobType`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,41 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
/// tests for JobApi
void main() {
// final instance = JobApi();
group('tests for JobApi', () {
//Future<Object> create(CreateJobDto createJobDto) async
test('test create', () async {
// TODO
});
//Future<AllJobStatusResponseDto> getAllJobsStatus() async
test('test getAllJobsStatus', () async {
// TODO
});
//Future<JobStatusResponseDto> getJobStatus(JobType jobType) async
test('test getJobStatus', () async {
// TODO
});
//Future<JobStatusResponseDto> stopJob(JobType jobType) async
test('test stopJob', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for JobCommandDto
void main() {
// final instance = JobCommandDto();
group('test JobCommandDto', () {
// JobCommand command
test('to test the property `command`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for JobCommand
void main() {
group('test JobCommand', () {
});
}

View File

@@ -0,0 +1,47 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for JobCounts
void main() {
// final instance = JobCounts();
group('test JobCounts', () {
// num active
test('to test the property `active`', () async {
// TODO
});
// num completed
test('to test the property `completed`', () async {
// TODO
});
// num failed
test('to test the property `failed`', () async {
// TODO
});
// num delayed
test('to test the property `delayed`', () async {
// TODO
});
// num waiting
test('to test the property `waiting`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for JobId
void main() {
group('test JobId', () {
});
}

View File

@@ -0,0 +1,32 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for JobStatusResponseDto
void main() {
// final instance = JobStatusResponseDto();
group('test JobStatusResponseDto', () {
// bool isActive
test('to test the property `isActive`', () async {
// TODO
});
// Object queueCount
test('to test the property `queueCount`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for JobType
void main() {
group('test JobType', () {
});
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.30.2+48 version: 1.31.0+49
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"

View File

@@ -1,4 +1,4 @@
node_modules/ node_modules/
upload/ upload/
dist/ dist/
.reverse-geocoding-dump

View File

@@ -134,6 +134,9 @@ describe('Album service', () => {
getAssetByTimeBucket: jest.fn(), getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(), getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(), getAssetCountByUserId: jest.fn(),
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
}; };
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock); sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);

View File

@@ -29,6 +29,9 @@ export interface IAssetRepository {
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>; getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>; getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>; getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
} }
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY'; export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@@ -40,6 +43,33 @@ export class AssetRepository implements IAssetRepository {
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
) {} ) {}
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.smartInfo', 'si')
.where('asset.resizePath IS NOT NULL')
.andWhere('si.id IS NULL')
.getMany();
}
async getAssetWithNoThumbnail(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.where('asset.resizePath IS NULL')
.orWhere('asset.resizePath = :resizePath', { resizePath: '' })
.orWhere('asset.webpPath IS NULL')
.orWhere('asset.webpPath = :webpPath', { webpPath: '' })
.getMany();
}
async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.exifInfo', 'ei')
.where('ei."assetId" IS NULL')
.getMany();
}
async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> { async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
// Get asset count by AssetType // Get asset count by AssetType
const res = await this.assetRepository const res = await this.assetRepository

View File

@@ -30,7 +30,7 @@ import { CommunicationGateway } from '../communication/communication.gateway';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { IAssetUploadedJob } from '@app/job/index'; import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant'; import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant'; import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
@@ -59,7 +59,7 @@ export class AssetController {
private assetService: AssetService, private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService, private backgroundTaskService: BackgroundTaskService,
@InjectQueue(assetUploadedQueueName) @InjectQueue(QueueNameEnum.ASSET_UPLOADED)
private assetUploadedQueue: Queue<IAssetUploadedJob>, private assetUploadedQueue: Queue<IAssetUploadedJob>,
) {} ) {}

View File

@@ -7,7 +7,7 @@ import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { CommunicationModule } from '../communication/communication.module'; import { CommunicationModule } from '../communication/communication.module';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant'; import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository'; import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
@Module({ @Module({
@@ -16,7 +16,7 @@ import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
BackgroundTaskModule, BackgroundTaskModule,
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: assetUploadedQueueName, name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@@ -107,6 +107,9 @@ describe('AssetService', () => {
getAssetByTimeBucket: jest.fn(), getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(), getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(), getAssetCountByUserId: jest.fn(),
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
}; };
sui = new AssetService(assetRepositoryMock, a); sui = new AssetService(assetRepositoryMock, a);

View File

@@ -1,12 +1,16 @@
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { ApiProperty } from '@nestjs/swagger';
export class ExifResponseDto { export class ExifResponseDto {
id?: string | null = null; @ApiProperty({ type: 'integer', format: 'int64' })
id?: number | null = null;
make?: string | null = null; make?: string | null = null;
model?: string | null = null; model?: string | null = null;
imageName?: string | null = null; imageName?: string | null = null;
exifImageWidth?: number | null = null; exifImageWidth?: number | null = null;
exifImageHeight?: number | null = null; exifImageHeight?: number | null = null;
@ApiProperty({ type: 'integer', format: 'int64' })
fileSizeInByte?: number | null = null; fileSizeInByte?: number | null = null;
orientation?: string | null = null; orientation?: string | null = null;
dateTimeOriginal?: Date | null = null; dateTimeOriginal?: Date | null = null;
@@ -25,13 +29,13 @@ export class ExifResponseDto {
export function mapExif(entity: ExifEntity): ExifResponseDto { export function mapExif(entity: ExifEntity): ExifResponseDto {
return { return {
id: entity.id, id: parseInt(entity.id),
make: entity.make, make: entity.make,
model: entity.model, model: entity.model,
imageName: entity.imageName, imageName: entity.imageName,
exifImageWidth: entity.exifImageWidth, exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight, exifImageHeight: entity.exifImageHeight,
fileSizeInByte: entity.fileSizeInByte, fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation, orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal, dateTimeOriginal: entity.dateTimeOriginal,
modifyDate: entity.modifyDate, modifyDate: entity.modifyDate,

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator';
export enum JobId {
THUMBNAIL_GENERATION = 'thumbnail-generation',
METADATA_EXTRACTION = 'metadata-extraction',
VIDEO_CONVERSION = 'video-conversion',
MACHINE_LEARNING = 'machine-learning',
}
export class GetJobDto {
@IsNotEmpty()
@IsEnum(JobId, {
message: `params must be one of ${Object.values(JobId).join()}`,
})
@ApiProperty({
enum: JobId,
enumName: 'JobId',
})
jobId!: string;
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty } from 'class-validator';
export class JobCommandDto {
@IsNotEmpty()
@IsIn(['start', 'stop'])
@ApiProperty({
enum: ['start', 'stop'],
enumName: 'JobCommand',
})
command!: string;
}

View File

@@ -0,0 +1,43 @@
import { Controller, Get, Body, UseGuards, ValidationPipe, Put, Param } from '@nestjs/common';
import { JobService } from './job.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
import { GetJobDto } from './dto/get-job.dto';
import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
import { JobCommandDto } from './dto/job-command.dto';
@UseGuards(JwtAuthGuard)
@UseGuards(AdminRolesGuard)
@ApiTags('Job')
@ApiBearerAuth()
@Controller('jobs')
export class JobController {
constructor(private readonly jobService: JobService) {}
@Get()
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.jobService.getAllJobsStatus();
}
@Get('/:jobId')
getJobStatus(@Param(ValidationPipe) params: GetJobDto): Promise<JobStatusResponseDto> {
return this.jobService.getJobStatus(params);
}
@Put('/:jobId')
async sendJobCommand(
@Param(ValidationPipe) params: GetJobDto,
@Body(ValidationPipe) body: JobCommandDto,
): Promise<number> {
if (body.command === 'start') {
return await this.jobService.startJob(params);
}
if (body.command === 'stop') {
return await this.jobService.stopJob(params);
}
return 0;
}
}

View File

@@ -0,0 +1,82 @@
import { Module } from '@nestjs/common';
import { JobService } from './job.service';
import { JobController } from './job.controller';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { UserEntity } from '@app/database/entities/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { QueueNameEnum } from '@app/job';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, AssetEntity, ExifEntity]),
ImmichJwtModule,
JwtModule.register(jwtConfig),
BullModule.registerQueue(
{
name: QueueNameEnum.THUMBNAIL_GENERATION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.METADATA_EXTRACTION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.CHECKSUM_GENERATION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.MACHINE_LEARNING,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
),
],
controllers: [JobController],
providers: [
JobService,
ImmichJwtService,
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
],
})
export class JobModule {}

View File

@@ -0,0 +1,180 @@
import {
exifExtractionProcessorName,
generateJPEGThumbnailProcessorName,
IMetadataExtractionJob,
IThumbnailGenerationJob,
IVideoTranscodeJob,
MachineLearningJobNameEnum,
QueueNameEnum,
videoMetadataExtractionProcessorName,
} from '@app/job';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
import { randomUUID } from 'crypto';
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
import { AssetType } from '@app/database/entities/asset.entity';
import { GetJobDto, JobId } from './dto/get-job.dto';
import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
@Injectable()
export class JobService {
constructor(
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectQueue(QueueNameEnum.MACHINE_LEARNING)
private machineLearningQueue: Queue<IMachineLearningJob>,
@Inject(ASSET_REPOSITORY)
private _assetRepository: IAssetRepository,
) {
this.thumbnailGeneratorQueue.empty();
this.metadataExtractionQueue.empty();
this.videoConversionQueue.empty();
}
async startJob(jobDto: GetJobDto): Promise<number> {
switch (jobDto.jobId) {
case JobId.THUMBNAIL_GENERATION:
return this.runThumbnailGenerationJob();
case JobId.METADATA_EXTRACTION:
return this.runMetadataExtractionJob();
case JobId.VIDEO_CONVERSION:
return 0;
case JobId.MACHINE_LEARNING:
return this.runMachineLearningPipeline();
default:
throw new BadRequestException('Invalid job id');
}
}
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
const thumbnailGeneratorJobCount = await this.thumbnailGeneratorQueue.getJobCounts();
const metadataExtractionJobCount = await this.metadataExtractionQueue.getJobCounts();
const videoConversionJobCount = await this.videoConversionQueue.getJobCounts();
const machineLearningJobCount = await this.machineLearningQueue.getJobCounts();
const response = new AllJobStatusResponseDto();
response.isThumbnailGenerationActive = Boolean(thumbnailGeneratorJobCount.waiting);
response.thumbnailGenerationQueueCount = thumbnailGeneratorJobCount;
response.isMetadataExtractionActive = Boolean(metadataExtractionJobCount.waiting);
response.metadataExtractionQueueCount = metadataExtractionJobCount;
response.isVideoConversionActive = Boolean(videoConversionJobCount.waiting);
response.videoConversionQueueCount = videoConversionJobCount;
response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting);
response.machineLearningQueueCount = machineLearningJobCount;
return response;
}
async getJobStatus(query: GetJobDto): Promise<JobStatusResponseDto> {
const response = new JobStatusResponseDto();
if (query.jobId === JobId.THUMBNAIL_GENERATION) {
response.isActive = Boolean((await this.thumbnailGeneratorQueue.getJobCounts()).waiting);
response.queueCount = await this.thumbnailGeneratorQueue.getJobCounts();
}
if (query.jobId === JobId.METADATA_EXTRACTION) {
response.isActive = Boolean((await this.metadataExtractionQueue.getJobCounts()).waiting);
response.queueCount = await this.metadataExtractionQueue.getJobCounts();
}
if (query.jobId === JobId.VIDEO_CONVERSION) {
response.isActive = Boolean((await this.videoConversionQueue.getJobCounts()).waiting);
response.queueCount = await this.videoConversionQueue.getJobCounts();
}
return response;
}
async stopJob(query: GetJobDto): Promise<number> {
switch (query.jobId) {
case JobId.THUMBNAIL_GENERATION:
this.thumbnailGeneratorQueue.empty();
return 0;
case JobId.METADATA_EXTRACTION:
this.metadataExtractionQueue.empty();
return 0;
case JobId.VIDEO_CONVERSION:
this.videoConversionQueue.empty();
return 0;
case JobId.MACHINE_LEARNING:
this.machineLearningQueue.empty();
return 0;
default:
throw new BadRequestException('Invalid job id');
}
}
private async runThumbnailGenerationJob(): Promise<number> {
const jobCount = await this.thumbnailGeneratorQueue.getJobCounts();
if (jobCount.waiting > 0) {
throw new BadRequestException('Thumbnail generation job is already running');
}
const assetsWithNoThumbnail = await this._assetRepository.getAssetWithNoThumbnail();
for (const asset of assetsWithNoThumbnail) {
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
}
return assetsWithNoThumbnail.length;
}
private async runMetadataExtractionJob(): Promise<number> {
const jobCount = await this.metadataExtractionQueue.getJobCounts();
if (jobCount.waiting > 0) {
throw new BadRequestException('Metadata extraction job is already running');
}
const assetsWithNoExif = await this._assetRepository.getAssetWithNoEXIF();
for (const asset of assetsWithNoExif) {
if (asset.type === AssetType.VIDEO) {
await this.metadataExtractionQueue.add(
videoMetadataExtractionProcessorName,
{ asset, fileName: asset.id },
{ jobId: randomUUID() },
);
} else {
await this.metadataExtractionQueue.add(
exifExtractionProcessorName,
{ asset, fileName: asset.id },
{ jobId: randomUUID() },
);
}
}
return assetsWithNoExif.length;
}
private async runMachineLearningPipeline(): Promise<number> {
const jobCount = await this.machineLearningQueue.getJobCounts();
if (jobCount.waiting > 0) {
throw new BadRequestException('Metadata extraction job is already running');
}
const assetWithNoSmartInfo = await this._assetRepository.getAssetWithNoSmartInfo();
for (const asset of assetWithNoSmartInfo) {
await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
await this.machineLearningQueue.add(
MachineLearningJobNameEnum.OBJECT_DETECTION,
{ asset },
{ jobId: randomUUID() },
);
}
return assetWithNoSmartInfo.length;
}
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
export class JobCounts {
@ApiProperty({ type: 'integer' })
active!: number;
@ApiProperty({ type: 'integer' })
completed!: number;
@ApiProperty({ type: 'integer' })
failed!: number;
@ApiProperty({ type: 'integer' })
delayed!: number;
@ApiProperty({ type: 'integer' })
waiting!: number;
}
export class AllJobStatusResponseDto {
isThumbnailGenerationActive!: boolean;
isMetadataExtractionActive!: boolean;
isVideoConversionActive!: boolean;
isMachineLearningActive!: boolean;
@ApiProperty({
type: JobCounts,
})
thumbnailGenerationQueueCount!: JobCounts;
@ApiProperty({
type: JobCounts,
})
metadataExtractionQueueCount!: JobCounts;
@ApiProperty({
type: JobCounts,
})
videoConversionQueueCount!: JobCounts;
@ApiProperty({
type: JobCounts,
})
machineLearningQueueCount!: JobCounts;
}

View File

@@ -0,0 +1,6 @@
import Bull from 'bull';
export class JobStatusResponseDto {
isActive!: boolean;
queueCount!: Bull.JobCounts;
}

View File

@@ -5,13 +5,13 @@ export class ServerInfoResponseDto {
diskUse!: string; diskUse!: string;
diskAvailable!: string; diskAvailable!: string;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer', format: 'int64' })
diskSizeRaw!: number; diskSizeRaw!: number;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer', format: 'int64' })
diskUseRaw!: number; diskUseRaw!: number;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer', format: 'int64' })
diskAvailableRaw!: number; diskAvailableRaw!: number;
@ApiProperty({ type: 'number', format: 'float' }) @ApiProperty({ type: 'number', format: 'float' })

View File

@@ -15,6 +15,7 @@ import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database'; import { DatabaseModule } from '@app/database';
import { JobModule } from './api-v1/job/job.module';
@Module({ @Module({
imports: [ imports: [
@@ -55,6 +56,8 @@ import { DatabaseModule } from '@app/database';
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ScheduleTasksModule, ScheduleTasksModule,
JobModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [], providers: [],

View File

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = { export const serverVersion: IServerVersion = {
major: 1, major: 1,
minor: 30, minor: 31,
patch: 2, patch: 0,
build: 48, build: 49,
}; };

View File

@@ -3,18 +3,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ScheduleTasksService } from './schedule-tasks.service'; import { ScheduleTasksService } from './schedule-tasks.service';
import { import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AssetEntity, ExifEntity]), TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: videoConversionQueueName, name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -22,7 +18,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: thumbnailGeneratorQueueName, name: QueueNameEnum.THUMBNAIL_GENERATION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -31,7 +27,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: metadataExtractionQueueName, name: QueueNameEnum.METADATA_EXTRACTION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@@ -12,11 +12,9 @@ import {
generateWEBPThumbnailProcessorName, generateWEBPThumbnailProcessorName,
IMetadataExtractionJob, IMetadataExtractionJob,
IVideoTranscodeJob, IVideoTranscodeJob,
metadataExtractionQueueName,
mp4ConversionProcessorName, mp4ConversionProcessorName,
QueueNameEnum,
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
videoMetadataExtractionProcessorName, videoMetadataExtractionProcessorName,
} from '@app/job'; } from '@app/job';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -30,13 +28,13 @@ export class ScheduleTasksService {
@InjectRepository(ExifEntity) @InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>, private exifRepository: Repository<ExifEntity>,
@InjectQueue(thumbnailGeneratorQueueName) @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
@InjectQueue(videoConversionQueueName) @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>, private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectQueue(metadataExtractionQueueName) @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>, private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
private configService: ConfigService, private configService: ConfigService,
@@ -108,11 +106,11 @@ export class ScheduleTasksService {
@Cron(CronExpression.EVERY_DAY_AT_3AM) @Cron(CronExpression.EVERY_DAY_AT_3AM)
async extractExif() { async extractExif() {
const exifAssets = await this.assetRepository.find({ const exifAssets = await this.assetRepository
where: { .createQueryBuilder('asset')
exifInfo: IsNull(), .leftJoinAndSelect('asset.exifInfo', 'ei')
}, .where('ei."assetId" IS NULL')
}); .getMany();
for (const asset of exifAssets) { for (const asset of exifAssets) {
if (asset.type === AssetType.VIDEO) { if (asset.type === AssetType.VIDEO) {

View File

@@ -4,13 +4,7 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
assetUploadedQueueName,
generateChecksumQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -19,6 +13,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
import { MicroservicesService } from './microservices.service'; import { MicroservicesService } from './microservices.service';
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor'; import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
import { GenerateChecksumProcessor } from './processors/generate-checksum.processor'; import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
import { MachineLearningProcessor } from './processors/machine-learning.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
@@ -42,7 +37,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}), }),
BullModule.registerQueue( BullModule.registerQueue(
{ {
name: thumbnailGeneratorQueueName, name: QueueNameEnum.THUMBNAIL_GENERATION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -50,7 +45,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: assetUploadedQueueName, name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -58,7 +53,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: metadataExtractionQueueName, name: QueueNameEnum.METADATA_EXTRACTION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -66,7 +61,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: videoConversionQueueName, name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -74,7 +69,15 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: generateChecksumQueueName, name: QueueNameEnum.CHECKSUM_GENERATION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.MACHINE_LEARNING,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -92,6 +95,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
MetadataExtractionProcessor, MetadataExtractionProcessor,
VideoTranscodeProcessor, VideoTranscodeProcessor,
GenerateChecksumProcessor, GenerateChecksumProcessor,
MachineLearningProcessor,
ConfigService, ConfigService,
], ],
exports: [], exports: [],

View File

@@ -1,4 +1,4 @@
import { generateChecksumQueueName } from '@app/job'; import { QueueNameEnum } from '@app/job';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bull'; import { Queue } from 'bull';
@@ -6,14 +6,18 @@ import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class MicroservicesService implements OnModuleInit { export class MicroservicesService implements OnModuleInit {
constructor ( constructor(
@InjectQueue(generateChecksumQueueName) @InjectQueue(QueueNameEnum.CHECKSUM_GENERATION)
private generateChecksumQueue: Queue, private generateChecksumQueue: Queue,
) {} ) {}
async onModuleInit() { async onModuleInit() {
await this.generateChecksumQueue.add({}, { await this.generateChecksumQueue.add(
jobId: randomUUID(), delay: 10000 // wait for migration {},
}); {
jobId: randomUUID(),
delay: 10000, // wait for migration
},
);
} }
} }

View File

@@ -4,30 +4,27 @@ import {
IMetadataExtractionJob, IMetadataExtractionJob,
IThumbnailGenerationJob, IThumbnailGenerationJob,
IVideoTranscodeJob, IVideoTranscodeJob,
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
assetUploadedProcessorName, assetUploadedProcessorName,
exifExtractionProcessorName, exifExtractionProcessorName,
generateJPEGThumbnailProcessorName, generateJPEGThumbnailProcessorName,
mp4ConversionProcessorName, mp4ConversionProcessorName,
videoMetadataExtractionProcessorName, videoMetadataExtractionProcessorName,
QueueNameEnum,
} from '@app/job'; } from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull'; import { Job, Queue } from 'bull';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
@Processor(assetUploadedQueueName) @Processor(QueueNameEnum.ASSET_UPLOADED)
export class AssetUploadedProcessor { export class AssetUploadedProcessor {
constructor( constructor(
@InjectQueue(thumbnailGeneratorQueueName) @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>, private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
@InjectQueue(metadataExtractionQueueName) @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>, private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue(videoConversionQueueName) @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>, private videoConversionQueue: Queue<IVideoTranscodeJob>,
) {} ) {}

View File

@@ -1,5 +1,5 @@
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { generateChecksumQueueName } from '@app/job'; import { QueueNameEnum } from '@app/job';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -8,7 +8,7 @@ import fs from 'node:fs';
import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm'; import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
// TODO: just temporary task to generate previous uploaded assets. // TODO: just temporary task to generate previous uploaded assets.
@Processor(generateChecksumQueueName) @Processor(QueueNameEnum.CHECKSUM_GENERATION)
export class GenerateChecksumProcessor { export class GenerateChecksumProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
@@ -33,7 +33,7 @@ export class GenerateChecksumProcessor {
const assets = await this.assetRepository.find({ const assets = await this.assetRepository.find({
where: whereStat, where: whereStat,
take: pageSize, take: pageSize,
order: { id: 'ASC' } order: { id: 'ASC' },
}); });
if (!assets?.length) { if (!assets?.length) {

View File

@@ -0,0 +1,60 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { MachineLearningJobNameEnum, QueueNameEnum } from '@app/job';
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Job } from 'bull';
import { Repository } from 'typeorm';
@Processor(QueueNameEnum.MACHINE_LEARNING)
export class MachineLearningProcessor {
constructor(
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
) {}
@Process({ name: MachineLearningJobNameEnum.IMAGE_TAGGING, concurrency: 2 })
async tagImage(job: Job<IMachineLearningJob>) {
const { asset } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
@Process({ name: MachineLearningJobNameEnum.OBJECT_DETECTION, concurrency: 2 })
async detectObject(job: Job<IMachineLearningJob>) {
try {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
} catch (error) {
Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
}
}
}

View File

@@ -1,23 +1,19 @@
import { ImmichLogLevel } from '@app/common/constants/log-level.constant'; import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { import {
IExifExtractionProcessor, IExifExtractionProcessor,
IVideoLengthExtractionProcessor, IVideoLengthExtractionProcessor,
exifExtractionProcessorName, exifExtractionProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
videoMetadataExtractionProcessorName, videoMetadataExtractionProcessorName,
metadataExtractionQueueName,
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
IReverseGeocodingProcessor, IReverseGeocodingProcessor,
QueueNameEnum,
} from '@app/job'; } from '@app/job';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Job } from 'bull'; import { Job } from 'bull';
import exifr from 'exifr'; import exifr from 'exifr';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
@@ -79,7 +75,7 @@ export interface GeoData {
distance: number; distance: number;
} }
@Processor(metadataExtractionQueueName) @Processor(QueueNameEnum.METADATA_EXTRACTION)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private isGeocodeInitialized = false; private isGeocodeInitialized = false;
private logLevel: ImmichLogLevel; private logLevel: ImmichLogLevel;
@@ -91,12 +87,9 @@ export class MetadataExtractionProcessor {
@InjectRepository(ExifEntity) @InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>, private exifRepository: Repository<ExifEntity>,
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
private configService: ConfigService, private configService: ConfigService,
) { ) {
if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') { if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
Logger.log('Initialising Reverse Geocoding'); Logger.log('Initialising Reverse Geocoding');
geocoderInit({ geocoderInit({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -109,7 +102,8 @@ export class MetadataExtractionProcessor {
alternateNames: false, alternateNames: false,
}, },
countries: [], countries: [],
dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'), dumpDirectory:
configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || process.cwd() + '/.reverse-geocoding-dump/',
}).then(() => { }).then(() => {
this.isGeocodeInitialized = true; this.isGeocodeInitialized = true;
Logger.log('Reverse Geocoding Initialised'); Logger.log('Reverse Geocoding Initialised');
@@ -273,48 +267,6 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: imageTaggingProcessorName, concurrency: 2 })
async tagImage(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
@Process({ name: objectDetectionProcessorName, concurrency: 2 })
async detectObject(job: Job) {
try {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
} catch (error) {
Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
}
}
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 }) @Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) { async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
const { asset, fileName } = job.data; const { asset, fileName } = job.data;

View File

@@ -5,11 +5,9 @@ import {
WebpGeneratorProcessor, WebpGeneratorProcessor,
generateJPEGThumbnailProcessorName, generateJPEGThumbnailProcessorName,
generateWEBPThumbnailProcessorName, generateWEBPThumbnailProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
JpegGeneratorProcessor, JpegGeneratorProcessor,
QueueNameEnum,
MachineLearningJobNameEnum,
} from '@app/job'; } from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@@ -25,8 +23,9 @@ import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository'; import { Repository } from 'typeorm/repository/Repository';
import { join } from 'path'; import { join } from 'path';
import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway'; import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
@Processor(thumbnailGeneratorQueueName) @Processor(QueueNameEnum.THUMBNAIL_GENERATION)
export class ThumbnailGeneratorProcessor { export class ThumbnailGeneratorProcessor {
private logLevel: ImmichLogLevel; private logLevel: ImmichLogLevel;
@@ -34,13 +33,13 @@ export class ThumbnailGeneratorProcessor {
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@InjectQueue(thumbnailGeneratorQueueName) @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
private wsCommunicationGateway: CommunicationGateway, private wsCommunicationGateway: CommunicationGateway,
@InjectQueue(metadataExtractionQueueName) @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
private metadataExtractionQueue: Queue, private machineLearningQueue: Queue<IMachineLearningJob>,
private configService: ConfigService, private configService: ConfigService,
) { ) {
@@ -80,8 +79,12 @@ export class ThumbnailGeneratorProcessor {
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(
MachineLearningJobNameEnum.OBJECT_DETECTION,
{ asset },
{ jobId: randomUUID() },
);
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset))); this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
} }
@@ -110,8 +113,12 @@ export class ThumbnailGeneratorProcessor {
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(
MachineLearningJobNameEnum.OBJECT_DETECTION,
{ asset },
{ jobId: randomUUID() },
);
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset))); this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
} }

View File

@@ -1,7 +1,7 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants'; import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { QueueNameEnum } from '@app/job';
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant'; import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface'; import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@@ -11,7 +11,7 @@ import ffmpeg from 'fluent-ffmpeg';
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync } from 'fs';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@Processor(videoConversionQueueName) @Processor(QueueNameEnum.VIDEO_CONVERSION)
export class VideoTranscodeProcessor { export class VideoTranscodeProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)

File diff suppressed because one or more lines are too long

View File

@@ -20,5 +20,12 @@ export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail';
export const exifExtractionProcessorName = 'exif-extraction'; export const exifExtractionProcessorName = 'exif-extraction';
export const videoMetadataExtractionProcessorName = 'extract-video-metadata'; export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
export const reverseGeocodingProcessorName = 'reverse-geocoding'; export const reverseGeocodingProcessorName = 'reverse-geocoding';
export const objectDetectionProcessorName = 'detect-object';
export const imageTaggingProcessorName = 'tag-image'; /**
* Machine learning Queue Jobs
*/
export enum MachineLearningJobNameEnum {
OBJECT_DETECTION = 'detect-object',
IMAGE_TAGGING = 'tag-image',
}

View File

@@ -1,5 +1,8 @@
export const thumbnailGeneratorQueueName = 'thumbnail-generator-queue'; export enum QueueNameEnum {
export const assetUploadedQueueName = 'asset-uploaded-queue'; THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
export const metadataExtractionQueueName = 'metadata-extraction-queue'; METADATA_EXTRACTION = 'metadata-extraction-queue',
export const videoConversionQueueName = 'video-conversion-queue'; VIDEO_CONVERSION = 'video-conversion-queue',
export const generateChecksumQueueName = 'generate-checksum-queue'; CHECKSUM_GENERATION = 'generate-checksum-queue',
ASSET_UPLOADED = 'asset-uploaded-queue',
MACHINE_LEARNING = 'machine-learning-queue',
}

View File

@@ -0,0 +1,8 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface IMachineLearningJob {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}

View File

@@ -59,7 +59,7 @@
"@nestjs/testing": "^8.4.7", "@nestjs/testing": "^8.4.7",
"@openapitools/openapi-generator-cli": "2.5.1", "@openapitools/openapi-generator-cli": "2.5.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7", "@types/bull": "^3.15.9",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
@@ -2339,9 +2339,9 @@
} }
}, },
"node_modules/@types/bull": { "node_modules/@types/bull": {
"version": "3.15.7", "version": "3.15.9",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz", "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
"integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==", "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@types/ioredis": "*", "@types/ioredis": "*",
@@ -3764,6 +3764,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/cache-manager": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.0.0.tgz",
"integrity": "sha512-1qKdoeoJKmrf95Zvhr3NpBVAgBESt4TuZomBzn4N2gCFZvHjuUXBK1H8EDVsJdba6/grIgi6WGYb/ncJj+wjtg==",
"optional": true,
"peer": true,
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^7.14.0"
}
},
"node_modules/cache-manager/node_modules/lru-cache": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.0.tgz",
"integrity": "sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -7674,6 +7695,13 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"optional": true,
"peer": true
},
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -12900,9 +12928,9 @@
} }
}, },
"@types/bull": { "@types/bull": {
"version": "3.15.7", "version": "3.15.9",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz", "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
"integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==", "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/ioredis": "*", "@types/ioredis": "*",
@@ -14073,6 +14101,26 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
}, },
"cache-manager": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.0.0.tgz",
"integrity": "sha512-1qKdoeoJKmrf95Zvhr3NpBVAgBESt4TuZomBzn4N2gCFZvHjuUXBK1H8EDVsJdba6/grIgi6WGYb/ncJj+wjtg==",
"optional": true,
"peer": true,
"requires": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^7.14.0"
},
"dependencies": {
"lru-cache": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.0.tgz",
"integrity": "sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==",
"optional": true,
"peer": true
}
}
},
"call-bind": { "call-bind": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -17088,6 +17136,13 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}, },
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"optional": true,
"peer": true
},
"lodash.defaults": { "lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",

View File

@@ -78,7 +78,7 @@
"@nestjs/testing": "^8.4.7", "@nestjs/testing": "^8.4.7",
"@openapitools/openapi-generator-cli": "2.5.1", "@openapitools/openapi-generator-cli": "2.5.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7", "@types/bull": "^3.15.9",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",

View File

@@ -4,6 +4,7 @@ import {
AuthenticationApi, AuthenticationApi,
Configuration, Configuration,
DeviceInfoApi, DeviceInfoApi,
JobApi,
ServerInfoApi, ServerInfoApi,
UserApi UserApi
} from './open-api'; } from './open-api';
@@ -15,6 +16,8 @@ class ImmichApi {
public authenticationApi: AuthenticationApi; public authenticationApi: AuthenticationApi;
public deviceInfoApi: DeviceInfoApi; public deviceInfoApi: DeviceInfoApi;
public serverInfoApi: ServerInfoApi; public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
private config = new Configuration({ basePath: '/api' }); private config = new Configuration({ basePath: '/api' });
constructor() { constructor() {
@@ -24,6 +27,7 @@ class ImmichApi {
this.authenticationApi = new AuthenticationApi(this.config); this.authenticationApi = new AuthenticationApi(this.config);
this.deviceInfoApi = new DeviceInfoApi(this.config); this.deviceInfoApi = new DeviceInfoApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config); this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);
} }
public setAccessToken(accessToken: string) { public setAccessToken(accessToken: string) {

View File

@@ -170,6 +170,61 @@ export interface AlbumResponseDto {
*/ */
'assets': Array<AssetResponseDto>; 'assets': Array<AssetResponseDto>;
} }
/**
*
* @export
* @interface AllJobStatusResponseDto
*/
export interface AllJobStatusResponseDto {
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'thumbnailGenerationQueueCount': JobCounts;
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'metadataExtractionQueueCount': JobCounts;
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'videoConversionQueueCount': JobCounts;
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'machineLearningQueueCount': JobCounts;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isThumbnailGenerationActive': boolean;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isMetadataExtractionActive': boolean;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isVideoConversionActive': boolean;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isMachineLearningActive': boolean;
}
/** /**
* *
* @export * @export
@@ -683,10 +738,16 @@ export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum];
export interface ExifResponseDto { export interface ExifResponseDto {
/** /**
* *
* @type {string} * @type {number}
* @memberof ExifResponseDto * @memberof ExifResponseDto
*/ */
'id'?: string | null; 'id'?: number | null;
/**
*
* @type {number}
* @memberof ExifResponseDto
*/
'fileSizeInByte'?: number | null;
/** /**
* *
* @type {string} * @type {string}
@@ -717,12 +778,6 @@ export interface ExifResponseDto {
* @memberof ExifResponseDto * @memberof ExifResponseDto
*/ */
'exifImageHeight'?: number | null; 'exifImageHeight'?: number | null;
/**
*
* @type {number}
* @memberof ExifResponseDto
*/
'fileSizeInByte'?: number | null;
/** /**
* *
* @type {string} * @type {string}
@@ -828,6 +883,105 @@ export interface GetAssetCountByTimeBucketDto {
*/ */
'timeGroup': TimeGroupEnum; 'timeGroup': TimeGroupEnum;
} }
/**
*
* @export
* @enum {string}
*/
export const JobCommand = {
Start: 'start',
Stop: 'stop'
} as const;
export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
/**
*
* @export
* @interface JobCommandDto
*/
export interface JobCommandDto {
/**
*
* @type {JobCommand}
* @memberof JobCommandDto
*/
'command': JobCommand;
}
/**
*
* @export
* @interface JobCounts
*/
export interface JobCounts {
/**
*
* @type {number}
* @memberof JobCounts
*/
'active': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'completed': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'failed': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'delayed': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'waiting': number;
}
/**
*
* @export
* @enum {string}
*/
export const JobId = {
ThumbnailGeneration: 'thumbnail-generation',
MetadataExtraction: 'metadata-extraction',
VideoConversion: 'video-conversion',
MachineLearning: 'machine-learning'
} as const;
export type JobId = typeof JobId[keyof typeof JobId];
/**
*
* @export
* @interface JobStatusResponseDto
*/
export interface JobStatusResponseDto {
/**
*
* @type {boolean}
* @memberof JobStatusResponseDto
*/
'isActive': boolean;
/**
*
* @type {object}
* @memberof JobStatusResponseDto
*/
'queueCount': object;
}
/** /**
* *
* @export * @export
@@ -3682,6 +3836,247 @@ export class DeviceInfoApi extends BaseAPI {
} }
/**
* JobApi - axios parameter creator
* @export
*/
export const JobApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllJobsStatus: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/jobs`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getJobStatus: async (jobId: JobId, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'jobId' is not null or undefined
assertParamExists('getJobStatus', 'jobId', jobId)
const localVarPath = `/jobs/{jobId}`
.replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendJobCommand: async (jobId: JobId, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'jobId' is not null or undefined
assertParamExists('sendJobCommand', 'jobId', jobId)
// verify required parameter 'jobCommandDto' is not null or undefined
assertParamExists('sendJobCommand', 'jobCommandDto', jobCommandDto)
const localVarPath = `/jobs/{jobId}`
.replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(jobCommandDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* JobApi - functional programming interface
* @export
*/
export const JobApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = JobApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllJobsStatus(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AllJobStatusResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllJobsStatus(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getJobStatus(jobId: JobId, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<JobStatusResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getJobStatus(jobId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<number>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* JobApi - factory interface
* @export
*/
export const JobApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = JobApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllJobsStatus(options?: any): AxiosPromise<AllJobStatusResponseDto> {
return localVarFp.getAllJobsStatus(options).then((request) => request(axios, basePath));
},
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getJobStatus(jobId: JobId, options?: any): AxiosPromise<JobStatusResponseDto> {
return localVarFp.getJobStatus(jobId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<number> {
return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
},
};
};
/**
* JobApi - object-oriented interface
* @export
* @class JobApi
* @extends {BaseAPI}
*/
export class JobApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof JobApi
*/
public getAllJobsStatus(options?: AxiosRequestConfig) {
return JobApiFp(this.configuration).getAllJobsStatus(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof JobApi
*/
public getJobStatus(jobId: JobId, options?: AxiosRequestConfig) {
return JobApiFp(this.configuration).getJobStatus(jobId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof JobApi
*/
public sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
}
}
/** /**
* ServerInfoApi - axios parameter creator * ServerInfoApi - axios parameter creator
* @export * @export

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { createEventDispatcher } from 'svelte';
export let title: string;
export let subtitle: string;
export let buttonTitle = 'Run';
export let jobStatus: boolean;
export let waitingJobCount: number;
export let activeJobCount: number;
const dispatch = createEventDispatcher();
</script>
<div class="flex border p-6 rounded-2xl bg-white">
<div class="w-[70%]">
<h1 class="font-medium text-immich-primary">{title}</h1>
<p class="text-sm mt-1 font-medium">{subtitle}</p>
<p class="text-sm">
<slot />
</p>
<table class="text-left w-full mt-4">
<!-- table header -->
<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12">
<tr class="flex w-full place-items-center">
<th class="text-center w-1/3 font-medium text-sm">Status</th>
<th class="text-center w-1/3 font-medium text-sm">Active</th>
<th class="text-center w-1/3 font-medium text-sm">Waiting</th>
</tr>
</thead>
<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border">
<tr class="text-center flex place-items-center w-full h-[40px]">
<td class="text-sm px-2 w-1/3 text-ellipsis">{jobStatus ? 'Active' : 'Idle'}</td>
<td class="text-sm px-2 w-1/3 text-ellipsis">{activeJobCount}</td>
<td class="text-sm px-2 w-1/3 text-ellipsis">{waitingJobCount}</td>
</tr>
</tbody>
</table>
</div>
<div class="w-[30%] flex place-items-center place-content-end">
<button
on:click={() => dispatch('click')}
class="border px-6 py-3 text-sm bg-gray-50 font-medium rounded-2xl hover:bg-immich-primary/10 transition-all hover:cursor-pointer disabled:cursor-not-allowed"
disabled={jobStatus}
>
{#if jobStatus}
<LoadingSpinner />
{:else}
{buttonTitle}
{/if}
</button>
</div>
</div>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { AllJobStatusResponseDto, api, JobCommand, JobId } from '@api';
import { onDestroy, onMount } from 'svelte';
import JobTile from './job-tile.svelte';
let allJobsStatus: AllJobStatusResponseDto;
let setIntervalHandler: NodeJS.Timer;
onMount(async () => {
const { data } = await api.jobApi.getAllJobsStatus();
allJobsStatus = data;
setIntervalHandler = setInterval(async () => {
const { data } = await api.jobApi.getAllJobsStatus();
allJobsStatus = data;
}, 1000);
});
1;
onDestroy(() => {
clearInterval(setIntervalHandler);
});
const runThumbnailGeneration = async () => {
try {
const { data } = await api.jobApi.sendJobCommand(JobId.ThumbnailGeneration, {
command: JobCommand.Start
});
if (data) {
notificationController.show({
message: `Thumbnail generation job started for ${data} asset`,
type: NotificationType.Info
});
} else {
notificationController.show({
message: `No missing thumbnails found`,
type: NotificationType.Info
});
}
} catch (e) {
console.log('[ERROR] runThumbnailGeneration', e);
notificationController.show({
message: `Error running thumbnail generation job, check console for more detail`,
type: NotificationType.Error
});
}
};
const runExtractEXIF = async () => {
try {
const { data } = await api.jobApi.sendJobCommand(JobId.MetadataExtraction, {
command: JobCommand.Start
});
if (data) {
notificationController.show({
message: `Extract EXIF job started for ${data} asset`,
type: NotificationType.Info
});
} else {
notificationController.show({
message: `No missing EXIF found`,
type: NotificationType.Info
});
}
} catch (e) {
console.log('[ERROR] runExtractEXIF', e);
notificationController.show({
message: `Error running extract EXIF job, check console for more detail`,
type: NotificationType.Error
});
}
};
const runMachineLearning = async () => {
try {
const { data } = await api.jobApi.sendJobCommand(JobId.MachineLearning, {
command: JobCommand.Start
});
if (data) {
notificationController.show({
message: `Object detection job started for ${data} asset`,
type: NotificationType.Info
});
} else {
notificationController.show({
message: `No missing object detection found`,
type: NotificationType.Info
});
}
} catch (e) {
console.log('[ERROR] runMachineLearning', e);
notificationController.show({
message: `Error running machine learning job, check console for more detail`,
type: NotificationType.Error
});
}
};
</script>
<div class="flex flex-col gap-6">
<JobTile
title={'Generate thumbnails'}
subtitle={'Regenerate missing thumbnail (JPEG, WEBP)'}
on:click={runThumbnailGeneration}
jobStatus={allJobsStatus?.isThumbnailGenerationActive}
waitingJobCount={allJobsStatus?.thumbnailGenerationQueueCount.waiting}
activeJobCount={allJobsStatus?.thumbnailGenerationQueueCount.active}
/>
<JobTile
title={'Extract EXIF'}
subtitle={'Extract missing EXIF information'}
on:click={runExtractEXIF}
jobStatus={allJobsStatus?.isMetadataExtractionActive}
waitingJobCount={allJobsStatus?.metadataExtractionQueueCount.waiting}
activeJobCount={allJobsStatus?.metadataExtractionQueueCount.active}
/>
<JobTile
title={'Detect objects'}
subtitle={'Run machine learning process to detect and classify objects'}
on:click={runMachineLearning}
jobStatus={allJobsStatus?.isMachineLearningActive}
waitingJobCount={allJobsStatus?.machineLearningQueueCount.waiting}
activeJobCount={allJobsStatus?.machineLearningQueueCount.active}
>
Note that some asset does not have any object detected, this is normal.
</JobTile>
</div>

View File

@@ -94,7 +94,7 @@
<div <div
id="immich-scrubbable-scrollbar" id="immich-scrubbable-scrollbar"
class="fixed right-0 bg-immich-bg z-10 hover:cursor-row-resize select-none" class="fixed right-0 bg-immich-bg z-[999] hover:cursor-row-resize select-none "
style:width={isDragging ? '100vw' : '60px'} style:width={isDragging ? '100vw' : '60px'}
style:background-color={isDragging ? 'transparent' : 'transparent'} style:background-color={isDragging ? 'transparent' : 'transparent'}
on:mouseenter={() => (isHover = true)} on:mouseenter={() => (isHover = true)}

View File

@@ -1,5 +1,7 @@
export enum AdminSideBarSelection { export enum AdminSideBarSelection {
USER_MANAGEMENT = 'User management' USER_MANAGEMENT = 'User management',
JOBS = 'Jobs',
SETTINGS = 'Settings'
} }
export enum AppSideBarSelection { export enum AppSideBarSelection {

View File

@@ -0,0 +1,3 @@
<main>
<slot />
</main>

View File

@@ -4,6 +4,7 @@
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection'; import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte'; import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import UserManagement from '$lib/components/admin-page/user-management.svelte'; import UserManagement from '$lib/components/admin-page/user-management.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@@ -12,6 +13,7 @@
import StatusBox from '$lib/components/shared-components/status-box.svelte'; import StatusBox from '$lib/components/shared-components/status-box.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { api, UserResponseDto } from '@api'; import { api, UserResponseDto } from '@api';
import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT; let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
@@ -104,14 +106,21 @@
{/if} {/if}
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen"> <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col"> <section id="admin-sidebar" class="pt-8 pr-6 flex flex-col gap-1">
<SideBarButton <SideBarButton
title="User" title="Users"
logo={AccountMultipleOutline} logo={AccountMultipleOutline}
actionType={AdminSideBarSelection.USER_MANAGEMENT} actionType={AdminSideBarSelection.USER_MANAGEMENT}
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT} isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
on:selected={onButtonClicked} on:selected={onButtonClicked}
/> />
<SideBarButton
title="Jobs"
logo={Cog}
actionType={AdminSideBarSelection.JOBS}
isSelected={selectedAction === AdminSideBarSelection.JOBS}
on:selected={onButtonClicked}
/>
<div class="mb-6 mt-auto"> <div class="mb-6 mt-auto">
<StatusBox /> <StatusBox />
@@ -132,6 +141,9 @@
on:edit-user={editUserHandler} on:edit-user={editUserHandler}
/> />
{/if} {/if}
{#if selectedAction === AdminSideBarSelection.JOBS}
<JobsPanel />
{/if}
</section> </section>
</section> </section>
</section> </section>