Browse Source

Merge upstream

Alex Tran 2 years ago
parent
commit
4f2c08525f
100 changed files with 2870 additions and 440 deletions
  1. 12 12
      .github/workflows/build_push_docker_latest.yml
  2. 16 14
      .github/workflows/build_push_docker_staging.yml
  3. 12 12
      .github/workflows/build_push_server_release.yml
  4. 83 0
      .github/workflows/openapi-generator.yml
  5. 1 1
      .github/workflows/test.yml
  6. 10 5
      README.md
  7. 4 1
      docker/.env.example
  8. 18 13
      install.sh
  9. 8 5
      mobile/android/app/src/main/AndroidManifest.xml
  10. 0 25
      mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt
  11. 8 7
      mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt
  12. 57 37
      mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt
  13. 3 4
      mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt
  14. 19 0
      mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt
  15. 2 12
      mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt
  16. 9 4
      mobile/android/fastlane/Fastfile
  17. 3 3
      mobile/android/fastlane/README.md
  18. 1 0
      mobile/android/fastlane/metadata/android/en-US/changelogs/47.txt
  19. 1 0
      mobile/android/fastlane/metadata/android/en-US/changelogs/48.txt
  20. 2 0
      mobile/android/fastlane/metadata/android/en-US/changelogs/49.txt
  21. 3 3
      mobile/android/fastlane/report.xml
  22. 4 0
      mobile/assets/i18n/en-US.json
  23. 3 3
      mobile/ios/Runner.xcodeproj/project.pbxproj
  24. 2 2
      mobile/ios/Runner/Info.plist
  25. 1 1
      mobile/ios/fastlane/Fastfile
  26. 6 6
      mobile/ios/fastlane/report.xml
  27. 101 23
      mobile/lib/modules/backup/background_service/background.service.dart
  28. 0 2
      mobile/lib/modules/home/providers/home_page_render_list_provider.dart
  29. 21 13
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  30. 5 1
      mobile/lib/modules/settings/services/app_settings.service.dart
  31. 49 1
      mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart
  32. 14 0
      mobile/openapi/.openapi-generator/FILES
  33. 9 0
      mobile/openapi/README.md
  34. 22 0
      mobile/openapi/doc/AllJobStatusResponseDto.md
  35. 15 0
      mobile/openapi/doc/CreateJobDto.md
  36. 2 2
      mobile/openapi/doc/ExifResponseDto.md
  37. 155 0
      mobile/openapi/doc/JobApi.md
  38. 14 0
      mobile/openapi/doc/JobCommand.md
  39. 15 0
      mobile/openapi/doc/JobCommandDto.md
  40. 19 0
      mobile/openapi/doc/JobCounts.md
  41. 14 0
      mobile/openapi/doc/JobId.md
  42. 16 0
      mobile/openapi/doc/JobStatusResponseDto.md
  43. 14 0
      mobile/openapi/doc/JobType.md
  44. 7 0
      mobile/openapi/lib/api.dart
  45. 159 0
      mobile/openapi/lib/api/job_api.dart
  46. 12 0
      mobile/openapi/lib/api_client.dart
  47. 6 0
      mobile/openapi/lib/api_helper.dart
  48. 167 0
      mobile/openapi/lib/model/all_job_status_response_dto.dart
  49. 56 67
      mobile/openapi/lib/model/asset_response_dto.dart
  50. 111 0
      mobile/openapi/lib/model/create_job_dto.dart
  51. 14 16
      mobile/openapi/lib/model/exif_response_dto.dart
  52. 85 0
      mobile/openapi/lib/model/job_command.dart
  53. 111 0
      mobile/openapi/lib/model/job_command_dto.dart
  54. 143 0
      mobile/openapi/lib/model/job_counts.dart
  55. 91 0
      mobile/openapi/lib/model/job_id.dart
  56. 119 0
      mobile/openapi/lib/model/job_status_response_dto.dart
  57. 91 0
      mobile/openapi/lib/model/job_type.dart
  58. 52 0
      mobile/openapi/test/all_job_status_response_dto_test.dart
  59. 27 0
      mobile/openapi/test/create_job_dto_test.dart
  60. 41 0
      mobile/openapi/test/job_api_test.dart
  61. 27 0
      mobile/openapi/test/job_command_dto_test.dart
  62. 21 0
      mobile/openapi/test/job_command_test.dart
  63. 47 0
      mobile/openapi/test/job_counts_test.dart
  64. 21 0
      mobile/openapi/test/job_id_test.dart
  65. 32 0
      mobile/openapi/test/job_status_response_dto_test.dart
  66. 21 0
      mobile/openapi/test/job_type_test.dart
  67. 1 1
      mobile/pubspec.yaml
  68. 1 1
      server/.dockerignore
  69. 2 0
      server/Dockerfile
  70. 3 0
      server/apps/immich/src/api-v1/album/album.service.spec.ts
  71. 30 0
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  72. 2 2
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  73. 2 2
      server/apps/immich/src/api-v1/asset/asset.module.ts
  74. 3 0
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  75. 2 1
      server/apps/immich/src/api-v1/asset/dto/get-asset-thumbnail.dto.ts
  76. 7 3
      server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
  77. 22 0
      server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
  78. 12 0
      server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
  79. 43 0
      server/apps/immich/src/api-v1/job/job.controller.ts
  80. 82 0
      server/apps/immich/src/api-v1/job/job.module.ts
  81. 180 0
      server/apps/immich/src/api-v1/job/job.service.ts
  82. 40 0
      server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts
  83. 6 0
      server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts
  84. 3 3
      server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts
  85. 3 0
      server/apps/immich/src/app.module.ts
  86. 2 2
      server/apps/immich/src/constants/server_version.constant.ts
  87. 8 0
      server/apps/immich/src/modules/background-task/background-task.processor.ts
  88. 4 8
      server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
  89. 9 11
      server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
  90. 16 12
      server/apps/microservices/src/microservices.module.ts
  91. 10 6
      server/apps/microservices/src/microservices.service.ts
  92. 5 8
      server/apps/microservices/src/processors/asset-uploaded.processor.ts
  93. 3 3
      server/apps/microservices/src/processors/generate-checksum.processor.ts
  94. 60 0
      server/apps/microservices/src/processors/machine-learning.processor.ts
  95. 28 64
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  96. 20 13
      server/apps/microservices/src/processors/thumbnail.processor.ts
  97. 2 2
      server/apps/microservices/src/processors/video-transcode.processor.ts
  98. 0 0
      server/immich-openapi-specs.json
  99. 16 1
      server/libs/common/src/config/app.config.ts
  100. 9 2
      server/libs/job/src/constants/job-name.constant.ts

