diff --git a/.github/workflows/build_push_docker_latest.yml b/.github/workflows/build_push_docker_latest.yml
index 2fb4792366..b69d5916f2 100644
--- a/.github/workflows/build_push_docker_latest.yml
+++ b/.github/workflows/build_push_docker_latest.yml
@@ -17,17 +17,17 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich Mono Repo
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./server
file: ./server/Dockerfile
@@ -45,17 +45,17 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -72,17 +72,17 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./web
file: ./web/Dockerfile
@@ -100,17 +100,17 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./nginx
file: ./nginx/Dockerfile
diff --git a/.github/workflows/build_push_docker_staging.yml b/.github/workflows/build_push_docker_staging.yml
index 902a7a62df..36f2f1f568 100644
--- a/.github/workflows/build_push_docker_staging.yml
+++ b/.github/workflows/build_push_docker_staging.yml
@@ -2,8 +2,6 @@ name: Build and Push Docker Image - Staging
on:
workflow_dispatch:
- push:
- branches: [main]
pull_request:
branches: [main]
@@ -19,10 +17,10 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -30,7 +28,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich Mono Repo
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./server
file: ./server/Dockerfile
@@ -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
@@ -48,10 +47,10 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -59,7 +58,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -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
@@ -76,10 +76,10 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -87,7 +87,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./web
file: ./web/Dockerfile
@@ -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
@@ -105,10 +106,10 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
@@ -116,7 +117,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./nginx
file: ./nginx/Dockerfile
@@ -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 }}
diff --git a/.github/workflows/build_push_server_release.yml b/.github/workflows/build_push_server_release.yml
index 0db75d6b70..3f9a7a74fd 100644
--- a/.github/workflows/build_push_server_release.yml
+++ b/.github/workflows/build_push_server_release.yml
@@ -22,11 +22,11 @@ jobs:
fallback: latest
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -35,7 +35,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-server release
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./server
file: ./server/Dockerfile
@@ -58,17 +58,17 @@ jobs:
with:
fallback: latest
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -94,11 +94,11 @@ jobs:
fallback: latest
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -107,7 +107,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-web release
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./web
file: ./web/Dockerfile
@@ -134,11 +134,11 @@ jobs:
fallback: latest
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2.0.0
+ uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2.0.0
+ uses: docker/setup-buildx-action@v2.1.0
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -147,7 +147,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-proxy release
- uses: docker/build-push-action@v3.1.1
+ uses: docker/build-push-action@v3.2.0
with:
context: ./nginx
file: ./nginx/Dockerfile
diff --git a/.github/workflows/openapi-generator.yml b/.github/workflows/openapi-generator.yml
new file mode 100644
index 0000000000..91731a0256
--- /dev/null
+++ b/.github/workflows/openapi-generator.yml
@@ -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'
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 60a379d8d5..db79ec0677 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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:
diff --git a/README.md b/README.md
index ac776b25c2..dd6015870a 100644
--- a/README.md
+++ b/README.md
@@ -46,13 +46,14 @@ 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)
# Features
-> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
+> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development. There will be continuous functions, features and api changes.
| Features | Mobile | Web |
| - | - | - |
@@ -117,11 +118,11 @@ There are several services that compose Immich:
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
-## Testing One-step installation (not recommended for production)
+## Testing one-step installation (not recommended for production)
-> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
+> ⚠️ *This installation method is for evaluating Immich before further customization to meet the users' needs.*
-*Applicable system: Ubuntu, Debian, MacOS*
+*Applicable operating systems: Ubuntu, Debian, MacOS*
- In the shell, from the directory of your choice, run the following command:
@@ -203,9 +204,13 @@ docker-compose pull && docker-compose up -d
| - | - | - |
| |
|
|
-> *The Play/App Store version might be lagging behind the latest release due to the review process.*
+> *The Play/App Store version might be lagging behind the latest release due to their 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)
# Development
diff --git a/docker/.env.example b/docker/.env.example
index 33b4925514..7b398969da 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -38,7 +38,10 @@ LOG_LEVEL=simple
# JWT SECRET
###################################################################################
-JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
+# This JWT_SECRET is used to sign the authentication keys for user login
+# You should set it to a long randomly generated value
+# You can use this command to generate one: openssl rand -base64 128
+JWT_SECRET=
###################################################################################
# Reverse Geocoding
diff --git a/install.sh b/install.sh
index 1701d33cc2..dbe0d8db6a 100755
--- a/install.sh
+++ b/install.sh
@@ -18,33 +18,37 @@ get_release_version() {
create_immich_directory() {
echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data
+ cd ./immich-app
}
download_docker_compose_file() {
echo "Downloading docker-compose.yml..."
- curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
+ curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./docker-compose.yml >/dev/null 2>&1
}
download_dot_env_file() {
echo "Downloading .env file..."
- curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
+ curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./.env >/dev/null 2>&1
+}
+
+replace_env_value() {
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ sed -i '' "s|$1=.*|$1=$2|" ./.env
+ else
+ sed -i "s|$1=.*|$1=$2|" ./.env
+ fi
}
populate_upload_location() {
echo "Populating default UPLOAD_LOCATION value..."
+ upload_location=$(pwd)/immich-data
+ replace_env_value "UPLOAD_LOCATION" $upload_location
+}
- cd ./immich-app/immich-data
-
- upload_location=$(pwd)
-
- # Replace value of UPLOAD_LOCATION in .env with upload_location path
- if [[ "$OSTYPE" == "darwin"* ]]; then
- sed -i '' "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
- else
- sed -i "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
- fi
-
- cd ..
+generate_jwt_secret() {
+ echo "Generating JWT_SECRET value..."
+ jwt_secret=$(openssl rand -base64 128)
+ replace_env_value "JWT_SECRET" $jwt_secret
}
start_docker_compose() {
@@ -88,4 +92,5 @@ create_immich_directory
download_docker_compose_file
download_dot_env_file
populate_upload_location
+generate_jwt_secret
start_docker_compose
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index af00aac413..36a967da6c 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
-
+
+
-
-
+
+
+
diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt
deleted file mode 100644
index bbdaa27f5f..0000000000
--- a/mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt
+++ /dev/null
@@ -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();
- }
-}
\ No newline at end of file
diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt
index bebaa579be..3cb231eaf6 100644
--- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt
+++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt
@@ -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>()!!
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)
diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt
index 24bbd1785d..116422634c 100644
--- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt
+++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt
@@ -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 {
@@ -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>()!!
- 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>()!!
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
/**
diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt
index ecbec640fa..a58ea14518 100644
--- a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt
+++ b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt
@@ -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)
diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt
new file mode 100644
index 0000000000..86b82d2be9
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt
index 2e6372231d..5df36cb18f 100644
--- a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt
@@ -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())
}
}
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index c0fc2fe307..bfa80f3aba 100644
--- a/mobile/android/fastlane/Fastfile
+++ b/mobile/android/fastlane/Fastfile
@@ -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')
diff --git a/mobile/android/fastlane/README.md b/mobile/android/fastlane/README.md
index fb4b573aac..11cb12000a 100644
--- a/mobile/android/fastlane/README.md
+++ b/mobile/android/fastlane/README.md
@@ -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
diff --git a/mobile/android/fastlane/metadata/android/en-US/changelogs/47.txt b/mobile/android/fastlane/metadata/android/en-US/changelogs/47.txt
new file mode 100644
index 0000000000..a97d899715
--- /dev/null
+++ b/mobile/android/fastlane/metadata/android/en-US/changelogs/47.txt
@@ -0,0 +1 @@
+* Improve scroll thumb date info
\ No newline at end of file
diff --git a/mobile/android/fastlane/metadata/android/en-US/changelogs/48.txt b/mobile/android/fastlane/metadata/android/en-US/changelogs/48.txt
new file mode 100644
index 0000000000..d6e2aac53c
--- /dev/null
+++ b/mobile/android/fastlane/metadata/android/en-US/changelogs/48.txt
@@ -0,0 +1 @@
+* Fixed parsing date error prevent timeline to be loaded.
\ No newline at end of file
diff --git a/mobile/android/fastlane/metadata/android/en-US/changelogs/49.txt b/mobile/android/fastlane/metadata/android/en-US/changelogs/49.txt
new file mode 100644
index 0000000000..90360a3c0c
--- /dev/null
+++ b/mobile/android/fastlane/metadata/android/en-US/changelogs/49.txt
@@ -0,0 +1,2 @@
+* Fixed run background service after being killed
+* Added background backup progress notifications
\ No newline at end of file
diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml
index 7ec06ddace..35596dd5e1 100644
--- a/mobile/android/fastlane/report.xml
+++ b/mobile/android/fastlane/report.xml
@@ -5,17 +5,17 @@
-
+
-
+
-
+
diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json
index c81ebdfe63..d2cd917519 100644
--- a/mobile/assets/i18n/en-US.json
+++ b/mobile/assets/i18n/en-US.json
@@ -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",
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index f5f19fdccc..d3518d6393 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -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;
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 80140355d9..639862b8b2 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -17,11 +17,11 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.29.6
+ 1.30.1
CFBundleSignature
????
CFBundleVersion
- 60
+ 62
LSRequiresIPhoneOS
MGLMapboxMetricsEnabledSettingShownInApp
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index 41089b5b15..1a584c1ad8 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -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,
diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml
index 632cdf7c9b..61fe97cbff 100644
--- a/mobile/ios/fastlane/report.xml
+++ b/mobile/ios/fastlane/report.xml
@@ -5,32 +5,32 @@
-
+
-
+
-
+
-
+
-
+
-
+
diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart
index 1af6dc9816..f5a0086b5b 100644
--- a/mobile/lib/modules/backup/background_service/background.service.dart
+++ b/mobile/lib/modules/backup/background_service/background.service.dart
@@ -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 _updateNotification({
- required String title,
+ Future _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(
+ '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 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 box =
await Hive.openBox(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 _runBackup(
BackupService backupService,
+ AppSettingsService settingsService,
HiveBackupAlbums backupAlbumInfo,
) async {
- _errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
+ _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
+ final bool notifyTotalProgress = settingsService
+ .getSetting(AppSettingsEnum.backgroundBackupTotalProgress);
+ final bool notifySingleProgress = settingsService
+ .getSetting(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
diff --git a/mobile/lib/modules/home/providers/home_page_render_list_provider.dart b/mobile/lib/modules/home/providers/home_page_render_list_provider.dart
index 707f98fb7d..f97fd537e0 100644
--- a/mobile/lib/modules/home/providers/home_page_render_list_provider.dart
+++ b/mobile/lib/modules/home/providers/home_page_render_list_provider.dart
@@ -1,5 +1,3 @@
-import 'dart:math';
-
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
index 74124f76ef..775f1b0c4b 100644
--- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
+++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
@@ -1,11 +1,8 @@
import 'dart:collection';
-import 'dart:math';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
-import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
-import 'package:flutter/src/widgets/framework.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:openapi/api.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@@ -15,7 +12,9 @@ import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
typedef ImmichAssetGridSelectionListener = void Function(
- bool, Set);
+ bool,
+ Set,
+);
class ImmichAssetGridState extends State {
final ItemScrollController _itemScrollController = ItemScrollController();
@@ -23,7 +22,7 @@ class ImmichAssetGridState extends State {
ItemPositionsListener.create();
bool _scrolling = false;
- Set _selectedAssets = HashSet();
+ final Set _selectedAssets = HashSet();
List get _assets {
return widget.renderList
@@ -86,7 +85,9 @@ class ImmichAssetGridState extends State {
}
Widget _buildThumbnailOrPlaceholder(
- AssetResponseDto asset, bool placeholder) {
+ AssetResponseDto asset,
+ bool placeholder,
+ ) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
@@ -104,7 +105,10 @@ class ImmichAssetGridState extends State {
}
Widget _buildAssetRow(
- BuildContext context, RenderAssetGridRow row, bool scrolling) {
+ BuildContext context,
+ RenderAssetGridRow row,
+ bool scrolling,
+ ) {
double size = _getItemSize(context);
return Row(
@@ -117,7 +121,9 @@ class ImmichAssetGridState extends State {
width: size,
height: size,
margin: EdgeInsets.only(
- top: widget.margin, right: last ? 0.0 : widget.margin),
+ top: widget.margin,
+ right: last ? 0.0 : widget.margin,
+ ),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
@@ -125,7 +131,10 @@ class ImmichAssetGridState extends State {
}
Widget _buildTitle(
- BuildContext context, String title, List assets) {
+ BuildContext context,
+ String title,
+ List assets,
+ ) {
return DailyTitleText(
isoDate: title,
multiselectEnabled: widget.selectionActive,
@@ -186,7 +195,7 @@ class ImmichAssetGridState extends State {
}
Widget _buildAssetGrid() {
- final useDragScrolling = _assets.length > 100;
+ final useDragScrolling = _assets.length >= 20;
void dragScrolling(bool active) {
setState(() {
@@ -218,7 +227,6 @@ class ImmichAssetGridState extends State {
);
}
-
@override
void didUpdateWidget(ImmichAssetGrid oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -248,14 +256,14 @@ class ImmichAssetGrid extends StatefulWidget {
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
- ImmichAssetGrid({
+ const ImmichAssetGrid({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.listener,
this.margin = 5.0,
- this.selectionActive = false
+ this.selectionActive = false,
});
@override
diff --git a/mobile/lib/modules/settings/services/app_settings.service.dart b/mobile/lib/modules/settings/services/app_settings.service.dart
index 469e6a0d43..292c40c210 100644
--- a/mobile/lib/modules/settings/services/app_settings.service.dart
+++ b/mobile/lib/modules/settings/services/app_settings.service.dart
@@ -6,7 +6,11 @@ enum AppSettingsEnum {
themeMode("themeMode", "system"), // "light","dark","system"
tilesPerRow("tilesPerRow", 4),
uploadErrorNotificationGracePeriod(
- "uploadErrorNotificationGracePeriod", 2),
+ "uploadErrorNotificationGracePeriod",
+ 2,
+ ),
+ backgroundBackupTotalProgress("backgroundBackupTotalProgress", true),
+ backgroundBackupSingleProgress("backgroundBackupSingleProgress", false),
storageIndicator("storageIndicator", true),
thumbnailCacheSize("thumbnailCacheSize", 10000),
imageCacheSize("imageCacheSize", 350),
diff --git a/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart b/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart
index 1643a830b5..be988e01cb 100644
--- a/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart
+++ b/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart
@@ -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(AppSettingsEnum.uploadErrorNotificationGracePeriod)
.toDouble();
+ totalProgressValue.value = appSettingService
+ .getSetting(AppSettingsEnum.backgroundBackupTotalProgress);
+ singleProgressValue.value = appSettingService
+ .getSetting(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 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();
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index a5d73e1474..3a8edc0030 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -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
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 0d037e60b3..3154654d42 100644
Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ
diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md
new file mode 100644
index 0000000000..3fa53791df
Binary files /dev/null and b/mobile/openapi/doc/AllJobStatusResponseDto.md differ
diff --git a/mobile/openapi/doc/CreateJobDto.md b/mobile/openapi/doc/CreateJobDto.md
new file mode 100644
index 0000000000..64cdbf0184
Binary files /dev/null and b/mobile/openapi/doc/CreateJobDto.md differ
diff --git a/mobile/openapi/doc/ExifResponseDto.md b/mobile/openapi/doc/ExifResponseDto.md
index 0e96bdcbe9..af4bb349ec 100644
Binary files a/mobile/openapi/doc/ExifResponseDto.md and b/mobile/openapi/doc/ExifResponseDto.md differ
diff --git a/mobile/openapi/doc/JobApi.md b/mobile/openapi/doc/JobApi.md
new file mode 100644
index 0000000000..124e3d2149
Binary files /dev/null and b/mobile/openapi/doc/JobApi.md differ
diff --git a/mobile/openapi/doc/JobCommand.md b/mobile/openapi/doc/JobCommand.md
new file mode 100644
index 0000000000..620e0439a5
Binary files /dev/null and b/mobile/openapi/doc/JobCommand.md differ
diff --git a/mobile/openapi/doc/JobCommandDto.md b/mobile/openapi/doc/JobCommandDto.md
new file mode 100644
index 0000000000..4e87fde8e8
Binary files /dev/null and b/mobile/openapi/doc/JobCommandDto.md differ
diff --git a/mobile/openapi/doc/JobCounts.md b/mobile/openapi/doc/JobCounts.md
new file mode 100644
index 0000000000..353b834382
Binary files /dev/null and b/mobile/openapi/doc/JobCounts.md differ
diff --git a/mobile/openapi/doc/JobId.md b/mobile/openapi/doc/JobId.md
new file mode 100644
index 0000000000..d2f68234d0
Binary files /dev/null and b/mobile/openapi/doc/JobId.md differ
diff --git a/mobile/openapi/doc/JobStatusResponseDto.md b/mobile/openapi/doc/JobStatusResponseDto.md
new file mode 100644
index 0000000000..13325a5152
Binary files /dev/null and b/mobile/openapi/doc/JobStatusResponseDto.md differ
diff --git a/mobile/openapi/doc/JobType.md b/mobile/openapi/doc/JobType.md
new file mode 100644
index 0000000000..6d7faab6b7
Binary files /dev/null and b/mobile/openapi/doc/JobType.md differ
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 3c87fc703b..150d878f63 100644
Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ
diff --git a/mobile/openapi/lib/api/job_api.dart b/mobile/openapi/lib/api/job_api.dart
new file mode 100644
index 0000000000..b64a67c35a
Binary files /dev/null and b/mobile/openapi/lib/api/job_api.dart differ
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 13cc028967..827332e9c3 100644
Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ
diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart
index 26c90fd0c7..7db37768c4 100644
Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ
diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart
new file mode 100644
index 0000000000..7be7166a77
Binary files /dev/null and b/mobile/openapi/lib/model/all_job_status_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart
index 21dcce7595..cd1e83c5f2 100644
Binary files a/mobile/openapi/lib/model/asset_response_dto.dart and b/mobile/openapi/lib/model/asset_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/create_job_dto.dart b/mobile/openapi/lib/model/create_job_dto.dart
new file mode 100644
index 0000000000..1eaf678647
Binary files /dev/null and b/mobile/openapi/lib/model/create_job_dto.dart differ
diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart
index 199c955e93..b81f0e347b 100644
Binary files a/mobile/openapi/lib/model/exif_response_dto.dart and b/mobile/openapi/lib/model/exif_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/job_command.dart b/mobile/openapi/lib/model/job_command.dart
new file mode 100644
index 0000000000..2734028076
Binary files /dev/null and b/mobile/openapi/lib/model/job_command.dart differ
diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart
new file mode 100644
index 0000000000..808eb50d75
Binary files /dev/null and b/mobile/openapi/lib/model/job_command_dto.dart differ
diff --git a/mobile/openapi/lib/model/job_counts.dart b/mobile/openapi/lib/model/job_counts.dart
new file mode 100644
index 0000000000..dadb72f328
Binary files /dev/null and b/mobile/openapi/lib/model/job_counts.dart differ
diff --git a/mobile/openapi/lib/model/job_id.dart b/mobile/openapi/lib/model/job_id.dart
new file mode 100644
index 0000000000..308d9c06c1
Binary files /dev/null and b/mobile/openapi/lib/model/job_id.dart differ
diff --git a/mobile/openapi/lib/model/job_status_response_dto.dart b/mobile/openapi/lib/model/job_status_response_dto.dart
new file mode 100644
index 0000000000..d3854b8f3a
Binary files /dev/null and b/mobile/openapi/lib/model/job_status_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/job_type.dart b/mobile/openapi/lib/model/job_type.dart
new file mode 100644
index 0000000000..2cf21674ed
Binary files /dev/null and b/mobile/openapi/lib/model/job_type.dart differ
diff --git a/mobile/openapi/test/all_job_status_response_dto_test.dart b/mobile/openapi/test/all_job_status_response_dto_test.dart
new file mode 100644
index 0000000000..0853da9d1b
Binary files /dev/null and b/mobile/openapi/test/all_job_status_response_dto_test.dart differ
diff --git a/mobile/openapi/test/create_job_dto_test.dart b/mobile/openapi/test/create_job_dto_test.dart
new file mode 100644
index 0000000000..5ae779231e
Binary files /dev/null and b/mobile/openapi/test/create_job_dto_test.dart differ
diff --git a/mobile/openapi/test/job_api_test.dart b/mobile/openapi/test/job_api_test.dart
new file mode 100644
index 0000000000..2b8d82393c
Binary files /dev/null and b/mobile/openapi/test/job_api_test.dart differ
diff --git a/mobile/openapi/test/job_command_dto_test.dart b/mobile/openapi/test/job_command_dto_test.dart
new file mode 100644
index 0000000000..fc31170277
Binary files /dev/null and b/mobile/openapi/test/job_command_dto_test.dart differ
diff --git a/mobile/openapi/test/job_command_test.dart b/mobile/openapi/test/job_command_test.dart
new file mode 100644
index 0000000000..df6822c9d4
Binary files /dev/null and b/mobile/openapi/test/job_command_test.dart differ
diff --git a/mobile/openapi/test/job_counts_test.dart b/mobile/openapi/test/job_counts_test.dart
new file mode 100644
index 0000000000..09fb4fc62f
Binary files /dev/null and b/mobile/openapi/test/job_counts_test.dart differ
diff --git a/mobile/openapi/test/job_id_test.dart b/mobile/openapi/test/job_id_test.dart
new file mode 100644
index 0000000000..66b6b7656c
Binary files /dev/null and b/mobile/openapi/test/job_id_test.dart differ
diff --git a/mobile/openapi/test/job_status_response_dto_test.dart b/mobile/openapi/test/job_status_response_dto_test.dart
new file mode 100644
index 0000000000..09ea08df58
Binary files /dev/null and b/mobile/openapi/test/job_status_response_dto_test.dart differ
diff --git a/mobile/openapi/test/job_type_test.dart b/mobile/openapi/test/job_type_test.dart
new file mode 100644
index 0000000000..d611a65570
Binary files /dev/null and b/mobile/openapi/test/job_type_test.dart differ
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 982f2bbf55..0f5b8b3da6 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -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"
diff --git a/server/.dockerignore b/server/.dockerignore
index 834ab88b61..a66e51e358 100644
--- a/server/.dockerignore
+++ b/server/.dockerignore
@@ -1,4 +1,4 @@
node_modules/
upload/
dist/
-
+.reverse-geocoding-dump
diff --git a/server/Dockerfile b/server/Dockerfile
index 5927a68278..c3daf8cda9 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production
+VOLUME /usr/src/app/upload
+
EXPOSE 3001
diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts
index 672d39f9af..2ef7e5530f 100644
--- a/server/apps/immich/src/api-v1/album/album.service.spec.ts
+++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts
@@ -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);
diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts
index 3c88633231..089819af37 100644
--- a/server/apps/immich/src/api-v1/asset/asset-repository.ts
+++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts
@@ -29,6 +29,9 @@ export interface IAssetRepository {
getAssetCountByUserId(userId: string): Promise;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise;
getAssetByChecksum(userId: string, checksum: Buffer): Promise;
+ getAssetWithNoThumbnail(): Promise;
+ getAssetWithNoEXIF(): Promise;
+ getAssetWithNoSmartInfo(): Promise;
}
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@@ -40,6 +43,33 @@ export class AssetRepository implements IAssetRepository {
private assetRepository: Repository,
) {}
+ async getAssetWithNoSmartInfo(): Promise {
+ 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 {
+ 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 {
+ return await this.assetRepository
+ .createQueryBuilder('asset')
+ .leftJoinAndSelect('asset.exifInfo', 'ei')
+ .where('ei."assetId" IS NULL')
+ .getMany();
+ }
+
async getAssetCountByUserId(userId: string): Promise {
// Get asset count by AssetType
const res = await this.assetRepository
diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts
index 387671df3a..a045bdce45 100644
--- a/server/apps/immich/src/api-v1/asset/asset.controller.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts
@@ -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,
) {}
diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts
index 13df9997f9..adc0705078 100644
--- a/server/apps/immich/src/api-v1/asset/asset.module.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.module.ts
@@ -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,
diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts
index 3b07d4f74f..89305dcd4b 100644
--- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts
@@ -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);
diff --git a/server/apps/immich/src/api-v1/asset/dto/get-asset-thumbnail.dto.ts b/server/apps/immich/src/api-v1/asset/dto/get-asset-thumbnail.dto.ts
index a0df22d238..5a8dc06872 100644
--- a/server/apps/immich/src/api-v1/asset/dto/get-asset-thumbnail.dto.ts
+++ b/server/apps/immich/src/api-v1/asset/dto/get-asset-thumbnail.dto.ts
@@ -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;
}
diff --git a/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts b/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
index c43c55b4eb..ff86716eca 100644
--- a/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
+++ b/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
@@ -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,
diff --git a/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts b/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
new file mode 100644
index 0000000000..8280c66940
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
@@ -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;
+}
diff --git a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
new file mode 100644
index 0000000000..f63f0fa517
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
@@ -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;
+}
diff --git a/server/apps/immich/src/api-v1/job/job.controller.ts b/server/apps/immich/src/api-v1/job/job.controller.ts
new file mode 100644
index 0000000000..2fbccb7fd8
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/job.controller.ts
@@ -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 {
+ return this.jobService.getAllJobsStatus();
+ }
+
+ @Get('/:jobId')
+ getJobStatus(@Param(ValidationPipe) params: GetJobDto): Promise {
+ return this.jobService.getJobStatus(params);
+ }
+
+ @Put('/:jobId')
+ async sendJobCommand(
+ @Param(ValidationPipe) params: GetJobDto,
+ @Body(ValidationPipe) body: JobCommandDto,
+ ): Promise {
+ if (body.command === 'start') {
+ return await this.jobService.startJob(params);
+ }
+ if (body.command === 'stop') {
+ return await this.jobService.stopJob(params);
+ }
+ return 0;
+ }
+}
diff --git a/server/apps/immich/src/api-v1/job/job.module.ts b/server/apps/immich/src/api-v1/job/job.module.ts
new file mode 100644
index 0000000000..2cb5beb7bf
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/job.module.ts
@@ -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 {}
diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts
new file mode 100644
index 0000000000..761a70906f
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/job.service.ts
@@ -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,
+
+ @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
+ private metadataExtractionQueue: Queue,
+
+ @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
+ private videoConversionQueue: Queue,
+
+ @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
+ private machineLearningQueue: Queue,
+
+ @Inject(ASSET_REPOSITORY)
+ private _assetRepository: IAssetRepository,
+ ) {
+ this.thumbnailGeneratorQueue.empty();
+ this.metadataExtractionQueue.empty();
+ this.videoConversionQueue.empty();
+ }
+
+ async startJob(jobDto: GetJobDto): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts b/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts
new file mode 100644
index 0000000000..884982bb5e
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts
@@ -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;
+}
diff --git a/server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts b/server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts
new file mode 100644
index 0000000000..fe411fa2ef
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts
@@ -0,0 +1,6 @@
+import Bull from 'bull';
+
+export class JobStatusResponseDto {
+ isActive!: boolean;
+ queueCount!: Bull.JobCounts;
+}
diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts b/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts
index 444292091b..e844da6899 100644
--- a/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts
+++ b/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts
@@ -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' })
diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts
index 16f644c030..3aef3d4b4d 100644
--- a/server/apps/immich/src/app.module.ts
+++ b/server/apps/immich/src/app.module.ts
@@ -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: [],
diff --git a/server/apps/immich/src/constants/server_version.constant.ts b/server/apps/immich/src/constants/server_version.constant.ts
index c0b1e3f43a..072c6776a2 100644
--- a/server/apps/immich/src/constants/server_version.constant.ts
+++ b/server/apps/immich/src/constants/server_version.constant.ts
@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = {
major: 1,
- minor: 30,
+ minor: 31,
patch: 0,
- build: 46,
+ build: 49,
};
diff --git a/server/apps/immich/src/modules/background-task/background-task.processor.ts b/server/apps/immich/src/modules/background-task/background-task.processor.ts
index a5fa1c5e06..d852f734c2 100644
--- a/server/apps/immich/src/modules/background-task/background-task.processor.ts
+++ b/server/apps/immich/src/modules/background-task/background-task.processor.ts
@@ -46,6 +46,14 @@ export class BackgroundTaskProcessor {
}
});
}
+
+ if (asset.encodedVideoPath) {
+ fs.unlink(asset.encodedVideoPath, (err) => {
+ if (err) {
+ console.log('error deleting ', asset.encodedVideoPath);
+ }
+ });
+ }
}
}
}
diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
index 51f70fe8f5..e932a67f97 100644
--- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
+++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
@@ -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,
diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
index cbdf3d8b16..d814d8b307 100644
--- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
+++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
@@ -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,
- @InjectQueue(thumbnailGeneratorQueueName)
+ @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue,
- @InjectQueue(videoConversionQueueName)
+ @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue,
- @InjectQueue(metadataExtractionQueueName)
+ @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue,
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) {
diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts
index 46b3b6afe0..0353bb08a9 100644
--- a/server/apps/microservices/src/microservices.module.ts
+++ b/server/apps/microservices/src/microservices.module.ts
@@ -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: [],
diff --git a/server/apps/microservices/src/microservices.service.ts b/server/apps/microservices/src/microservices.service.ts
index e10fe46b83..5a03220f97 100644
--- a/server/apps/microservices/src/microservices.service.ts
+++ b/server/apps/microservices/src/microservices.service.ts
@@ -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
+ },
+ );
}
}
diff --git a/server/apps/microservices/src/processors/asset-uploaded.processor.ts b/server/apps/microservices/src/processors/asset-uploaded.processor.ts
index 7b70c07482..340d22a06d 100644
--- a/server/apps/microservices/src/processors/asset-uploaded.processor.ts
+++ b/server/apps/microservices/src/processors/asset-uploaded.processor.ts
@@ -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,
- @InjectQueue(metadataExtractionQueueName)
+ @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue,
- @InjectQueue(videoConversionQueueName)
+ @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue,
) {}
diff --git a/server/apps/microservices/src/processors/generate-checksum.processor.ts b/server/apps/microservices/src/processors/generate-checksum.processor.ts
index 2dcd1c2bd4..bbf20cccd4 100644
--- a/server/apps/microservices/src/processors/generate-checksum.processor.ts
+++ b/server/apps/microservices/src/processors/generate-checksum.processor.ts
@@ -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) {
diff --git a/server/apps/microservices/src/processors/machine-learning.processor.ts b/server/apps/microservices/src/processors/machine-learning.processor.ts
new file mode 100644
index 0000000000..39c92fd9f0
--- /dev/null
+++ b/server/apps/microservices/src/processors/machine-learning.processor.ts
@@ -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,
+ ) {}
+
+ @Process({ name: MachineLearningJobNameEnum.IMAGE_TAGGING, concurrency: 2 })
+ async tagImage(job: Job) {
+ 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) {
+ 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)}`);
+ }
+ }
+}
diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts
index 0860fcdd49..b247215d43 100644
--- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts
+++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts
@@ -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,
- @InjectRepository(SmartInfoEntity)
- private smartInfoRepository: Repository,
-
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) {
const { asset, fileName } = job.data;
diff --git a/server/apps/microservices/src/processors/thumbnail.processor.ts b/server/apps/microservices/src/processors/thumbnail.processor.ts
index eb368f1a82..211da0537c 100644
--- a/server/apps/microservices/src/processors/thumbnail.processor.ts
+++ b/server/apps/microservices/src/processors/thumbnail.processor.ts
@@ -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,
- @InjectQueue(thumbnailGeneratorQueueName)
+ @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue,
private wsCommunicationGateway: CommunicationGateway,
- @InjectQueue(metadataExtractionQueueName)
- private metadataExtractionQueue: Queue,
+ @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
+ private machineLearningQueue: Queue,
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)));
}
diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts
index 45ea17ab09..2e04a75e0a 100644
--- a/server/apps/microservices/src/processors/video-transcode.processor.ts
+++ b/server/apps/microservices/src/processors/video-transcode.processor.ts
@@ -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)
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index e78036840f..59cc9b6a83 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -1 +1 @@
-{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download":{"get":{"operationId":"downloadFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file":{"get":{"operationId":"serveFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"string","nullable":true,"default":null},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"fileSizeInByte":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"}},"required":["photos","videos"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true}},"required":["successful"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer"},"diskUseRaw":{"type":"integer"},"diskAvailableRaw":{"type":"integer"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}}}}}
\ No newline at end of file
+{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download":{"get":{"operationId":"downloadFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file":{"get":{"operationId":"serveFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}},"/jobs":{"get":{"operationId":"getAllJobsStatus","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllJobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/jobs/{jobId}":{"get":{"operationId":"getJobStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]},"put":{"operationId":"sendJobCommand","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobCommandDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"number"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"integer","nullable":true,"default":null,"format":"int64"},"fileSizeInByte":{"type":"integer","nullable":true,"default":null,"format":"int64"},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"}},"required":["photos","videos"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true}},"required":["successful"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer","format":"int64"},"diskUseRaw":{"type":"integer","format":"int64"},"diskAvailableRaw":{"type":"integer","format":"int64"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}},"JobCounts":{"type":"object","properties":{"active":{"type":"integer"},"completed":{"type":"integer"},"failed":{"type":"integer"},"delayed":{"type":"integer"},"waiting":{"type":"integer"}},"required":["active","completed","failed","delayed","waiting"]},"AllJobStatusResponseDto":{"type":"object","properties":{"thumbnailGenerationQueueCount":{"$ref":"#/components/schemas/JobCounts"},"metadataExtractionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"videoConversionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"machineLearningQueueCount":{"$ref":"#/components/schemas/JobCounts"},"isThumbnailGenerationActive":{"type":"boolean"},"isMetadataExtractionActive":{"type":"boolean"},"isVideoConversionActive":{"type":"boolean"},"isMachineLearningActive":{"type":"boolean"}},"required":["thumbnailGenerationQueueCount","metadataExtractionQueueCount","videoConversionQueueCount","machineLearningQueueCount","isThumbnailGenerationActive","isMetadataExtractionActive","isVideoConversionActive","isMachineLearningActive"]},"JobId":{"type":"string","enum":["thumbnail-generation","metadata-extraction","video-conversion","machine-learning"]},"JobStatusResponseDto":{"type":"object","properties":{"isActive":{"type":"boolean"},"queueCount":{"type":"object"}},"required":["isActive","queueCount"]},"JobCommand":{"type":"string","enum":["start","stop"]},"JobCommandDto":{"type":"object","properties":{"command":{"$ref":"#/components/schemas/JobCommand"}},"required":["command"]}}}}
\ No newline at end of file
diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts
index 1e657403e0..04aae2733e 100644
--- a/server/libs/common/src/config/app.config.ts
+++ b/server/libs/common/src/config/app.config.ts
@@ -1,5 +1,20 @@
+import { Logger } from '@nestjs/common';
import { ConfigModuleOptions } from '@nestjs/config';
import Joi from 'joi';
+import { createSecretKey, generateKeySync } from 'node:crypto'
+
+const jwtSecretValidator: Joi.CustomValidator = (value, ) => {
+ const key = createSecretKey(value, "base64")
+ const keySizeBits = (key.symmetricKeySize ?? 0) * 8
+
+ if (keySizeBits < 128) {
+ const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64')
+ Logger.warn("The current JWT_SECRET key is insecure. It should be at least 128 bits long!")
+ Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`)
+ }
+
+ return value;
+}
export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env',
@@ -9,7 +24,7 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_USERNAME: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_DATABASE_NAME: Joi.string().required(),
- JWT_SECRET: Joi.string().required(),
+ JWT_SECRET: Joi.string().required().custom(jwtSecretValidator),
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
diff --git a/server/libs/job/src/constants/job-name.constant.ts b/server/libs/job/src/constants/job-name.constant.ts
index 002dd7f5a7..7b32c69028 100644
--- a/server/libs/job/src/constants/job-name.constant.ts
+++ b/server/libs/job/src/constants/job-name.constant.ts
@@ -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',
+}
diff --git a/server/libs/job/src/constants/queue-name.constant.ts b/server/libs/job/src/constants/queue-name.constant.ts
index 15b7d7a6cc..f0f4c4e053 100644
--- a/server/libs/job/src/constants/queue-name.constant.ts
+++ b/server/libs/job/src/constants/queue-name.constant.ts
@@ -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',
+}
diff --git a/server/libs/job/src/interfaces/machine-learning.interface.ts b/server/libs/job/src/interfaces/machine-learning.interface.ts
new file mode 100644
index 0000000000..13bf5e19d5
--- /dev/null
+++ b/server/libs/job/src/interfaces/machine-learning.interface.ts
@@ -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;
+}
diff --git a/server/package-lock.json b/server/package-lock.json
index 39730ad635..9cb57e7d52 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -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",
diff --git a/server/package.json b/server/package.json
index 368765adc1..7d7f5ea2b2 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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",
diff --git a/web/src/api/api.ts b/web/src/api/api.ts
index 5e2b8f356d..b621c649f2 100644
--- a/web/src/api/api.ts
+++ b/web/src/api/api.ts
@@ -4,6 +4,7 @@ import {
AuthenticationApi,
Configuration,
DeviceInfoApi,
+ JobApi,
ServerInfoApi,
UserApi
} from './open-api';
@@ -15,6 +16,8 @@ class ImmichApi {
public authenticationApi: AuthenticationApi;
public deviceInfoApi: DeviceInfoApi;
public serverInfoApi: ServerInfoApi;
+ public jobApi: JobApi;
+
private config = new Configuration({ basePath: '/api' });
constructor() {
@@ -24,6 +27,7 @@ class ImmichApi {
this.authenticationApi = new AuthenticationApi(this.config);
this.deviceInfoApi = new DeviceInfoApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
+ this.jobApi = new JobApi(this.config);
}
public setAccessToken(accessToken: string) {
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index 27ff36ab88..6654d9a328 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -170,6 +170,61 @@ export interface AlbumResponseDto {
*/
'assets': Array;
}
+/**
+ *
+ * @export
+ * @interface AllJobStatusResponseDto
+ */
+export interface AllJobStatusResponseDto {
+ /**
+ *
+ * @type {JobCounts}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'thumbnailGenerationQueueCount': JobCounts;
+ /**
+ *
+ * @type {JobCounts}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'metadataExtractionQueueCount': JobCounts;
+ /**
+ *
+ * @type {JobCounts}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'videoConversionQueueCount': JobCounts;
+ /**
+ *
+ * @type {JobCounts}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'machineLearningQueueCount': JobCounts;
+ /**
+ *
+ * @type {boolean}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'isThumbnailGenerationActive': boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'isMetadataExtractionActive': boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'isVideoConversionActive': boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof AllJobStatusResponseDto
+ */
+ 'isMachineLearningActive': boolean;
+}
/**
*
* @export
@@ -683,10 +738,16 @@ export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum];
export interface ExifResponseDto {
/**
*
- * @type {string}
+ * @type {number}
* @memberof ExifResponseDto
*/
- 'id'?: string | null;
+ 'id'?: number | null;
+ /**
+ *
+ * @type {number}
+ * @memberof ExifResponseDto
+ */
+ 'fileSizeInByte'?: number | null;
/**
*
* @type {string}
@@ -717,12 +778,6 @@ export interface ExifResponseDto {
* @memberof ExifResponseDto
*/
'exifImageHeight'?: number | null;
- /**
- *
- * @type {number}
- * @memberof ExifResponseDto
- */
- 'fileSizeInByte'?: number | null;
/**
*
* @type {string}
@@ -828,6 +883,105 @@ export interface GetAssetCountByTimeBucketDto {
*/
'timeGroup': TimeGroupEnum;
}
+/**
+ *
+ * @export
+ * @enum {string}
+ */
+
+export const JobCommand = {
+ Start: 'start',
+ Stop: 'stop'
+} as const;
+
+export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
+
+
+/**
+ *
+ * @export
+ * @interface JobCommandDto
+ */
+export interface JobCommandDto {
+ /**
+ *
+ * @type {JobCommand}
+ * @memberof JobCommandDto
+ */
+ 'command': JobCommand;
+}
+/**
+ *
+ * @export
+ * @interface JobCounts
+ */
+export interface JobCounts {
+ /**
+ *
+ * @type {number}
+ * @memberof JobCounts
+ */
+ 'active': number;
+ /**
+ *
+ * @type {number}
+ * @memberof JobCounts
+ */
+ 'completed': number;
+ /**
+ *
+ * @type {number}
+ * @memberof JobCounts
+ */
+ 'failed': number;
+ /**
+ *
+ * @type {number}
+ * @memberof JobCounts
+ */
+ 'delayed': number;
+ /**
+ *
+ * @type {number}
+ * @memberof JobCounts
+ */
+ 'waiting': number;
+}
+/**
+ *
+ * @export
+ * @enum {string}
+ */
+
+export const JobId = {
+ ThumbnailGeneration: 'thumbnail-generation',
+ MetadataExtraction: 'metadata-extraction',
+ VideoConversion: 'video-conversion',
+ MachineLearning: 'machine-learning'
+} as const;
+
+export type JobId = typeof JobId[keyof typeof JobId];
+
+
+/**
+ *
+ * @export
+ * @interface JobStatusResponseDto
+ */
+export interface JobStatusResponseDto {
+ /**
+ *
+ * @type {boolean}
+ * @memberof JobStatusResponseDto
+ */
+ 'isActive': boolean;
+ /**
+ *
+ * @type {object}
+ * @memberof JobStatusResponseDto
+ */
+ 'queueCount': object;
+}
/**
*
* @export
@@ -3682,6 +3836,247 @@ export class DeviceInfoApi extends BaseAPI {
}
+/**
+ * JobApi - axios parameter creator
+ * @export
+ */
+export const JobApiAxiosParamCreator = function (configuration?: Configuration) {
+ return {
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getAllJobsStatus: async (options: AxiosRequestConfig = {}): Promise => {
+ const localVarPath = `/jobs`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getJobStatus: async (jobId: JobId, options: AxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'jobId' is not null or undefined
+ assertParamExists('getJobStatus', 'jobId', jobId)
+ const localVarPath = `/jobs/{jobId}`
+ .replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {JobCommandDto} jobCommandDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ sendJobCommand: async (jobId: JobId, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'jobId' is not null or undefined
+ assertParamExists('sendJobCommand', 'jobId', jobId)
+ // verify required parameter 'jobCommandDto' is not null or undefined
+ assertParamExists('sendJobCommand', 'jobCommandDto', jobCommandDto)
+ const localVarPath = `/jobs/{jobId}`
+ .replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+
+ localVarHeaderParameter['Content-Type'] = 'application/json';
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+ localVarRequestOptions.data = serializeDataIfNeeded(jobCommandDto, localVarRequestOptions, configuration)
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ }
+};
+
+/**
+ * JobApi - functional programming interface
+ * @export
+ */
+export const JobApiFp = function(configuration?: Configuration) {
+ const localVarAxiosParamCreator = JobApiAxiosParamCreator(configuration)
+ return {
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async getAllJobsStatus(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.getAllJobsStatus(options);
+ return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+ },
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async getJobStatus(jobId: JobId, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.getJobStatus(jobId, options);
+ return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+ },
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {JobCommandDto} jobCommandDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
+ return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+ },
+ }
+};
+
+/**
+ * JobApi - factory interface
+ * @export
+ */
+export const JobApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
+ const localVarFp = JobApiFp(configuration)
+ return {
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getAllJobsStatus(options?: any): AxiosPromise {
+ return localVarFp.getAllJobsStatus(options).then((request) => request(axios, basePath));
+ },
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getJobStatus(jobId: JobId, options?: any): AxiosPromise {
+ return localVarFp.getJobStatus(jobId, options).then((request) => request(axios, basePath));
+ },
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {JobCommandDto} jobCommandDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: any): AxiosPromise {
+ return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
+ },
+ };
+};
+
+/**
+ * JobApi - object-oriented interface
+ * @export
+ * @class JobApi
+ * @extends {BaseAPI}
+ */
+export class JobApi extends BaseAPI {
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof JobApi
+ */
+ public getAllJobsStatus(options?: AxiosRequestConfig) {
+ return JobApiFp(this.configuration).getAllJobsStatus(options).then((request) => request(this.axios, this.basePath));
+ }
+
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof JobApi
+ */
+ public getJobStatus(jobId: JobId, options?: AxiosRequestConfig) {
+ return JobApiFp(this.configuration).getJobStatus(jobId, options).then((request) => request(this.axios, this.basePath));
+ }
+
+ /**
+ *
+ * @param {JobId} jobId
+ * @param {JobCommandDto} jobCommandDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof JobApi
+ */
+ public sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
+ return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
+ }
+}
+
+
/**
* ServerInfoApi - axios parameter creator
* @export
diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte
new file mode 100644
index 0000000000..5d09434f86
--- /dev/null
+++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+
{title}
+
{subtitle}
+
+
+
+
+
+
+
+ Status |
+ Active |
+ Waiting |
+
+
+
+
+ {jobStatus ? 'Active' : 'Idle'} |
+ {activeJobCount} |
+ {waitingJobCount} |
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
new file mode 100644
index 0000000000..54b3a4fc8d
--- /dev/null
+++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+ Note that some asset does not have any object detected, this is normal.
+
+
diff --git a/web/src/lib/components/shared-components/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar.svelte
index 60f3c637e2..bdae32478d 100644
--- a/web/src/lib/components/shared-components/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar.svelte
@@ -119,7 +119,7 @@
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
id="account-info-panel"
- class="absolute right-[25px] top-[75px] bg-white shadow-lg rounded-2xl w-[360px] text-center"
+ class="absolute right-[25px] top-[75px] bg-white shadow-lg rounded-2xl w-[360px] text-center z-[100]"
use:clickOutside
on:out-click={() => (shouldShowAccountInfoPanel = false)}
>
diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
index b4daac2d5f..4cba23d8ca 100644
--- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
+++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
@@ -94,7 +94,7 @@
(isHover = true)}
diff --git a/web/src/lib/models/admin-sidebar-selection.ts b/web/src/lib/models/admin-sidebar-selection.ts
index a11914c96b..6ffe6ef4e1 100644
--- a/web/src/lib/models/admin-sidebar-selection.ts
+++ b/web/src/lib/models/admin-sidebar-selection.ts
@@ -1,5 +1,7 @@
export enum AdminSideBarSelection {
- USER_MANAGEMENT = 'User management'
+ USER_MANAGEMENT = 'User management',
+ JOBS = 'Jobs',
+ SETTINGS = 'Settings'
}
export enum AppSideBarSelection {
diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte
new file mode 100644
index 0000000000..7270e1de67
--- /dev/null
+++ b/web/src/routes/admin/+layout.svelte
@@ -0,0 +1,3 @@
+
+
+
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte
index 8d18519c2d..5bde9cadfa 100644
--- a/web/src/routes/admin/+page.svelte
+++ b/web/src/routes/admin/+page.svelte
@@ -4,6 +4,7 @@
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
+ import Cog from 'svelte-material-icons/Cog.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import UserManagement from '$lib/components/admin-page/user-management.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@@ -12,6 +13,7 @@
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import type { PageData } from './$types';
import { api, UserResponseDto } from '@api';
+ import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
@@ -104,14 +106,21 @@
{/if}