Compare commits

..

50 Commits

Author SHA1 Message Date
Alex
2f5cc3059a Merge pull request #799 from AnTheMaker/patch-1 2022-10-09 11:00:25 -05:00
An | Anton Röhm
4355485581 lower z-index of #immich-scrubbable-scrollbar 2022-10-09 16:55:10 +02:00
An | Anton Röhm
342c3254cb add z-index to #account-info-panel 2022-10-09 16:54:21 +02:00
Alex
5fc82dfaa2 Merge pull request #793 from immich-app/fix/update-reverse-geocoder
Update local-reverse-geocoder to 0.12.5
2022-10-08 15:40:42 -05:00
Alex Tran
9bfacaa39a Specific specific type for enum value for openapi generator to work correctly 2022-10-07 14:30:15 -05:00
Alex Tran
a2882a4908 Added additional type to enum of openapi 2022-10-07 14:26:16 -05:00
Alex
1adc64a352 chore: add GitHub action to generate SDK in Rust/Typescript/Dart 2022-10-07 09:52:11 -05:00
Alex Tran
c28863966b Remove build on PR 2022-10-07 09:50:04 -05:00
Alex Tran
14dc679332 Added SDK to Rust 2022-10-07 09:46:10 -05:00
Alex Tran
17085dd8a0 Added SDK to Rust 2022-10-07 09:39:22 -05:00
Alex Tran
82b8313da0 Fix test 2022-10-07 09:16:45 -05:00
Alex Tran
4f7e764fa0 Fix typing 2022-10-07 09:15:05 -05:00
Alex
d52da8bbea Merge pull request #794 from immich-app/792-bug-encoded-videos-are-not-removed-when-original-asset-is-deleted
fix(server): Delete encoded video when deleting file
2022-10-07 08:47:56 -05:00
Alex Tran
cdddcad784 fix(server): Delete encoded video when deleting file 2022-10-07 08:47:13 -05:00
bo0tzz
38767cad0f Update local-reverse-geocoder to 0.12.5
This version includes a fix to the error handling in that library, which
was causing our code to silently fail and loop.
See https://github.com/tomayac/local-reverse-geocoder/issues/58 for more detail.
2022-10-07 12:14:27 +02:00
Alex Tran
c3d7dda61f Added generation for dart 2022-10-06 17:23:05 -05:00
Alex Tran
c4e32ce159 Rename repo 2022-10-06 16:15:36 -05:00
Alex Tran
6355a07dc4 Added github token custom 2022-10-06 16:09:15 -05:00
Alex Tran
0e3fb41e73 fixed 2022-10-06 15:48:38 -05:00
Alex Tran
fdac5af5ee Added github token 2022-10-06 15:47:47 -05:00
Alex Tran
0e509ceafa Added permissionf or github bot 2022-10-06 15:45:30 -05:00
Alex Tran
fc255b558d fix 2022-10-06 15:40:01 -05:00
Alex Tran
9e54e30011 git push force 2022-10-06 15:38:38 -05:00
Alex Tran
77312ce2e0 Force push 2022-10-06 15:37:03 -05:00
Alex Tran
9a6d29d6e7 Add global config for git 2022-10-06 15:34:42 -05:00
Alex Tran
2cb7517f64 Fix url 2022-10-06 15:33:07 -05:00
Alex Tran
3228882fc0 Authenticate 2022-10-06 15:32:19 -05:00
Alex Tran
6804e3dc73 Fixed 2022-10-06 15:27:31 -05:00
Alex Tran
f9af61a5ca Manually push to repo 2022-10-06 15:21:17 -05:00
Alex Tran
a94b443f13 Push to typescript sdk repo 2022-10-06 15:11:09 -05:00
Alex Tran
fd06aa2135 Add workflow to PR to test 2022-10-06 15:04:24 -05:00
Alex Tran
dd0f40559d added github action file' 2022-10-06 14:59:54 -05:00
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
Alex
536fda04f2 Up version for release 2022-10-04 15:29:47 -05:00
Alex
2094204877 Up version for release 2022-10-04 15:29:37 -05:00
Alex
ab375cca1a Up Version for release 2022-10-04 15:21:58 -05:00
Alex
479f706f8a fix(mobile): Fix error parsing datetime prevent the timeline to be displayed (#784) 2022-10-04 15:19:29 -05:00
Deepesh Bhardwaj
4342285507 Updated jpeg thumbnail path (#780) 2022-10-04 09:46:06 -05:00
Jonas Janz
8bb656cb17 add docker volumes to services (#766)
* add docker volumes to services

this change adds the volume definitions for
/usr/src/app/upload
/usr/src/app/.reverse-geocoding-dump

to the `immich-server` docker-compose files
as /usr/src/app/upload should always be a volume for the containers
I also added it to the `Dockerfile`

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

* remove geocoding-dump volume from docker-compose

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-10-01 16:01:27 -05:00
Alex
3f1f835df3 Update readme for beta release invitation links 2022-09-29 15:13:18 -05:00
Matthias Rupp
87ca031335 Fix bug with missing year and add date to drag handle (#761) 2022-09-29 10:19:55 -05:00
109 changed files with 3524 additions and 449 deletions

View File

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

83
.github/workflows/openapi-generator.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Generate OpenAPI SDK
on:
workflow_dispatch:
push:
branches: [main]
jobs:
generate-typescript-axios:
runs-on: ubuntu-latest
name: OpenAPI Generator
steps:
# Checkout your code
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_TOKEN }}
# Use the action to generate a client package
# This uses the default path for the openapi document and thus assumes there is an openapi.json in the current workspace.
- name: Generate Typescript Axios Client
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: typescript-axios
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
# Do something with the generated client (likely publishing it somewhere)
- name: Push to typescript repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd typescript-axios-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-typescript-axios.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'
- name: Generate Dart SDK
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: dart
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
- name: Push to Dart repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd dart-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-dart.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'
- name: Generate Rust SDK
uses: openapi-generators/openapitools-generator-action@v1
with:
generator: rust
generator-tag: v6.2.0
openapi-file: server/immich-openapi-specs.json
- name: Push to Rust repo
run: |
git config --global init.defaultBranch main
git config --global pull.rebase false
git config --global user.email "alex.tran1502@gmail.com"
git config --global user.name "Alex Tran"
cd rust-client
git init
git add .
git commit -m "Update SDK"
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-rust.git
git pull origin main --allow-unrelated-histories
git push origin main 2>&1 | grep -v 'To https'

View File

@@ -15,7 +15,7 @@ jobs:
- name: Checkout code
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
server-unit-tests:

View File

@@ -46,6 +46,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
- [Installation](#installation)
- [Update](#update)
- [Mobile App](#mobile-app)
- [App Beta Invitation links](#App-Beta-release-channel)
- [Development](#development)
- [Support](#support)
- [Known Issues](#known-issues)
@@ -205,7 +206,11 @@ docker-compose pull && docker-compose up -d
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
# App Beta release channel
You can opt-in to join app beta release channel by following the links below:
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
<br/>
# Development

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich">
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
<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=".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">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
@@ -12,12 +12,15 @@
</intent-filter>
</activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<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>
<uses-permission android:name="android.permission.INTERNET" />
<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

@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
* Android plugin for Dart `BackgroundService`
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
* the background service, e.g. start (enqueue), stop (cancel)
*/
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
@@ -38,14 +38,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!!
when(call.method) {
when (call.method) {
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
.edit()
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true)
}
@@ -54,7 +55,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
val requireUnmeteredNetwork = args.get(0) as Boolean
val requireCharging = args.get(1) as Boolean
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
result.success(true)
result.success(true)
}
"disable" -> {
ContentObserverWorker.disable(ctx)

View File

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

View File

@@ -46,9 +46,6 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
* @param context Android Context
*/
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)
Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) {
@@ -123,8 +120,10 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
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)
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
return
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
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.content.Intent
class MainActivity: FlutterActivity() {
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.getPlugins().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
}
flutterEngine.plugins.add(BackgroundServicePlugin())
}
}

View File

@@ -16,12 +16,17 @@
default_platform(:android)
platform :android do
desc "Build Android"
lane :build do
desc "Build Android and Release Testing"
lane :beta do
gradle(
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 47,
"android.injected.version.name" => "1.30.2",
}
)
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', track: 'beta')
end
desc "Build and Release Android"
@@ -30,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 46,
"android.injected.version.name" => "1.30.0",
"android.injected.version.code" => 49,
"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')

View File

@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## Android
### android build
### android beta
```sh
[bundle exec] fastlane android build
[bundle exec] fastlane android beta
```
Build Android
Build Android and Release Testing
### android release

View File

@@ -0,0 +1 @@
* Improve scroll thumb date info

View File

@@ -0,0 +1 @@
* Fixed parsing date error prevent timeline to be loaded.

View File

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

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="99.857291">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="40.236485">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
</testcase>

View File

@@ -134,6 +134,10 @@
"setting_notifications_notify_never": "never",
"setting_notifications_subtitle": "Adjust your notification preferences",
"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",
"share_add": "Add",
"share_add_photos": "Add photos",

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 60;
CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 60;
CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 60;
CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.29.6</string>
<string>1.30.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>60</string>
<string>62</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

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

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.604146">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.321654">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.655368">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="62.328417">
<testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="62.633232">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
</testcase>

View File

@@ -27,11 +27,11 @@ final backgroundServiceProvider = Provider(
/// Background backup service
class BackgroundService {
static const String _portNameLock = "immichLock";
BackgroundService();
static const MethodChannel _foregroundChannel =
MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel');
static final NumberFormat numberFormat = NumberFormat("###0.##");
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
bool _canceledBySystem = false;
@@ -40,6 +40,10 @@ class BackgroundService {
SendPort? _waitingIsolate;
ReceivePort? _rp;
bool _errorGracePeriodExceeded = true;
int _uploadedAssetsCount = 0;
int _assetsToUploadCount = 0;
int _lastDetailProgressUpdate = 0;
String _lastPrintedProgress = "";
bool get isBackgroundInitialized {
return _isBackgroundInitialized;
@@ -125,22 +129,29 @@ class BackgroundService {
}
/// Updates the notification shown by the background service
Future<bool> _updateNotification({
required String title,
Future<bool?> _updateNotification({
String? title,
String? content,
int progress = 0,
int max = 0,
bool indeterminate = false,
bool isDetail = false,
bool onlyIfFG = false,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized) {
return await _backgroundChannel
.invokeMethod('updateNotification', [title, content]);
return _backgroundChannel.invokeMethod<bool>(
'updateNotification',
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
);
}
} catch (error) {
debugPrint("[_updateNotification] failed to communicate with plugin");
}
return Future.value(false);
return false;
}
/// Shows a new priority notification
@@ -274,6 +285,7 @@ class BackgroundService {
case "onAssetsChanged":
final Future<bool> translationsLoaded = loadTranslations();
try {
_clearErrorNotifications();
final bool hasAccess = await acquireLock();
if (!hasAccess) {
debugPrint("[_callHandler] could not acquire lock, exiting");
@@ -313,19 +325,23 @@ class BackgroundService {
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
BackupService backupService = BackupService(apiService);
AppSettingsService settingsService = AppSettingsService();
final Box<HiveBackupAlbums> box =
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
if (backupAlbumInfo == null) {
_clearErrorNotifications();
return true;
}
await PhotoManager.setIgnorePermissionCheck(true);
do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
final bool backupOk = await _runBackup(
backupService,
settingsService,
backupAlbumInfo,
);
if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put(
@@ -346,9 +362,14 @@ class BackgroundService {
Future<bool> _runBackup(
BackupService backupService,
AppSettingsService settingsService,
HiveBackupAlbums backupAlbumInfo,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
final bool notifyTotalProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
final bool notifySingleProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
if (_canceledBySystem) {
return false;
@@ -372,22 +393,29 @@ class BackgroundService {
}
if (toUpload.isEmpty) {
_clearErrorNotifications();
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();
final bool ok = await backupService.backupAsset(
toUpload,
_cancellationToken!,
_onAssetUploaded,
_onProgress,
_onSetCurrentBackupAsset,
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
notifySingleProgress ? _onProgress : (sent, total) {},
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
_onBackupError,
);
if (ok) {
_clearErrorNotifications();
} else {
if (!ok && !_cancellationToken!.isCancelled) {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(),
@@ -396,16 +424,43 @@ class BackgroundService {
return ok;
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
debugPrint("Uploaded $deviceAssetId from $deviceId");
String _formatAssetBackupProgress() {
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) {
_showErrorNotification(
title: "Upload failed",
content: "backup_background_service_upload_failure_notification"
title: "backup_background_service_upload_failure_notification"
.tr(args: [errorAssetInfo.fileName]),
individualTag: errorAssetInfo.id,
);
@@ -413,14 +468,17 @@ class BackgroundService {
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
_updateNotification(
title: "backup_background_service_in_progress_notification".tr(),
content: "backup_background_service_current_upload_notification"
title: "backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]),
content: "",
isDetail: true,
progress: 0,
max: 0,
);
}
bool _isErrorGracePeriodExceeded() {
final int value = AppSettingsService()
bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
final int value = appSettingsService
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
if (value == 0) {
return true;
@@ -445,6 +503,26 @@ class BackgroundService {
assert(false, "Invalid value");
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

View File

@@ -1,5 +1,6 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -22,16 +23,14 @@ class RenderAssetGridElement {
final RenderAssetGridElementType type;
final RenderAssetGridRow? assetRow;
final String? title;
final int? month;
final int? year;
final DateTime date;
final List<AssetResponseDto>? relatedAssetList;
RenderAssetGridElement(
this.type, {
this.assetRow,
this.title,
this.month,
this.year,
required this.date,
this.relatedAssetList,
});
}
@@ -46,45 +45,50 @@ final renderListProvider = StateProvider((ref) {
DateTime? lastDate;
assetGroups.forEach((groupName, assets) {
final date = DateTime.parse(groupName);
try {
final date = DateTime.parse(groupName);
if (lastDate == null || lastDate!.month != date.month) {
if (lastDate == null || lastDate!.month != date.month) {
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
title: groupName,
date: date,
),
);
}
// Add group title
elements.add(
RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
title: groupName, month: date.month, year: date.year),
);
}
// Add group title
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.dayTitle,
title: groupName,
month: date.month,
year: date.year,
relatedAssetList: assets,
),
);
// Add rows
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
month: date.month,
year: date.year,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
RenderAssetGridElement(
RenderAssetGridElementType.dayTitle,
title: groupName,
date: date,
relatedAssetList: assets,
),
);
elements.add(rowElement);
cursor += rowElements;
}
// Add rows
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
lastDate = date;
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
),
);
elements.add(rowElement);
cursor += rowElements;
}
lastDate = date;
} catch (e) {
debugPrint(e.toString());
}
});
return elements;

View File

@@ -189,6 +189,7 @@ class ScrollLabel extends StatelessWidget {
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: constraints ?? _defaultConstraints,
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: child,
),
@@ -257,8 +258,6 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
Widget build(BuildContext context) {
Text? labelText;
if (widget.labelTextBuilder != null && _isDragInProcess) {
int numberOfItems = widget.child.itemCount;
labelText = widget.labelTextBuilder!(_currentItem);
}

View File

@@ -127,8 +127,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
}
Text _labelBuilder(int pos) {
return Text(
"${renderList[pos].month} / ${renderList[pos].year}",
final date = renderList[pos].date;
return Text(DateFormat.yMMMd().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
@@ -154,6 +154,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: ScrollablePositionedList.builder(

View File

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

View File

@@ -15,12 +15,20 @@ class NotificationSetting extends HookConsumerWidget {
final appSettingService = ref.watch(appSettingsServiceProvider);
final sliderValue = useState(0.0);
final totalProgressValue =
useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
final singleProgressValue =
useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
useEffect(
() {
sliderValue.value = appSettingService
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
.toDouble();
totalProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
singleProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
return null;
},
[],
@@ -42,6 +50,22 @@ class NotificationSetting extends HookConsumerWidget {
),
).tr(),
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(
isThreeLine: false,
dense: true,
@@ -53,7 +77,9 @@ class NotificationSetting extends HookConsumerWidget {
value: sliderValue.value,
onChanged: (double v) => sliderValue.value = v,
onChangeEnd: (double v) => appSettingService.setSetting(
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()),
AppSettingsEnum.uploadErrorNotificationGracePeriod,
v.toInt(),
),
max: 5.0,
divisions: 5,
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) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();

View File

@@ -8,6 +8,7 @@ doc/AdminSignupResponseDto.md
doc/AlbumApi.md
doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md
doc/AllJobStatusResponseDto.md
doc/AssetApi.md
doc/AssetCountByTimeBucket.md
doc/AssetCountByTimeBucketResponseDto.md
@@ -33,6 +34,12 @@ doc/DeviceTypeEnum.md
doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.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/LoginResponseDto.md
doc/LogoutResponseDto.md
@@ -59,6 +66,7 @@ lib/api/album_api.dart
lib/api/asset_api.dart
lib/api/authentication_api.dart
lib/api/device_info_api.dart
lib/api/job_api.dart
lib/api/server_info_api.dart
lib/api/user_api.dart
lib/api_client.dart
@@ -74,6 +82,7 @@ lib/model/add_users_dto.dart
lib/model/admin_signup_response_dto.dart
lib/model/album_count_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_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/get_asset_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_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 |
*DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /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* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
@@ -117,6 +120,7 @@ Class | Method | HTTP request | Description
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
- [AlbumResponseDto](doc//AlbumResponseDto.md)
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
- [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
@@ -139,6 +143,11 @@ Class | Method | HTTP request | Description
- [ExifResponseDto](doc//ExifResponseDto.md)
- [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.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)
- [LoginResponseDto](doc//LoginResponseDto.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
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | | [optional]
**id** | **int** | | [optional]
**fileSizeInByte** | **int** | | [optional]
**make** | **String** | | [optional]
**model** | **String** | | [optional]
**imageName** | **String** | | [optional]
**exifImageWidth** | **num** | | [optional]
**exifImageHeight** | **num** | | [optional]
**fileSizeInByte** | **num** | | [optional]
**orientation** | **String** | | [optional]
**dateTimeOriginal** | [**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/authentication_api.dart';
part 'api/device_info_api.dart';
part 'api/job_api.dart';
part 'api/server_info_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/album_count_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_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/get_asset_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_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);
case 'AlbumResponseDto':
return AlbumResponseDto.fromJson(value);
case 'AllJobStatusResponseDto':
return AllJobStatusResponseDto.fromJson(value);
case 'AssetCountByTimeBucket':
return AssetCountByTimeBucket.fromJson(value);
case 'AssetCountByTimeBucketResponseDto':
@@ -246,6 +248,16 @@ class ApiClient {
return GetAssetByTimeBucketDto.fromJson(value);
case 'GetAssetCountByTimeBucketDto':
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':
return LoginCredentialDto.fromJson(value);
case 'LoginResponseDto':

View File

@@ -64,6 +64,12 @@ String parameterToString(dynamic value) {
if (value is DeviceTypeEnum) {
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) {
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;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode);
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode);
@override
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]';
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]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
if (resizePath != null) {
_json[r'resizePath'] = resizePath;
} else {
_json[r'resizePath'] = null;
}
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
if (mimeType != null) {
_json[r'mimeType'] = mimeType;
} else {
_json[r'mimeType'] = null;
}
_json[r'duration'] = duration;
_json[r'duration'] = duration;
if (webpPath != null) {
_json[r'webpPath'] = webpPath;
} else {
@@ -175,13 +172,13 @@ class AssetResponseDto {
// 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 "AssetResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// });
// return true;
// }());
assert(() {
requiredKeys.forEach((key) {
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.');
});
return true;
}());
return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -205,10 +202,7 @@ class AssetResponseDto {
return null;
}
static List<AssetResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) {
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
static Map<String, List<AssetResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson(
entry.value,
growable: growable,
);
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
@@ -274,3 +262,4 @@ class AssetResponseDto {
'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.
ExifResponseDto({
this.id,
this.fileSizeInByte,
this.make,
this.model,
this.imageName,
this.exifImageWidth,
this.exifImageHeight,
this.fileSizeInByte,
this.orientation,
this.dateTimeOriginal,
this.modifyDate,
@@ -35,7 +35,9 @@ class ExifResponseDto {
this.country,
});
String? id;
int? id;
int? fileSizeInByte;
String? make;
@@ -47,8 +49,6 @@ class ExifResponseDto {
num? exifImageHeight;
num? fileSizeInByte;
String? orientation;
DateTime? dateTimeOriginal;
@@ -78,12 +78,12 @@ class ExifResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto &&
other.id == id &&
other.fileSizeInByte == fileSizeInByte &&
other.make == make &&
other.model == model &&
other.imageName == imageName &&
other.exifImageWidth == exifImageWidth &&
other.exifImageHeight == exifImageHeight &&
other.fileSizeInByte == fileSizeInByte &&
other.orientation == orientation &&
other.dateTimeOriginal == dateTimeOriginal &&
other.modifyDate == modifyDate &&
@@ -102,12 +102,12 @@ class ExifResponseDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(id == null ? 0 : id!.hashCode) +
(fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
(make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) +
(imageName == null ? 0 : imageName!.hashCode) +
(exifImageWidth == null ? 0 : exifImageWidth!.hashCode) +
(exifImageHeight == null ? 0 : exifImageHeight!.hashCode) +
(fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode) +
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
(modifyDate == null ? 0 : modifyDate!.hashCode) +
@@ -123,7 +123,7 @@ class ExifResponseDto {
(country == null ? 0 : country!.hashCode);
@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() {
final _json = <String, dynamic>{};
@@ -132,6 +132,11 @@ class ExifResponseDto {
} else {
_json[r'id'] = null;
}
if (fileSizeInByte != null) {
_json[r'fileSizeInByte'] = fileSizeInByte;
} else {
_json[r'fileSizeInByte'] = null;
}
if (make != null) {
_json[r'make'] = make;
} else {
@@ -157,11 +162,6 @@ class ExifResponseDto {
} else {
_json[r'exifImageHeight'] = null;
}
if (fileSizeInByte != null) {
_json[r'fileSizeInByte'] = fileSizeInByte;
} else {
_json[r'fileSizeInByte'] = null;
}
if (orientation != null) {
_json[r'orientation'] = orientation;
} else {
@@ -249,7 +249,8 @@ class 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'),
model: mapValueOfType<String>(json, r'model'),
imageName: mapValueOfType<String>(json, r'imageName'),
@@ -259,9 +260,6 @@ class ExifResponseDto {
exifImageHeight: json[r'exifImageHeight'] == null
? null
: num.parse(json[r'exifImageHeight'].toString()),
fileSizeInByte: json[r'fileSizeInByte'] == null
? null
: num.parse(json[r'fileSizeInByte'].toString()),
orientation: mapValueOfType<String>(json, r'orientation'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''),
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
publish_to: "none"
version: 1.30.0+46
version: 1.31.0+49
environment:
sdk: ">=2.17.0 <3.0.0"

View File

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

View File

@@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production
VOLUME /usr/src/app/upload
EXPOSE 3001

View File

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

View File

@@ -29,6 +29,9 @@ export interface IAssetRepository {
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
}
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@@ -40,6 +43,33 @@ export class AssetRepository implements IAssetRepository {
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> {
// Get asset count by AssetType
const res = await this.assetRepository

View File

@@ -30,7 +30,7 @@ import { CommunicationGateway } from '../communication/communication.gateway';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
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 { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
@@ -59,7 +59,7 @@ export class AssetController {
private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService,
@InjectQueue(assetUploadedQueueName)
@InjectQueue(QueueNameEnum.ASSET_UPLOADED)
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 { BackgroundTaskService } from '../../modules/background-task/background-task.service';
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';
@Module({
@@ -16,7 +16,7 @@ import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
BackgroundTaskModule,
TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({
name: assetUploadedQueueName,
name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,

View File

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

View File

@@ -9,10 +9,11 @@ export enum GetAssetThumbnailFormatEnum {
export class GetAssetThumbnailDto {
@IsOptional()
@ApiProperty({
type: String,
enum: GetAssetThumbnailFormatEnum,
default: GetAssetThumbnailFormatEnum.WEBP,
required: false,
enumName: 'ThumbnailFormat',
})
format = GetAssetThumbnailFormatEnum.WEBP;
format: GetAssetThumbnailFormatEnum = GetAssetThumbnailFormatEnum.WEBP;
}

View File

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

View File

@@ -0,0 +1,22 @@
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({
type: String,
enum: JobId,
enumName: 'JobId',
})
jobId!: JobId;
}

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;
diskAvailable!: string;
@ApiProperty({ type: 'integer' })
@ApiProperty({ type: 'integer', format: 'int64' })
diskSizeRaw!: number;
@ApiProperty({ type: 'integer' })
@ApiProperty({ type: 'integer', format: 'int64' })
diskUseRaw!: number;
@ApiProperty({ type: 'integer' })
@ApiProperty({ type: 'integer', format: 'int64' })
diskAvailableRaw!: number;
@ApiProperty({ type: 'number', format: 'float' })

View File

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

View File

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

View File

@@ -46,6 +46,14 @@ export class BackgroundTaskProcessor {
}
});
}
if (asset.encodedVideoPath) {
fs.unlink(asset.encodedVideoPath, (err) => {
if (err) {
console.log('error deleting ', asset.encodedVideoPath);
}
});
}
}
}
}

View File

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

View File

@@ -12,11 +12,9 @@ import {
generateWEBPThumbnailProcessorName,
IMetadataExtractionJob,
IVideoTranscodeJob,
metadataExtractionQueueName,
mp4ConversionProcessorName,
QueueNameEnum,
reverseGeocodingProcessorName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
videoMetadataExtractionProcessorName,
} from '@app/job';
import { ConfigService } from '@nestjs/config';
@@ -30,13 +28,13 @@ export class ScheduleTasksService {
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@InjectQueue(thumbnailGeneratorQueueName)
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue,
@InjectQueue(videoConversionQueueName)
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectQueue(metadataExtractionQueueName)
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
private configService: ConfigService,
@@ -108,11 +106,11 @@ export class ScheduleTasksService {
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async extractExif() {
const exifAssets = await this.assetRepository.find({
where: {
exifInfo: IsNull(),
},
});
const exifAssets = await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.exifInfo', 'ei')
.where('ei."assetId" IS NULL')
.getMany();
for (const asset of exifAssets) {
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 { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import {
assetUploadedQueueName,
generateChecksumQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
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 { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
import { MachineLearningProcessor } from './processors/machine-learning.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
@@ -42,7 +37,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}),
BullModule.registerQueue(
{
name: thumbnailGeneratorQueueName,
name: QueueNameEnum.THUMBNAIL_GENERATION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
@@ -50,7 +45,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
},
},
{
name: assetUploadedQueueName,
name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
@@ -58,7 +53,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
},
},
{
name: metadataExtractionQueueName,
name: QueueNameEnum.METADATA_EXTRACTION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
@@ -66,7 +61,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
},
},
{
name: videoConversionQueueName,
name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: {
attempts: 3,
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: {
attempts: 3,
removeOnComplete: true,
@@ -92,6 +95,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
MetadataExtractionProcessor,
VideoTranscodeProcessor,
GenerateChecksumProcessor,
MachineLearningProcessor,
ConfigService,
],
exports: [],

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -8,7 +8,7 @@ import fs from 'node:fs';
import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
// TODO: just temporary task to generate previous uploaded assets.
@Processor(generateChecksumQueueName)
@Processor(QueueNameEnum.CHECKSUM_GENERATION)
export class GenerateChecksumProcessor {
constructor(
@InjectRepository(AssetEntity)
@@ -33,7 +33,7 @@ export class GenerateChecksumProcessor {
const assets = await this.assetRepository.find({
where: whereStat,
take: pageSize,
order: { id: 'ASC' }
order: { id: 'ASC' },
});
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 { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import {
IExifExtractionProcessor,
IVideoLengthExtractionProcessor,
exifExtractionProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
videoMetadataExtractionProcessorName,
metadataExtractionQueueName,
reverseGeocodingProcessorName,
IReverseGeocodingProcessor,
QueueNameEnum,
} from '@app/job';
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Job } from 'bull';
import exifr from 'exifr';
import ffmpeg from 'fluent-ffmpeg';
@@ -43,20 +39,20 @@ function geocoderLookup(points: { latitude: number; longitude: number }[]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
geocoder.lookUp(points, 1, (err, addresses) => {
resolve(addresses[0][0]);
resolve(addresses[0][0] as GeoData);
});
});
}
const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
export interface AdminCode {
export type AdminCode = {
name: string;
asciiName: string;
geoNameId: string;
}
};
export interface GeoData {
export type GeoData = {
geoNameId: string;
name: string;
asciiName: string;
@@ -67,8 +63,8 @@ export interface GeoData {
featureCode: string;
countryCode: string;
cc2?: any;
admin1Code?: AdminCode;
admin2Code?: AdminCode;
admin1Code?: AdminCode | string;
admin2Code?: AdminCode | string;
admin3Code?: any;
admin4Code?: any;
population: string;
@@ -77,9 +73,9 @@ export interface GeoData {
timezone: string;
modificationDate: string;
distance: number;
}
};
@Processor(metadataExtractionQueueName)
@Processor(QueueNameEnum.METADATA_EXTRACTION)
export class MetadataExtractionProcessor {
private isGeocodeInitialized = false;
private logLevel: ImmichLogLevel;
@@ -91,12 +87,9 @@ export class MetadataExtractionProcessor {
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
private configService: ConfigService,
) {
if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
Logger.log('Initialising Reverse Geocoding');
geocoderInit({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -109,7 +102,8 @@ export class MetadataExtractionProcessor {
alternateNames: false,
},
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(() => {
this.isGeocodeInitialized = true;
Logger.log('Reverse Geocoding Initialised');
@@ -129,10 +123,22 @@ export class MetadataExtractionProcessor {
const city = geoCodeInfo.name;
let state = '';
if (geoCodeInfo.admin2Code?.name) state += geoCodeInfo.admin2Code.name;
if (geoCodeInfo.admin1Code?.name) {
if (geoCodeInfo.admin2Code?.name) state += ', ';
state += geoCodeInfo.admin1Code.name;
if (geoCodeInfo.admin2Code) {
const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
state += adminCode2.name;
}
if (geoCodeInfo.admin1Code) {
const adminCode1 = geoCodeInfo.admin1Code as AdminCode;
if (geoCodeInfo.admin2Code) {
const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
if (adminCode2.name) {
state += ', ';
}
}
state += adminCode1.name;
}
return { country, state, city };
@@ -273,48 +279,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 })
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
const { asset, fileName } = job.data;

View File

@@ -5,11 +5,9 @@ import {
WebpGeneratorProcessor,
generateJPEGThumbnailProcessorName,
generateWEBPThumbnailProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
JpegGeneratorProcessor,
QueueNameEnum,
MachineLearningJobNameEnum,
} from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
@@ -25,8 +23,9 @@ import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository';
import { join } from 'path';
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 {
private logLevel: ImmichLogLevel;
@@ -34,13 +33,13 @@ export class ThumbnailGeneratorProcessor {
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectQueue(thumbnailGeneratorQueueName)
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue,
private wsCommunicationGateway: CommunicationGateway,
@InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue,
@InjectQueue(QueueNameEnum.MACHINE_LEARNING)
private machineLearningQueue: Queue<IMachineLearningJob>,
private configService: ConfigService,
) {
@@ -62,7 +61,7 @@ export class ThumbnailGeneratorProcessor {
const temp = asset.originalPath.split('/');
const originalFilename = temp[temp.length - 1].split('.')[0];
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
if (asset.type == AssetType.IMAGE) {
try {
@@ -80,8 +79,12 @@ export class ThumbnailGeneratorProcessor {
asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { 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)));
}
@@ -110,8 +113,12 @@ export class ThumbnailGeneratorProcessor {
asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { 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)));
}

View File

@@ -1,7 +1,7 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { QueueNameEnum } from '@app/job';
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 { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
@@ -11,7 +11,7 @@ import ffmpeg from 'fluent-ffmpeg';
import { existsSync, mkdirSync } from 'fs';
import { Repository } from 'typeorm';
@Processor(videoConversionQueueName)
@Processor(QueueNameEnum.VIDEO_CONVERSION)
export class VideoTranscodeProcessor {
constructor(
@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 videoMetadataExtractionProcessorName = 'extract-video-metadata';
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 const assetUploadedQueueName = 'asset-uploaded-queue';
export const metadataExtractionQueueName = 'metadata-extraction-queue';
export const videoConversionQueueName = 'video-conversion-queue';
export const generateChecksumQueueName = 'generate-checksum-queue';
export enum QueueNameEnum {
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
METADATA_EXTRACTION = 'metadata-extraction-queue',
VIDEO_CONVERSION = 'video-conversion-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

@@ -36,7 +36,7 @@
"geo-tz": "^7.0.2",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.2",
"local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"passport": "^0.6.0",
@@ -59,7 +59,7 @@
"@nestjs/testing": "^8.4.7",
"@openapitools/openapi-generator-cli": "2.5.1",
"@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7",
"@types/bull": "^3.15.9",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.13",
@@ -2339,9 +2339,9 @@
}
},
"node_modules/@types/bull": {
"version": "3.15.7",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz",
"integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==",
"version": "3.15.9",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
"integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
"dev": true,
"dependencies": {
"@types/ioredis": "*",
@@ -3764,6 +3764,27 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -7640,12 +7661,11 @@
}
},
"node_modules/local-reverse-geocoder": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/local-reverse-geocoder/-/local-reverse-geocoder-0.12.2.tgz",
"integrity": "sha512-kTSvDxGTuJoqx619jmHFoGCqFpBi0PPwyd7PDOLZCyo8mMEwJSMx713+ksOCihGpzUfO3hcclE7z/T43sY/IaA==",
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/local-reverse-geocoder/-/local-reverse-geocoder-0.12.5.tgz",
"integrity": "sha512-FaH8+T29K9PQRiiqYlt+M9Qvq9GlSnWEnX0FTDXgPrNzQ9SWWYGEvO5uODwAD6sep9z19u/K/+Z3cw4AGVW97Q==",
"dependencies": {
"async": "^3.2.4",
"cors": "^2.8.5",
"csv-parse": "^5.3.0",
"debug": "^4.3.4",
"kdt": "^0.1.0",
@@ -7674,6 +7694,13 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"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": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -12900,9 +12927,9 @@
}
},
"@types/bull": {
"version": "3.15.7",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz",
"integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==",
"version": "3.15.9",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
"integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
"dev": true,
"requires": {
"@types/ioredis": "*",
@@ -14073,6 +14100,26 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -17061,12 +17108,11 @@
"dev": true
},
"local-reverse-geocoder": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/local-reverse-geocoder/-/local-reverse-geocoder-0.12.2.tgz",
"integrity": "sha512-kTSvDxGTuJoqx619jmHFoGCqFpBi0PPwyd7PDOLZCyo8mMEwJSMx713+ksOCihGpzUfO3hcclE7z/T43sY/IaA==",
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/local-reverse-geocoder/-/local-reverse-geocoder-0.12.5.tgz",
"integrity": "sha512-FaH8+T29K9PQRiiqYlt+M9Qvq9GlSnWEnX0FTDXgPrNzQ9SWWYGEvO5uODwAD6sep9z19u/K/+Z3cw4AGVW97Q==",
"requires": {
"async": "^3.2.4",
"cors": "^2.8.5",
"csv-parse": "^5.3.0",
"debug": "^4.3.4",
"kdt": "^0.1.0",
@@ -17088,6 +17134,13 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"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": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",

View File

@@ -55,7 +55,7 @@
"geo-tz": "^7.0.2",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.2",
"local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"passport": "^0.6.0",
@@ -78,7 +78,7 @@
"@nestjs/testing": "^8.4.7",
"@openapitools/openapi-generator-cli": "2.5.1",
"@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7",
"@types/bull": "^3.15.9",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.13",

Some files were not shown because too many files have changed in this diff Show More