+ 12 - 12
.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

+ 16 - 14
.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 }}

+ 12 - 12
.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

+ 83 - 0
.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'

+ 1 - 1
.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:

+ 10 - 5
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
 | - | - | - |
 | <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
 
-> *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)
   <br/>  
 
 # Development

+ 4 - 1
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

+ 18 - 13
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
 }
 
-populate_upload_location() {
-  echo "Populating default UPLOAD_LOCATION value..."
-
-  cd ./immich-app/immich-data
-
-  upload_location=$(pwd)
-
-  # Replace value of UPLOAD_LOCATION in .env with upload_location path
+replace_env_value() {
   if [[ "$OSTYPE" == "darwin"* ]]; then
-    sed -i '' "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
+    sed -i '' "s|$1=.*|$1=$2|" ./.env
   else
-    sed -i "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
+    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 ..
+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

+ 8 - 5
mobile/android/app/src/main/AndroidManifest.xml

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

+ 0 - 25
mobile/android/app/src/main/kotlin/com/example/mobile/AppClearedService.kt

@@ -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();
-    }
-}

+ 8 - 7
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<ArrayList<*>>()!!
                 ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
-                    .edit()
-                    .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
-                    .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
-                    .apply()
+                        .edit()
+                        .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
+                        .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
+                        .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
+                        .apply()
                 ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
                 result.success(true)
             }
@@ -54,7 +55,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
                 val requireUnmeteredNetwork = args.get(0) as Boolean
                 val requireCharging = args.get(1) as Boolean
                 ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
-                result.success(true)   
+                result.success(true)
             }
             "disable" -> {
                 ContentObserverWorker.disable(ctx)

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

+ 3 - 4
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)

+ 19 - 0
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)
+    }
+}

+ 2 - 12
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())
     }
 
 }

+ 9 - 4
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')

+ 3 - 3
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
 

+ 1 - 0
mobile/android/fastlane/metadata/android/en-US/changelogs/47.txt

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

+ 1 - 0
mobile/android/fastlane/metadata/android/en-US/changelogs/48.txt

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

+ 2 - 0
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

+ 3 - 3
mobile/android/fastlane/report.xml

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

+ 4 - 0
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",

+ 3 - 3
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;

+ 2 - 2
mobile/ios/Runner/Info.plist

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

+ 1 - 1
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,

+ 6 - 6
mobile/ios/fastlane/report.xml

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

+ 101 - 23
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<bool> _updateNotification({
-    required String title,
+  Future<bool?> _updateNotification({
+    String? title,
     String? content,
+    int progress = 0,
+    int max = 0,
+    bool indeterminate = false,
+    bool isDetail = false,
+    bool onlyIfFG = false,
   }) async {
     if (!Platform.isAndroid) {
       return true;
     }
     try {
       if (_isBackgroundInitialized) {
-        return await _backgroundChannel
-            .invokeMethod('updateNotification', [title, content]);
+        return _backgroundChannel.invokeMethod<bool>(
+          'updateNotification',
+          [title, content, progress, max, indeterminate, isDetail, onlyIfFG],
+        );
       }
     } catch (error) {
       debugPrint("[_updateNotification] failed to communicate with plugin");
     }
-    return Future.value(false);
+    return false;
   }
 
   /// Shows a new priority notification
@@ -274,6 +285,7 @@ class BackgroundService {
       case "onAssetsChanged":
         final Future<bool> translationsLoaded = loadTranslations();
         try {
+          _clearErrorNotifications();
           final bool hasAccess = await acquireLock();
           if (!hasAccess) {
             debugPrint("[_callHandler] could not acquire lock, exiting");
@@ -313,19 +325,23 @@ class BackgroundService {
     apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
     apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
     BackupService backupService = BackupService(apiService);
+    AppSettingsService settingsService = AppSettingsService();
 
     final Box<HiveBackupAlbums> box =
         await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
     final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
     if (backupAlbumInfo == null) {
-      _clearErrorNotifications();
       return true;
     }
 
     await PhotoManager.setIgnorePermissionCheck(true);
 
     do {
-      final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
+      final bool backupOk = await _runBackup(
+        backupService,
+        settingsService,
+        backupAlbumInfo,
+      );
       if (backupOk) {
         await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
         await box.put(
@@ -346,9 +362,14 @@ class BackgroundService {
 
   Future<bool> _runBackup(
     BackupService backupService,
+    AppSettingsService settingsService,
     HiveBackupAlbums backupAlbumInfo,
   ) async {
-    _errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
+    _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
+    final bool notifyTotalProgress = settingsService
+        .getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
+    final bool notifySingleProgress = settingsService
+        .getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
 
     if (_canceledBySystem) {
       return false;
@@ -372,22 +393,29 @@ class BackgroundService {
     }
 
     if (toUpload.isEmpty) {
-      _clearErrorNotifications();
       return true;
     }
+    _assetsToUploadCount = toUpload.length;
+    _uploadedAssetsCount = 0;
+    _updateNotification(
+      title: "backup_background_service_in_progress_notification".tr(),
+      content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
+      progress: 0,
+      max: notifyTotalProgress ? _assetsToUploadCount : 0,
+      indeterminate: !notifyTotalProgress,
+      onlyIfFG: !notifyTotalProgress,
+    );
 
     _cancellationToken = CancellationToken();
     final bool ok = await backupService.backupAsset(
       toUpload,
       _cancellationToken!,
-      _onAssetUploaded,
-      _onProgress,
-      _onSetCurrentBackupAsset,
+      notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
+      notifySingleProgress ? _onProgress : (sent, total) {},
+      notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
       _onBackupError,
     );
-    if (ok) {
-      _clearErrorNotifications();
-    } else {
+    if (!ok && !_cancellationToken!.isCancelled) {
       _showErrorNotification(
         title: "backup_background_service_error_title".tr(),
         content: "backup_background_service_backup_failed_message".tr(),
@@ -396,16 +424,43 @@ class BackgroundService {
     return ok;
   }
 
+  String _formatAssetBackupProgress() {
+    final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
+    return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
+  }
+
   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) {}
+  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

+ 0 - 2
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';

+ 21 - 13
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<AssetResponseDto>);
+  bool,
+  Set<AssetResponseDto>,
+);
 
 class ImmichAssetGridState extends State<ImmichAssetGrid> {
   final ItemScrollController _itemScrollController = ItemScrollController();
@@ -23,7 +22,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
       ItemPositionsListener.create();
 
   bool _scrolling = false;
-  Set<String> _selectedAssets = HashSet();
+  final Set<String> _selectedAssets = HashSet();
 
   List<AssetResponseDto> get _assets {
     return widget.renderList
@@ -86,7 +85,9 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   }
 
   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<ImmichAssetGrid> {
   }
 
   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<ImmichAssetGrid> {
           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<ImmichAssetGrid> {
   }
 
   Widget _buildTitle(
-      BuildContext context, String title, List<AssetResponseDto> assets) {
+    BuildContext context,
+    String title,
+    List<AssetResponseDto> assets,
+  ) {
     return DailyTitleText(
       isoDate: title,
       multiselectEnabled: widget.selectionActive,
@@ -186,7 +195,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   }
 
   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<ImmichAssetGrid> {
     );
   }
 
-
   @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

+ 5 - 1
mobile/lib/modules/settings/services/app_settings.service.dart

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

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

+ 14 - 0
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

+ 9 - 0
mobile/openapi/README.md

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

+ 22 - 0
mobile/openapi/doc/AllJobStatusResponseDto.md

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

+ 15 - 0
mobile/openapi/doc/CreateJobDto.md

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

+ 2 - 2
mobile/openapi/doc/ExifResponseDto.md

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

+ 155 - 0
mobile/openapi/doc/JobApi.md

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

+ 14 - 0
mobile/openapi/doc/JobCommand.md

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

+ 15 - 0
mobile/openapi/doc/JobCommandDto.md

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

+ 19 - 0
mobile/openapi/doc/JobCounts.md

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

+ 14 - 0
mobile/openapi/doc/JobId.md

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

+ 16 - 0
mobile/openapi/doc/JobStatusResponseDto.md

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

+ 14 - 0
mobile/openapi/doc/JobType.md

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

+ 7 - 0
mobile/openapi/lib/api.dart

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

+ 159 - 0
mobile/openapi/lib/api/job_api.dart

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

+ 12 - 0
mobile/openapi/lib/api_client.dart

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

+ 6 - 0
mobile/openapi/lib/api_helper.dart

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

+ 167 - 0
mobile/openapi/lib/model/all_job_status_response_dto.dart

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

+ 56 - 67
mobile/openapi/lib/model/asset_response_dto.dart

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

+ 111 - 0
mobile/openapi/lib/model/create_job_dto.dart

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

+ 14 - 16
mobile/openapi/lib/model/exif_response_dto.dart

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

+ 85 - 0
mobile/openapi/lib/model/job_command.dart

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

+ 111 - 0
mobile/openapi/lib/model/job_command_dto.dart

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

+ 143 - 0
mobile/openapi/lib/model/job_counts.dart

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

+ 91 - 0
mobile/openapi/lib/model/job_id.dart

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

+ 119 - 0
mobile/openapi/lib/model/job_status_response_dto.dart

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

+ 91 - 0
mobile/openapi/lib/model/job_type.dart

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

+ 52 - 0
mobile/openapi/test/all_job_status_response_dto_test.dart

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

+ 27 - 0
mobile/openapi/test/create_job_dto_test.dart

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

+ 41 - 0
mobile/openapi/test/job_api_test.dart

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

+ 27 - 0
mobile/openapi/test/job_command_dto_test.dart

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

+ 21 - 0
mobile/openapi/test/job_command_test.dart

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

+ 47 - 0
mobile/openapi/test/job_counts_test.dart

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

+ 21 - 0
mobile/openapi/test/job_id_test.dart

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

+ 32 - 0
mobile/openapi/test/job_status_response_dto_test.dart

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

+ 21 - 0
mobile/openapi/test/job_type_test.dart

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

+ 1 - 1
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"

+ 1 - 1
server/.dockerignore

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

+ 2 - 0
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

+ 3 - 0
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);

+ 30 - 0
server/apps/immich/src/api-v1/asset/asset-repository.ts

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

+ 2 - 2
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<IAssetUploadedJob>,
   ) {}
 

+ 2 - 2
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,

+ 3 - 0
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);

+ 2 - 1
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;
 }

+ 7 - 3
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,

+ 22 - 0
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;
+}

+ 12 - 0
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;
+}

+ 43 - 0
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<AllJobStatusResponseDto> {
+    return this.jobService.getAllJobsStatus();
+  }
+
+  @Get('/:jobId')
+  getJobStatus(@Param(ValidationPipe) params: GetJobDto): Promise<JobStatusResponseDto> {
+    return this.jobService.getJobStatus(params);
+  }
+
+  @Put('/:jobId')
+  async sendJobCommand(
+    @Param(ValidationPipe) params: GetJobDto,
+    @Body(ValidationPipe) body: JobCommandDto,
+  ): Promise<number> {
+    if (body.command === 'start') {
+      return await this.jobService.startJob(params);
+    }
+    if (body.command === 'stop') {
+      return await this.jobService.stopJob(params);
+    }
+    return 0;
+  }
+}

+ 82 - 0
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 {}

+ 180 - 0
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<IThumbnailGenerationJob>,
+
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
+    private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
+
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
+    private videoConversionQueue: Queue<IVideoTranscodeJob>,
+
+    @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
+    private machineLearningQueue: Queue<IMachineLearningJob>,
+
+    @Inject(ASSET_REPOSITORY)
+    private _assetRepository: IAssetRepository,
+  ) {
+    this.thumbnailGeneratorQueue.empty();
+    this.metadataExtractionQueue.empty();
+    this.videoConversionQueue.empty();
+  }
+
+  async startJob(jobDto: GetJobDto): Promise<number> {
+    switch (jobDto.jobId) {
+      case JobId.THUMBNAIL_GENERATION:
+        return this.runThumbnailGenerationJob();
+      case JobId.METADATA_EXTRACTION:
+        return this.runMetadataExtractionJob();
+      case JobId.VIDEO_CONVERSION:
+        return 0;
+      case JobId.MACHINE_LEARNING:
+        return this.runMachineLearningPipeline();
+      default:
+        throw new BadRequestException('Invalid job id');
+    }
+  }
+
+  async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
+    const thumbnailGeneratorJobCount = await this.thumbnailGeneratorQueue.getJobCounts();
+    const metadataExtractionJobCount = await this.metadataExtractionQueue.getJobCounts();
+    const videoConversionJobCount = await this.videoConversionQueue.getJobCounts();
+    const machineLearningJobCount = await this.machineLearningQueue.getJobCounts();
+
+    const response = new AllJobStatusResponseDto();
+    response.isThumbnailGenerationActive = Boolean(thumbnailGeneratorJobCount.waiting);
+    response.thumbnailGenerationQueueCount = thumbnailGeneratorJobCount;
+    response.isMetadataExtractionActive = Boolean(metadataExtractionJobCount.waiting);
+    response.metadataExtractionQueueCount = metadataExtractionJobCount;
+    response.isVideoConversionActive = Boolean(videoConversionJobCount.waiting);
+    response.videoConversionQueueCount = videoConversionJobCount;
+    response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting);
+    response.machineLearningQueueCount = machineLearningJobCount;
+
+    return response;
+  }
+
+  async getJobStatus(query: GetJobDto): Promise<JobStatusResponseDto> {
+    const response = new JobStatusResponseDto();
+    if (query.jobId === JobId.THUMBNAIL_GENERATION) {
+      response.isActive = Boolean((await this.thumbnailGeneratorQueue.getJobCounts()).waiting);
+      response.queueCount = await this.thumbnailGeneratorQueue.getJobCounts();
+    }
+
+    if (query.jobId === JobId.METADATA_EXTRACTION) {
+      response.isActive = Boolean((await this.metadataExtractionQueue.getJobCounts()).waiting);
+      response.queueCount = await this.metadataExtractionQueue.getJobCounts();
+    }
+
+    if (query.jobId === JobId.VIDEO_CONVERSION) {
+      response.isActive = Boolean((await this.videoConversionQueue.getJobCounts()).waiting);
+      response.queueCount = await this.videoConversionQueue.getJobCounts();
+    }
+
+    return response;
+  }
+
+  async stopJob(query: GetJobDto): Promise<number> {
+    switch (query.jobId) {
+      case JobId.THUMBNAIL_GENERATION:
+        this.thumbnailGeneratorQueue.empty();
+        return 0;
+      case JobId.METADATA_EXTRACTION:
+        this.metadataExtractionQueue.empty();
+        return 0;
+      case JobId.VIDEO_CONVERSION:
+        this.videoConversionQueue.empty();
+        return 0;
+      case JobId.MACHINE_LEARNING:
+        this.machineLearningQueue.empty();
+        return 0;
+      default:
+        throw new BadRequestException('Invalid job id');
+    }
+  }
+
+  private async runThumbnailGenerationJob(): Promise<number> {
+    const jobCount = await this.thumbnailGeneratorQueue.getJobCounts();
+
+    if (jobCount.waiting > 0) {
+      throw new BadRequestException('Thumbnail generation job is already running');
+    }
+
+    const assetsWithNoThumbnail = await this._assetRepository.getAssetWithNoThumbnail();
+
+    for (const asset of assetsWithNoThumbnail) {
+      await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
+    }
+
+    return assetsWithNoThumbnail.length;
+  }
+
+  private async runMetadataExtractionJob(): Promise<number> {
+    const jobCount = await this.metadataExtractionQueue.getJobCounts();
+
+    if (jobCount.waiting > 0) {
+      throw new BadRequestException('Metadata extraction job is already running');
+    }
+
+    const assetsWithNoExif = await this._assetRepository.getAssetWithNoEXIF();
+    for (const asset of assetsWithNoExif) {
+      if (asset.type === AssetType.VIDEO) {
+        await this.metadataExtractionQueue.add(
+          videoMetadataExtractionProcessorName,
+          { asset, fileName: asset.id },
+          { jobId: randomUUID() },
+        );
+      } else {
+        await this.metadataExtractionQueue.add(
+          exifExtractionProcessorName,
+          { asset, fileName: asset.id },
+          { jobId: randomUUID() },
+        );
+      }
+    }
+    return assetsWithNoExif.length;
+  }
+
+  private async runMachineLearningPipeline(): Promise<number> {
+    const jobCount = await this.machineLearningQueue.getJobCounts();
+
+    if (jobCount.waiting > 0) {
+      throw new BadRequestException('Metadata extraction job is already running');
+    }
+
+    const assetWithNoSmartInfo = await this._assetRepository.getAssetWithNoSmartInfo();
+
+    for (const asset of assetWithNoSmartInfo) {
+      await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(
+        MachineLearningJobNameEnum.OBJECT_DETECTION,
+        { asset },
+        { jobId: randomUUID() },
+      );
+    }
+
+    return assetWithNoSmartInfo.length;
+  }
+}

+ 40 - 0
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;
+}

+ 6 - 0
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;
+}

+ 3 - 3
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' })

+ 3 - 0
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: [],

+ 2 - 2
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,
 };

+ 8 - 0
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);
+          }
+        });
+      }
     }
   }
 }

+ 4 - 8
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,

+ 9 - 11
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<ExifEntity>,
 
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue,
 
-    @InjectQueue(videoConversionQueueName)
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
 
-    @InjectQueue(metadataExtractionQueueName)
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
 
     private configService: ConfigService,
@@ -108,11 +106,11 @@ export class ScheduleTasksService {
 
   @Cron(CronExpression.EVERY_DAY_AT_3AM)
   async extractExif() {
-    const exifAssets = await this.assetRepository.find({
-      where: {
-        exifInfo: IsNull(),
-      },
-    });
+    const exifAssets = await this.assetRepository
+      .createQueryBuilder('asset')
+      .leftJoinAndSelect('asset.exifInfo', 'ei')
+      .where('ei."assetId" IS NULL')
+      .getMany();
 
     for (const asset of exifAssets) {
       if (asset.type === AssetType.VIDEO) {

+ 16 - 12
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: [],

+ 10 - 6
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
+      },
+    );
   }
 }

+ 5 - 8
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<IThumbnailGenerationJob>,
 
-    @InjectQueue(metadataExtractionQueueName)
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
 
-    @InjectQueue(videoConversionQueueName)
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
   ) {}
 

+ 3 - 3
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) {

+ 60 - 0
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<SmartInfoEntity>,
+  ) {}
+
+  @Process({ name: MachineLearningJobNameEnum.IMAGE_TAGGING, concurrency: 2 })
+  async tagImage(job: Job<IMachineLearningJob>) {
+    const { asset } = job.data;
+
+    const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
+      thumbnailPath: asset.resizePath,
+    });
+
+    if (res.status == 201 && res.data.length > 0) {
+      const smartInfo = new SmartInfoEntity();
+      smartInfo.assetId = asset.id;
+      smartInfo.tags = [...res.data];
+
+      await this.smartInfoRepository.upsert(smartInfo, {
+        conflictPaths: ['assetId'],
+      });
+    }
+  }
+
+  @Process({ name: MachineLearningJobNameEnum.OBJECT_DETECTION, concurrency: 2 })
+  async detectObject(job: Job<IMachineLearningJob>) {
+    try {
+      const { asset }: { asset: AssetEntity } = job.data;
+
+      const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
+        thumbnailPath: asset.resizePath,
+      });
+
+      if (res.status == 201 && res.data.length > 0) {
+        const smartInfo = new SmartInfoEntity();
+        smartInfo.assetId = asset.id;
+        smartInfo.objects = [...res.data];
+
+        await this.smartInfoRepository.upsert(smartInfo, {
+          conflictPaths: ['assetId'],
+        });
+      }
+    } catch (error) {
+      Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
+    }
+  }
+}

+ 28 - 64
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<ExifEntity>,
 
-    @InjectRepository(SmartInfoEntity)
-    private smartInfoRepository: Repository<SmartInfoEntity>,
-
     private configService: ConfigService,
   ) {
-    if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
+    if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
       Logger.log('Initialising Reverse Geocoding');
       geocoderInit({
         // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -109,7 +102,8 @@ export class MetadataExtractionProcessor {
           alternateNames: false,
         },
         countries: [],
-        dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'),
+        dumpDirectory:
+          configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || process.cwd() + '/.reverse-geocoding-dump/',
       }).then(() => {
         this.isGeocodeInitialized = true;
         Logger.log('Reverse Geocoding Initialised');
@@ -129,10 +123,22 @@ export class MetadataExtractionProcessor {
     const city = geoCodeInfo.name;
 
     let state = '';
-    if (geoCodeInfo.admin2Code?.name) state += geoCodeInfo.admin2Code.name;
-    if (geoCodeInfo.admin1Code?.name) {
-      if (geoCodeInfo.admin2Code?.name) state += ', ';
-      state += geoCodeInfo.admin1Code.name;
+
+    if (geoCodeInfo.admin2Code) {
+      const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
+      state += adminCode2.name;
+    }
+
+    if (geoCodeInfo.admin1Code) {
+      const adminCode1 = geoCodeInfo.admin1Code as AdminCode;
+
+      if (geoCodeInfo.admin2Code) {
+        const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
+        if (adminCode2.name) {
+          state += ', ';
+        }
+      }
+      state += adminCode1.name;
     }
 
     return { country, state, city };
@@ -273,48 +279,6 @@ export class MetadataExtractionProcessor {
     }
   }
 
-  @Process({ name: imageTaggingProcessorName, concurrency: 2 })
-  async tagImage(job: Job) {
-    const { asset }: { asset: AssetEntity } = job.data;
-
-    const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
-      thumbnailPath: asset.resizePath,
-    });
-
-    if (res.status == 201 && res.data.length > 0) {
-      const smartInfo = new SmartInfoEntity();
-      smartInfo.assetId = asset.id;
-      smartInfo.tags = [...res.data];
-
-      await this.smartInfoRepository.upsert(smartInfo, {
-        conflictPaths: ['assetId'],
-      });
-    }
-  }
-
-  @Process({ name: objectDetectionProcessorName, concurrency: 2 })
-  async detectObject(job: Job) {
-    try {
-      const { asset }: { asset: AssetEntity } = job.data;
-
-      const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
-        thumbnailPath: asset.resizePath,
-      });
-
-      if (res.status == 201 && res.data.length > 0) {
-        const smartInfo = new SmartInfoEntity();
-        smartInfo.assetId = asset.id;
-        smartInfo.objects = [...res.data];
-
-        await this.smartInfoRepository.upsert(smartInfo, {
-          conflictPaths: ['assetId'],
-        });
-      }
-    } catch (error) {
-      Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
-    }
-  }
-
   @Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
   async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
     const { asset, fileName } = job.data;

+ 20 - 13
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<AssetEntity>,
 
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue,
 
     private wsCommunicationGateway: CommunicationGateway,
 
-    @InjectQueue(metadataExtractionQueueName)
-    private metadataExtractionQueue: Queue,
+    @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
+    private machineLearningQueue: Queue<IMachineLearningJob>,
 
     private configService: ConfigService,
   ) {
@@ -62,7 +61,7 @@ export class ThumbnailGeneratorProcessor {
 
     const temp = asset.originalPath.split('/');
     const originalFilename = temp[temp.length - 1].split('.')[0];
-    const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
+    const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
 
     if (asset.type == AssetType.IMAGE) {
       try {
@@ -80,8 +79,12 @@ export class ThumbnailGeneratorProcessor {
       asset.resizePath = jpegThumbnailPath;
 
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(
+        MachineLearningJobNameEnum.OBJECT_DETECTION,
+        { asset },
+        { jobId: randomUUID() },
+      );
       this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
     }
 
@@ -110,8 +113,12 @@ export class ThumbnailGeneratorProcessor {
       asset.resizePath = jpegThumbnailPath;
 
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(
+        MachineLearningJobNameEnum.OBJECT_DETECTION,
+        { asset },
+        { jobId: randomUUID() },
+      );
 
       this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
     }

+ 2 - 2
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)

File diff suppressed because it is too large
+ 0 - 0
server/immich-openapi-specs.json


+ 16 - 1
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<string> = (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'),

+ 9 - 2
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',
+}

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