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
           fetch-depth: 0
 
 
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
         with:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and push Immich Mono Repo
       - name: Build and push Immich Mono Repo
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./server
           context: ./server
           file: ./server/Dockerfile
           file: ./server/Dockerfile
@@ -45,17 +45,17 @@ jobs:
           fetch-depth: 0
           fetch-depth: 0
 
 
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
         with:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and Push Machine Learning
       - name: Build and Push Machine Learning
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./machine-learning
           context: ./machine-learning
           file: ./machine-learning/Dockerfile
           file: ./machine-learning/Dockerfile
@@ -72,17 +72,17 @@ jobs:
         with:
         with:
           fetch-depth: 0
           fetch-depth: 0
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
         with:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and Push Web
       - name: Build and Push Web
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./web
           context: ./web
           file: ./web/Dockerfile
           file: ./web/Dockerfile
@@ -100,17 +100,17 @@ jobs:
         with:
         with:
           fetch-depth: 0
           fetch-depth: 0
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
         with:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and Push Proxy
       - name: Build and Push Proxy
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./nginx
           context: ./nginx
           file: ./nginx/Dockerfile
           file: ./nginx/Dockerfile

+ 16 - 14
.github/workflows/build_push_docker_staging.yml

@@ -2,8 +2,6 @@ name: Build and Push Docker Image - Staging
 
 
 on:
 on:
   workflow_dispatch:
   workflow_dispatch:
-  push:
-    branches: [main]
   pull_request:
   pull_request:
     branches: [main]
     branches: [main]
 
 
@@ -19,10 +17,10 @@ jobs:
           fetch-depth: 0
           fetch-depth: 0
 
 
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         if: ${{ github.repository == 'immich-app/immich' }}
         if: ${{ github.repository == 'immich-app/immich' }}
         uses: docker/login-action@v2
         uses: docker/login-action@v2
@@ -30,7 +28,7 @@ jobs:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and push Immich Mono Repo
       - name: Build and push Immich Mono Repo
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./server
           context: ./server
           file: ./server/Dockerfile
           file: ./server/Dockerfile
@@ -38,6 +36,7 @@ jobs:
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           tags: |
           tags: |
             altran1502/immich-server:staging
             altran1502/immich-server:staging
+            altran1502/immich-server:${{ github.event.pull_request.number }}
 
 
   build_and_push_machine_learning_staging:
   build_and_push_machine_learning_staging:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -48,10 +47,10 @@ jobs:
           fetch-depth: 0
           fetch-depth: 0
 
 
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         if: ${{ github.repository == 'immich-app/immich' }}
         if: ${{ github.repository == 'immich-app/immich' }}
         uses: docker/login-action@v2
         uses: docker/login-action@v2
@@ -59,7 +58,7 @@ jobs:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and Push Machine Learning
       - name: Build and Push Machine Learning
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./machine-learning
           context: ./machine-learning
           file: ./machine-learning/Dockerfile
           file: ./machine-learning/Dockerfile
@@ -67,6 +66,7 @@ jobs:
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           tags: |
           tags: |
             altran1502/immich-machine-learning:staging
             altran1502/immich-machine-learning:staging
+            altran1502/immich-machine-learning:${{ github.event.pull_request.number }}
 
 
   build_and_push_web_staging:
   build_and_push_web_staging:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -76,10 +76,10 @@ jobs:
         with:
         with:
           fetch-depth: 0
           fetch-depth: 0
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         if: ${{ github.repository == 'immich-app/immich' }}
         if: ${{ github.repository == 'immich-app/immich' }}
         uses: docker/login-action@v2
         uses: docker/login-action@v2
@@ -87,7 +87,7 @@ jobs:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and Push Web
       - name: Build and Push Web
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./web
           context: ./web
           file: ./web/Dockerfile
           file: ./web/Dockerfile
@@ -96,6 +96,7 @@ jobs:
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           tags: |
           tags: |
             altran1502/immich-web:staging
             altran1502/immich-web:staging
+            altran1502/immich-web:${{ github.event.pull_request.number }}
 
 
   build_and_push_nginx_staging:
   build_and_push_nginx_staging:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -105,10 +106,10 @@ jobs:
         with:
         with:
           fetch-depth: 0
           fetch-depth: 0
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         if: ${{ github.repository == 'immich-app/immich' }}
         if: ${{ github.repository == 'immich-app/immich' }}
         uses: docker/login-action@v2
         uses: docker/login-action@v2
@@ -116,7 +117,7 @@ jobs:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and Push Proxy
       - name: Build and Push Proxy
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./nginx
           context: ./nginx
           file: ./nginx/Dockerfile
           file: ./nginx/Dockerfile
@@ -124,3 +125,4 @@ jobs:
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
           tags: |
           tags: |
             altran1502/immich-proxy:staging
             altran1502/immich-proxy:staging
+            altran1502/immich-proxy:${{ github.event.pull_request.number }}

+ 12 - 12
.github/workflows/build_push_server_release.yml

@@ -22,11 +22,11 @@ jobs:
           fallback: latest
           fallback: latest
 
 
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
 
 
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
@@ -35,7 +35,7 @@ jobs:
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
 
 
       - name: Build and push immich-server release
       - name: Build and push immich-server release
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./server
           context: ./server
           file: ./server/Dockerfile
           file: ./server/Dockerfile
@@ -58,17 +58,17 @@ jobs:
         with:
         with:
           fallback: latest
           fallback: latest
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
         with:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
       - name: Build and Push Machine Learning
       - name: Build and Push Machine Learning
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./machine-learning
           context: ./machine-learning
           file: ./machine-learning/Dockerfile
           file: ./machine-learning/Dockerfile
@@ -94,11 +94,11 @@ jobs:
           fallback: latest
           fallback: latest
 
 
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
 
 
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
@@ -107,7 +107,7 @@ jobs:
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
 
 
       - name: Build and push immich-web release
       - name: Build and push immich-web release
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./web
           context: ./web
           file: ./web/Dockerfile
           file: ./web/Dockerfile
@@ -134,11 +134,11 @@ jobs:
           fallback: latest
           fallback: latest
 
 
       - name: Set up QEMU
       - 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
       - name: Set up Docker Buildx
         id: buildx
         id: buildx
-        uses: docker/setup-buildx-action@v2.0.0
+        uses: docker/setup-buildx-action@v2.1.0
 
 
       - name: Login to Docker Hub
       - name: Login to Docker Hub
         uses: docker/login-action@v2
         uses: docker/login-action@v2
@@ -147,7 +147,7 @@ jobs:
           password: ${{ secrets.DOCKERHUB_TOKEN }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
 
 
       - name: Build and push immich-proxy release
       - name: Build and push immich-proxy release
-        uses: docker/build-push-action@v3.1.1
+        uses: docker/build-push-action@v3.2.0
         with:
         with:
           context: ./nginx
           context: ./nginx
           file: ./nginx/Dockerfile
           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
       - name: Checkout code
         uses: actions/checkout@v3
         uses: actions/checkout@v3
 
 
-      - name: Run Immich Server 2E2 Test
+      - name: Run Immich Server E2E Test
         run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
         run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
 
 
   server-unit-tests:
   server-unit-tests:

+ 10 - 5
README.md

@@ -46,13 +46,14 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
 - [Installation](#installation)
 - [Installation](#installation)
 - [Update](#update)
 - [Update](#update)
 - [Mobile App](#mobile-app)
 - [Mobile App](#mobile-app)
+- [App Beta Invitation links](#App-Beta-release-channel)
 - [Development](#development)
 - [Development](#development)
 - [Support](#support)
 - [Support](#support)
 - [Known Issues](#known-issues)
 - [Known Issues](#known-issues)
 
 
 # Features 
 # 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 |
 | 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).
 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:
 - 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/> |
 | <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/>  
   <br/>  
 
 
 # Development
 # Development

+ 4 - 1
docker/.env.example

@@ -38,7 +38,10 @@ LOG_LEVEL=simple
 # JWT SECRET
 # 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
 # Reverse Geocoding

+ 18 - 13
install.sh

@@ -18,33 +18,37 @@ get_release_version() {
 create_immich_directory() {
 create_immich_directory() {
   echo "Creating Immich directory..."
   echo "Creating Immich directory..."
   mkdir -p ./immich-app/immich-data
   mkdir -p ./immich-app/immich-data
+  cd ./immich-app
 }
 }
 
 
 download_docker_compose_file() {
 download_docker_compose_file() {
   echo "Downloading docker-compose.yml..."
   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() {
 download_dot_env_file() {
   echo "Downloading .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
   if [[ "$OSTYPE" == "darwin"* ]]; then
-    sed -i '' "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
+    sed -i '' "s|$1=.*|$1=$2|" ./.env
   else
   else
-    sed -i "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
+    sed -i "s|$1=.*|$1=$2|" ./.env
   fi
   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() {
 start_docker_compose() {
@@ -88,4 +92,5 @@ create_immich_directory
 download_docker_compose_file
 download_docker_compose_file
 download_dot_env_file
 download_dot_env_file
 populate_upload_location
 populate_upload_location
+generate_jwt_secret
 start_docker_compose
 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">
     <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
       <!-- Specifies an Android theme to apply to this Activity as soon as
       <!-- Specifies an Android theme to apply to this Activity as soon as
                  the Android process has started. This theme is visible to the user
                  the Android process has started. This theme is visible to the user
@@ -12,12 +12,15 @@
       </intent-filter>
       </intent-filter>
 
 
     </activity>
     </activity>
-    <service android:name=".AppClearedService" android:stopWithTask="false" />
     <!-- Don't delete the meta-data below.
     <!-- Don't delete the meta-data below.
              This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
              This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
     <meta-data android:name="flutterEmbedding" android:value="2" />
     <meta-data android:name="flutterEmbedding" android:value="2" />
-
-
+    <!-- Disables default WorkManager initialization to use our custom initialization -->
+    <provider
+        android:name="androidx.startup.InitializationProvider"
+        android:authorities="${applicationId}.androidx-startup"
+        tools:node="remove">
+    </provider>
   </application>
   </application>
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

+ 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`
  * Android plugin for Dart `BackgroundService`
  *
  *
  * Receives messages/method calls from the foreground Dart side to manage
  * 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 {
 class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
 
 
@@ -38,14 +38,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
 
 
     override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
     override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
         val ctx = context!!
         val ctx = context!!
-        when(call.method) {
+        when (call.method) {
             "enable" -> {
             "enable" -> {
                 val args = call.arguments<ArrayList<*>>()!!
                 val args = call.arguments<ArrayList<*>>()!!
                 ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
                 ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
-                    .edit()
-                    .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)
                 ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
                 result.success(true)
                 result.success(true)
             }
             }
@@ -54,7 +55,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
                 val requireUnmeteredNetwork = args.get(0) as Boolean
                 val requireUnmeteredNetwork = args.get(0) as Boolean
                 val requireCharging = args.get(1) as Boolean
                 val requireCharging = args.get(1) as Boolean
                 ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
                 ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
-                result.success(true)   
+                result.success(true)
             }
             }
             "disable" -> {
             "disable" -> {
                 ContentObserverWorker.disable(ctx)
                 ContentObserverWorker.disable(ctx)

+ 57 - 37
mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt

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

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

+ 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.os.Bundle
 import android.content.Intent
 import android.content.Intent
 
 
-class MainActivity: FlutterActivity() {
+class MainActivity : FlutterActivity() {
 
 
     override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
     override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
         super.configureFlutterEngine(flutterEngine)
         super.configureFlutterEngine(flutterEngine)
-        flutterEngine.getPlugins().add(BackgroundServicePlugin())
-    }
-
-    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)
 default_platform(:android)
 
 
 platform :android do
 platform :android do
-  desc "Build Android"
-  lane :build do
+  desc "Build Android and Release Testing"
+  lane :beta do
     gradle(
     gradle(
       task: 'bundle', 
       task: 'bundle', 
       build_type: 'Release',
       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
   end
 
 
   desc "Build and Release Android"
   desc "Build and Release Android"
@@ -30,8 +35,8 @@ platform :android do
       task: 'bundle', 
       task: 'bundle', 
       build_type: 'Release',
       build_type: 'Release',
       properties: {
       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')
     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
 
 
-### android build
+### android beta
 
 
 ```sh
 ```sh
-[bundle exec] fastlane android build
+[bundle exec] fastlane android beta
 ```
 ```
 
 
-Build Android
+Build Android and Release Testing
 
 
 ### android release
 ### 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>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="99.857291">
+      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
         
         
       </testcase>
       </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>
       </testcase>
     
     

+ 4 - 0
mobile/assets/i18n/en-US.json

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

+ 3 - 3
mobile/ios/Runner.xcodeproj/project.pbxproj

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

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

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

+ 1 - 1
mobile/ios/fastlane/Fastfile

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

+ 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>
     
     
       
       
-      <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>
     
     
       
       
-      <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>
     
     
       
       
-      <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>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="4: build_app" time="62.328417">
+      <testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
         
         
       </testcase>
       </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>
       </testcase>
     
     

+ 101 - 23
mobile/lib/modules/backup/background_service/background.service.dart

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

+ 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:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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';
 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:collection';
-import 'dart:math';
 
 
 import 'package:collection/collection.dart';
 import 'package:collection/collection.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:easy_localization/easy_localization.dart';
-import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.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:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:scrollable_positioned_list/scrollable_positioned_list.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';
 import 'draggable_scrollbar_custom.dart';
 
 
 typedef ImmichAssetGridSelectionListener = void Function(
 typedef ImmichAssetGridSelectionListener = void Function(
-    bool, Set<AssetResponseDto>);
+  bool,
+  Set<AssetResponseDto>,
+);
 
 
 class ImmichAssetGridState extends State<ImmichAssetGrid> {
 class ImmichAssetGridState extends State<ImmichAssetGrid> {
   final ItemScrollController _itemScrollController = ItemScrollController();
   final ItemScrollController _itemScrollController = ItemScrollController();
@@ -23,7 +22,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
       ItemPositionsListener.create();
       ItemPositionsListener.create();
 
 
   bool _scrolling = false;
   bool _scrolling = false;
-  Set<String> _selectedAssets = HashSet();
+  final Set<String> _selectedAssets = HashSet();
 
 
   List<AssetResponseDto> get _assets {
   List<AssetResponseDto> get _assets {
     return widget.renderList
     return widget.renderList
@@ -86,7 +85,9 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   }
   }
 
 
   Widget _buildThumbnailOrPlaceholder(
   Widget _buildThumbnailOrPlaceholder(
-      AssetResponseDto asset, bool placeholder) {
+    AssetResponseDto asset,
+    bool placeholder,
+  ) {
     if (placeholder) {
     if (placeholder) {
       return const DecoratedBox(
       return const DecoratedBox(
         decoration: BoxDecoration(color: Colors.grey),
         decoration: BoxDecoration(color: Colors.grey),
@@ -104,7 +105,10 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   }
   }
 
 
   Widget _buildAssetRow(
   Widget _buildAssetRow(
-      BuildContext context, RenderAssetGridRow row, bool scrolling) {
+    BuildContext context,
+    RenderAssetGridRow row,
+    bool scrolling,
+  ) {
     double size = _getItemSize(context);
     double size = _getItemSize(context);
 
 
     return Row(
     return Row(
@@ -117,7 +121,9 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
           width: size,
           width: size,
           height: size,
           height: size,
           margin: EdgeInsets.only(
           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),
           child: _buildThumbnailOrPlaceholder(asset, scrolling),
         );
         );
       }).toList(),
       }).toList(),
@@ -125,7 +131,10 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   }
   }
 
 
   Widget _buildTitle(
   Widget _buildTitle(
-      BuildContext context, String title, List<AssetResponseDto> assets) {
+    BuildContext context,
+    String title,
+    List<AssetResponseDto> assets,
+  ) {
     return DailyTitleText(
     return DailyTitleText(
       isoDate: title,
       isoDate: title,
       multiselectEnabled: widget.selectionActive,
       multiselectEnabled: widget.selectionActive,
@@ -186,7 +195,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   }
   }
 
 
   Widget _buildAssetGrid() {
   Widget _buildAssetGrid() {
-    final useDragScrolling = _assets.length > 100;
+    final useDragScrolling = _assets.length >= 20;
 
 
     void dragScrolling(bool active) {
     void dragScrolling(bool active) {
       setState(() {
       setState(() {
@@ -218,7 +227,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
     );
     );
   }
   }
 
 
-
   @override
   @override
   void didUpdateWidget(ImmichAssetGrid oldWidget) {
   void didUpdateWidget(ImmichAssetGrid oldWidget) {
     super.didUpdateWidget(oldWidget);
     super.didUpdateWidget(oldWidget);
@@ -248,14 +256,14 @@ class ImmichAssetGrid extends StatefulWidget {
   final ImmichAssetGridSelectionListener? listener;
   final ImmichAssetGridSelectionListener? listener;
   final bool selectionActive;
   final bool selectionActive;
 
 
-  ImmichAssetGrid({
+  const ImmichAssetGrid({
     super.key,
     super.key,
     required this.renderList,
     required this.renderList,
     required this.assetsPerRow,
     required this.assetsPerRow,
     required this.showStorageIndicator,
     required this.showStorageIndicator,
     this.listener,
     this.listener,
     this.margin = 5.0,
     this.margin = 5.0,
-    this.selectionActive = false
+    this.selectionActive = false,
   });
   });
 
 
   @override
   @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"
   themeMode<String>("themeMode", "system"), // "light","dark","system"
   tilesPerRow<int>("tilesPerRow", 4),
   tilesPerRow<int>("tilesPerRow", 4),
   uploadErrorNotificationGracePeriod<int>(
   uploadErrorNotificationGracePeriod<int>(
-      "uploadErrorNotificationGracePeriod", 2),
+    "uploadErrorNotificationGracePeriod",
+    2,
+  ),
+  backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
+  backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
   storageIndicator<bool>("storageIndicator", true),
   storageIndicator<bool>("storageIndicator", true),
   thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
   thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
   imageCacheSize<int>("imageCacheSize", 350),
   imageCacheSize<int>("imageCacheSize", 350),

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

+ 14 - 0
mobile/openapi/.openapi-generator/FILES

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

+ 9 - 0
mobile/openapi/README.md

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

+ 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
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
-**id** | **String** |  | [optional] 
+**id** | **int** |  | [optional] 
+**fileSizeInByte** | **int** |  | [optional] 
 **make** | **String** |  | [optional] 
 **make** | **String** |  | [optional] 
 **model** | **String** |  | [optional] 
 **model** | **String** |  | [optional] 
 **imageName** | **String** |  | [optional] 
 **imageName** | **String** |  | [optional] 
 **exifImageWidth** | **num** |  | [optional] 
 **exifImageWidth** | **num** |  | [optional] 
 **exifImageHeight** | **num** |  | [optional] 
 **exifImageHeight** | **num** |  | [optional] 
-**fileSizeInByte** | **num** |  | [optional] 
 **orientation** | **String** |  | [optional] 
 **orientation** | **String** |  | [optional] 
 **dateTimeOriginal** | [**DateTime**](DateTime.md) |  | [optional] 
 **dateTimeOriginal** | [**DateTime**](DateTime.md) |  | [optional] 
 **modifyDate** | [**DateTime**](DateTime.md) |  | [optional] 
 **modifyDate** | [**DateTime**](DateTime.md) |  | [optional] 

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

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

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

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

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

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

+ 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
 description: Immich - selfhosted backup media file on mobile phone
 
 
 publish_to: "none"
 publish_to: "none"
-version: 1.30.0+46
+version: 1.31.0+49
 
 
 environment:
 environment:
   sdk: ">=2.17.0 <3.0.0"
   sdk: ">=2.17.0 <3.0.0"

+ 1 - 1
server/.dockerignore

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

+ 2 - 0
server/Dockerfile

@@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
 
 
 RUN npm prune --production
 RUN npm prune --production
 
 
+VOLUME /usr/src/app/upload
+
 EXPOSE 3001
 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(),
       getAssetByTimeBucket: jest.fn(),
       getAssetByChecksum: jest.fn(),
       getAssetByChecksum: jest.fn(),
       getAssetCountByUserId: jest.fn(),
       getAssetCountByUserId: jest.fn(),
+      getAssetWithNoEXIF: jest.fn(),
+      getAssetWithNoThumbnail: jest.fn(),
+      getAssetWithNoSmartInfo: jest.fn(),
     };
     };
 
 
     sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
     sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);

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

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

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

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

+ 3 - 0
server/apps/immich/src/api-v1/asset/asset.service.spec.ts

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

+ 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 {
 export class GetAssetThumbnailDto {
   @IsOptional()
   @IsOptional()
   @ApiProperty({
   @ApiProperty({
+    type: String,
     enum: GetAssetThumbnailFormatEnum,
     enum: GetAssetThumbnailFormatEnum,
     default: GetAssetThumbnailFormatEnum.WEBP,
     default: GetAssetThumbnailFormatEnum.WEBP,
     required: false,
     required: false,
     enumName: 'ThumbnailFormat',
     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 { ExifEntity } from '@app/database/entities/exif.entity';
+import { ApiProperty } from '@nestjs/swagger';
 
 
 export class ExifResponseDto {
 export class ExifResponseDto {
-  id?: string | null = null;
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  id?: number | null = null;
   make?: string | null = null;
   make?: string | null = null;
   model?: string | null = null;
   model?: string | null = null;
   imageName?: string | null = null;
   imageName?: string | null = null;
   exifImageWidth?: number | null = null;
   exifImageWidth?: number | null = null;
   exifImageHeight?: number | null = null;
   exifImageHeight?: number | null = null;
+
+  @ApiProperty({ type: 'integer', format: 'int64' })
   fileSizeInByte?: number | null = null;
   fileSizeInByte?: number | null = null;
   orientation?: string | null = null;
   orientation?: string | null = null;
   dateTimeOriginal?: Date | null = null;
   dateTimeOriginal?: Date | null = null;
@@ -25,13 +29,13 @@ export class ExifResponseDto {
 
 
 export function mapExif(entity: ExifEntity): ExifResponseDto {
 export function mapExif(entity: ExifEntity): ExifResponseDto {
   return {
   return {
-    id: entity.id,
+    id: parseInt(entity.id),
     make: entity.make,
     make: entity.make,
     model: entity.model,
     model: entity.model,
     imageName: entity.imageName,
     imageName: entity.imageName,
     exifImageWidth: entity.exifImageWidth,
     exifImageWidth: entity.exifImageWidth,
     exifImageHeight: entity.exifImageHeight,
     exifImageHeight: entity.exifImageHeight,
-    fileSizeInByte: entity.fileSizeInByte,
+    fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
     orientation: entity.orientation,
     orientation: entity.orientation,
     dateTimeOriginal: entity.dateTimeOriginal,
     dateTimeOriginal: entity.dateTimeOriginal,
     modifyDate: entity.modifyDate,
     modifyDate: entity.modifyDate,

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

+ 3 - 0
server/apps/immich/src/app.module.ts

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

+ 2 - 2
server/apps/immich/src/constants/server_version.constant.ts

@@ -10,7 +10,7 @@ export interface IServerVersion {
 
 
 export const serverVersion: IServerVersion = {
 export const serverVersion: IServerVersion = {
   major: 1,
   major: 1,
-  minor: 30,
+  minor: 31,
   patch: 0,
   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 { TypeOrmModule } from '@nestjs/typeorm';
 import { AssetEntity } from '@app/database/entities/asset.entity';
 import { AssetEntity } from '@app/database/entities/asset.entity';
 import { ScheduleTasksService } from './schedule-tasks.service';
 import { ScheduleTasksService } from './schedule-tasks.service';
-import {
-  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';
 import { ExifEntity } from '@app/database/entities/exif.entity';
 
 
 @Module({
 @Module({
   imports: [
   imports: [
     TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
     TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
     BullModule.registerQueue({
     BullModule.registerQueue({
-      name: videoConversionQueueName,
+      name: QueueNameEnum.VIDEO_CONVERSION,
       defaultJobOptions: {
       defaultJobOptions: {
         attempts: 3,
         attempts: 3,
         removeOnComplete: true,
         removeOnComplete: true,
@@ -22,7 +18,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
       },
       },
     }),
     }),
     BullModule.registerQueue({
     BullModule.registerQueue({
-      name: thumbnailGeneratorQueueName,
+      name: QueueNameEnum.THUMBNAIL_GENERATION,
       defaultJobOptions: {
       defaultJobOptions: {
         attempts: 3,
         attempts: 3,
         removeOnComplete: true,
         removeOnComplete: true,
@@ -31,7 +27,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
     }),
     }),
 
 
     BullModule.registerQueue({
     BullModule.registerQueue({
-      name: metadataExtractionQueueName,
+      name: QueueNameEnum.METADATA_EXTRACTION,
       defaultJobOptions: {
       defaultJobOptions: {
         attempts: 3,
         attempts: 3,
         removeOnComplete: true,
         removeOnComplete: true,

+ 9 - 11
server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts

@@ -12,11 +12,9 @@ import {
   generateWEBPThumbnailProcessorName,
   generateWEBPThumbnailProcessorName,
   IMetadataExtractionJob,
   IMetadataExtractionJob,
   IVideoTranscodeJob,
   IVideoTranscodeJob,
-  metadataExtractionQueueName,
   mp4ConversionProcessorName,
   mp4ConversionProcessorName,
+  QueueNameEnum,
   reverseGeocodingProcessorName,
   reverseGeocodingProcessorName,
-  thumbnailGeneratorQueueName,
-  videoConversionQueueName,
   videoMetadataExtractionProcessorName,
   videoMetadataExtractionProcessorName,
 } from '@app/job';
 } from '@app/job';
 import { ConfigService } from '@nestjs/config';
 import { ConfigService } from '@nestjs/config';
@@ -30,13 +28,13 @@ export class ScheduleTasksService {
     @InjectRepository(ExifEntity)
     @InjectRepository(ExifEntity)
     private exifRepository: Repository<ExifEntity>,
     private exifRepository: Repository<ExifEntity>,
 
 
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue,
     private thumbnailGeneratorQueue: Queue,
 
 
-    @InjectQueue(videoConversionQueueName)
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
 
 
-    @InjectQueue(metadataExtractionQueueName)
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
 
 
     private configService: ConfigService,
     private configService: ConfigService,
@@ -108,11 +106,11 @@ export class ScheduleTasksService {
 
 
   @Cron(CronExpression.EVERY_DAY_AT_3AM)
   @Cron(CronExpression.EVERY_DAY_AT_3AM)
   async extractExif() {
   async extractExif() {
-    const exifAssets = await this.assetRepository.find({
-      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) {
     for (const asset of exifAssets) {
       if (asset.type === AssetType.VIDEO) {
       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 { ExifEntity } from '@app/database/entities/exif.entity';
 import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import { UserEntity } from '@app/database/entities/user.entity';
 import { UserEntity } from '@app/database/entities/user.entity';
-import {
-  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 { BullModule } from '@nestjs/bull';
 import { Module } from '@nestjs/common';
 import { Module } from '@nestjs/common';
 import { ConfigModule, ConfigService } from '@nestjs/config';
 import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -19,6 +13,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
 import { MicroservicesService } from './microservices.service';
 import { MicroservicesService } from './microservices.service';
 import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
 import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
 import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
 import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
+import { MachineLearningProcessor } from './processors/machine-learning.processor';
 import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
 import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
 import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
 import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
 import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
 import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
@@ -42,7 +37,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
     }),
     }),
     BullModule.registerQueue(
     BullModule.registerQueue(
       {
       {
-        name: thumbnailGeneratorQueueName,
+        name: QueueNameEnum.THUMBNAIL_GENERATION,
         defaultJobOptions: {
         defaultJobOptions: {
           attempts: 3,
           attempts: 3,
           removeOnComplete: true,
           removeOnComplete: true,
@@ -50,7 +45,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
         },
       },
       },
       {
       {
-        name: assetUploadedQueueName,
+        name: QueueNameEnum.ASSET_UPLOADED,
         defaultJobOptions: {
         defaultJobOptions: {
           attempts: 3,
           attempts: 3,
           removeOnComplete: true,
           removeOnComplete: true,
@@ -58,7 +53,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
         },
       },
       },
       {
       {
-        name: metadataExtractionQueueName,
+        name: QueueNameEnum.METADATA_EXTRACTION,
         defaultJobOptions: {
         defaultJobOptions: {
           attempts: 3,
           attempts: 3,
           removeOnComplete: true,
           removeOnComplete: true,
@@ -66,7 +61,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
         },
       },
       },
       {
       {
-        name: videoConversionQueueName,
+        name: QueueNameEnum.VIDEO_CONVERSION,
         defaultJobOptions: {
         defaultJobOptions: {
           attempts: 3,
           attempts: 3,
           removeOnComplete: true,
           removeOnComplete: true,
@@ -74,7 +69,15 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
         },
       },
       },
       {
       {
-        name: generateChecksumQueueName,
+        name: QueueNameEnum.CHECKSUM_GENERATION,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+      {
+        name: QueueNameEnum.MACHINE_LEARNING,
         defaultJobOptions: {
         defaultJobOptions: {
           attempts: 3,
           attempts: 3,
           removeOnComplete: true,
           removeOnComplete: true,
@@ -92,6 +95,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
     MetadataExtractionProcessor,
     MetadataExtractionProcessor,
     VideoTranscodeProcessor,
     VideoTranscodeProcessor,
     GenerateChecksumProcessor,
     GenerateChecksumProcessor,
+    MachineLearningProcessor,
     ConfigService,
     ConfigService,
   ],
   ],
   exports: [],
   exports: [],

+ 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 { InjectQueue } from '@nestjs/bull';
 import { Injectable, OnModuleInit } from '@nestjs/common';
 import { Injectable, OnModuleInit } from '@nestjs/common';
 import { Queue } from 'bull';
 import { Queue } from 'bull';
@@ -6,14 +6,18 @@ import { randomUUID } from 'node:crypto';
 
 
 @Injectable()
 @Injectable()
 export class MicroservicesService implements OnModuleInit {
 export class MicroservicesService implements OnModuleInit {
-  constructor (
-    @InjectQueue(generateChecksumQueueName)
+  constructor(
+    @InjectQueue(QueueNameEnum.CHECKSUM_GENERATION)
     private generateChecksumQueue: Queue,
     private generateChecksumQueue: Queue,
   ) {}
   ) {}
 
 
   async onModuleInit() {
   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,
   IMetadataExtractionJob,
   IThumbnailGenerationJob,
   IThumbnailGenerationJob,
   IVideoTranscodeJob,
   IVideoTranscodeJob,
-  assetUploadedQueueName,
-  metadataExtractionQueueName,
-  thumbnailGeneratorQueueName,
-  videoConversionQueueName,
   assetUploadedProcessorName,
   assetUploadedProcessorName,
   exifExtractionProcessorName,
   exifExtractionProcessorName,
   generateJPEGThumbnailProcessorName,
   generateJPEGThumbnailProcessorName,
   mp4ConversionProcessorName,
   mp4ConversionProcessorName,
   videoMetadataExtractionProcessorName,
   videoMetadataExtractionProcessorName,
+  QueueNameEnum,
 } from '@app/job';
 } from '@app/job';
 import { InjectQueue, Process, Processor } from '@nestjs/bull';
 import { InjectQueue, Process, Processor } from '@nestjs/bull';
 import { Job, Queue } from 'bull';
 import { Job, Queue } from 'bull';
 import { randomUUID } from 'crypto';
 import { randomUUID } from 'crypto';
 
 
-@Processor(assetUploadedQueueName)
+@Processor(QueueNameEnum.ASSET_UPLOADED)
 export class AssetUploadedProcessor {
 export class AssetUploadedProcessor {
   constructor(
   constructor(
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
     private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
 
 
-    @InjectQueue(metadataExtractionQueueName)
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
 
 
-    @InjectQueue(videoConversionQueueName)
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
   ) {}
   ) {}
 
 

+ 3 - 3
server/apps/microservices/src/processors/generate-checksum.processor.ts

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

+ 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 { ImmichLogLevel } from '@app/common/constants/log-level.constant';
 import { AssetEntity } from '@app/database/entities/asset.entity';
 import { AssetEntity } from '@app/database/entities/asset.entity';
 import { ExifEntity } from '@app/database/entities/exif.entity';
 import { ExifEntity } from '@app/database/entities/exif.entity';
-import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import {
 import {
   IExifExtractionProcessor,
   IExifExtractionProcessor,
   IVideoLengthExtractionProcessor,
   IVideoLengthExtractionProcessor,
   exifExtractionProcessorName,
   exifExtractionProcessorName,
-  imageTaggingProcessorName,
-  objectDetectionProcessorName,
   videoMetadataExtractionProcessorName,
   videoMetadataExtractionProcessorName,
-  metadataExtractionQueueName,
   reverseGeocodingProcessorName,
   reverseGeocodingProcessorName,
   IReverseGeocodingProcessor,
   IReverseGeocodingProcessor,
+  QueueNameEnum,
 } from '@app/job';
 } from '@app/job';
 import { Process, Processor } from '@nestjs/bull';
 import { Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
 import { Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { ConfigService } from '@nestjs/config';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
-import axios from 'axios';
 import { Job } from 'bull';
 import { Job } from 'bull';
 import exifr from 'exifr';
 import exifr from 'exifr';
 import ffmpeg from 'fluent-ffmpeg';
 import ffmpeg from 'fluent-ffmpeg';
@@ -43,20 +39,20 @@ function geocoderLookup(points: { latitude: number; longitude: number }[]) {
     // eslint-disable-next-line @typescript-eslint/ban-ts-comment
     // eslint-disable-next-line @typescript-eslint/ban-ts-comment
     // @ts-ignore
     // @ts-ignore
     geocoder.lookUp(points, 1, (err, addresses) => {
     geocoder.lookUp(points, 1, (err, addresses) => {
-      resolve(addresses[0][0]);
+      resolve(addresses[0][0] as GeoData);
     });
     });
   });
   });
 }
 }
 
 
 const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
 const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
 
 
-export interface AdminCode {
+export type AdminCode = {
   name: string;
   name: string;
   asciiName: string;
   asciiName: string;
   geoNameId: string;
   geoNameId: string;
-}
+};
 
 
-export interface GeoData {
+export type GeoData = {
   geoNameId: string;
   geoNameId: string;
   name: string;
   name: string;
   asciiName: string;
   asciiName: string;
@@ -67,8 +63,8 @@ export interface GeoData {
   featureCode: string;
   featureCode: string;
   countryCode: string;
   countryCode: string;
   cc2?: any;
   cc2?: any;
-  admin1Code?: AdminCode;
-  admin2Code?: AdminCode;
+  admin1Code?: AdminCode | string;
+  admin2Code?: AdminCode | string;
   admin3Code?: any;
   admin3Code?: any;
   admin4Code?: any;
   admin4Code?: any;
   population: string;
   population: string;
@@ -77,9 +73,9 @@ export interface GeoData {
   timezone: string;
   timezone: string;
   modificationDate: string;
   modificationDate: string;
   distance: number;
   distance: number;
-}
+};
 
 
-@Processor(metadataExtractionQueueName)
+@Processor(QueueNameEnum.METADATA_EXTRACTION)
 export class MetadataExtractionProcessor {
 export class MetadataExtractionProcessor {
   private isGeocodeInitialized = false;
   private isGeocodeInitialized = false;
   private logLevel: ImmichLogLevel;
   private logLevel: ImmichLogLevel;
@@ -91,12 +87,9 @@ export class MetadataExtractionProcessor {
     @InjectRepository(ExifEntity)
     @InjectRepository(ExifEntity)
     private exifRepository: Repository<ExifEntity>,
     private exifRepository: Repository<ExifEntity>,
 
 
-    @InjectRepository(SmartInfoEntity)
-    private smartInfoRepository: Repository<SmartInfoEntity>,
-
     private configService: ConfigService,
     private configService: ConfigService,
   ) {
   ) {
-    if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
+    if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
       Logger.log('Initialising Reverse Geocoding');
       Logger.log('Initialising Reverse Geocoding');
       geocoderInit({
       geocoderInit({
         // eslint-disable-next-line @typescript-eslint/ban-ts-comment
         // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -109,7 +102,8 @@ export class MetadataExtractionProcessor {
           alternateNames: false,
           alternateNames: false,
         },
         },
         countries: [],
         countries: [],
-        dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'),
+        dumpDirectory:
+          configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || process.cwd() + '/.reverse-geocoding-dump/',
       }).then(() => {
       }).then(() => {
         this.isGeocodeInitialized = true;
         this.isGeocodeInitialized = true;
         Logger.log('Reverse Geocoding Initialised');
         Logger.log('Reverse Geocoding Initialised');
@@ -129,10 +123,22 @@ export class MetadataExtractionProcessor {
     const city = geoCodeInfo.name;
     const city = geoCodeInfo.name;
 
 
     let state = '';
     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 };
     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 })
   @Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
   async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
   async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
     const { asset, fileName } = job.data;
     const { asset, fileName } = job.data;

+ 20 - 13
server/apps/microservices/src/processors/thumbnail.processor.ts

@@ -5,11 +5,9 @@ import {
   WebpGeneratorProcessor,
   WebpGeneratorProcessor,
   generateJPEGThumbnailProcessorName,
   generateJPEGThumbnailProcessorName,
   generateWEBPThumbnailProcessorName,
   generateWEBPThumbnailProcessorName,
-  imageTaggingProcessorName,
-  objectDetectionProcessorName,
-  metadataExtractionQueueName,
-  thumbnailGeneratorQueueName,
   JpegGeneratorProcessor,
   JpegGeneratorProcessor,
+  QueueNameEnum,
+  MachineLearningJobNameEnum,
 } from '@app/job';
 } from '@app/job';
 import { InjectQueue, Process, Processor } from '@nestjs/bull';
 import { InjectQueue, Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
 import { Logger } from '@nestjs/common';
@@ -25,8 +23,9 @@ import sharp from 'sharp';
 import { Repository } from 'typeorm/repository/Repository';
 import { Repository } from 'typeorm/repository/Repository';
 import { join } from 'path';
 import { join } from 'path';
 import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
 import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
+import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
 
 
-@Processor(thumbnailGeneratorQueueName)
+@Processor(QueueNameEnum.THUMBNAIL_GENERATION)
 export class ThumbnailGeneratorProcessor {
 export class ThumbnailGeneratorProcessor {
   private logLevel: ImmichLogLevel;
   private logLevel: ImmichLogLevel;
 
 
@@ -34,13 +33,13 @@ export class ThumbnailGeneratorProcessor {
     @InjectRepository(AssetEntity)
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
     private assetRepository: Repository<AssetEntity>,
 
 
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue,
     private thumbnailGeneratorQueue: Queue,
 
 
     private wsCommunicationGateway: CommunicationGateway,
     private wsCommunicationGateway: CommunicationGateway,
 
 
-    @InjectQueue(metadataExtractionQueueName)
-    private metadataExtractionQueue: Queue,
+    @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
+    private machineLearningQueue: Queue<IMachineLearningJob>,
 
 
     private configService: ConfigService,
     private configService: ConfigService,
   ) {
   ) {
@@ -62,7 +61,7 @@ export class ThumbnailGeneratorProcessor {
 
 
     const temp = asset.originalPath.split('/');
     const temp = asset.originalPath.split('/');
     const originalFilename = temp[temp.length - 1].split('.')[0];
     const originalFilename = temp[temp.length - 1].split('.')[0];
-    const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
+    const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
 
 
     if (asset.type == AssetType.IMAGE) {
     if (asset.type == AssetType.IMAGE) {
       try {
       try {
@@ -80,8 +79,12 @@ export class ThumbnailGeneratorProcessor {
       asset.resizePath = jpegThumbnailPath;
       asset.resizePath = jpegThumbnailPath;
 
 
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
-      await this.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)));
       this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
     }
     }
 
 
@@ -110,8 +113,12 @@ export class ThumbnailGeneratorProcessor {
       asset.resizePath = jpegThumbnailPath;
       asset.resizePath = jpegThumbnailPath;
 
 
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
-      await this.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)));
       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 { APP_UPLOAD_LOCATION } from '@app/common/constants';
 import { AssetEntity } from '@app/database/entities/asset.entity';
 import { AssetEntity } from '@app/database/entities/asset.entity';
+import { QueueNameEnum } from '@app/job';
 import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
 import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
-import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
 import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
 import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
 import { Process, Processor } from '@nestjs/bull';
 import { Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
 import { Logger } from '@nestjs/common';
@@ -11,7 +11,7 @@ import ffmpeg from 'fluent-ffmpeg';
 import { existsSync, mkdirSync } from 'fs';
 import { existsSync, mkdirSync } from 'fs';
 import { Repository } from 'typeorm';
 import { Repository } from 'typeorm';
 
 
-@Processor(videoConversionQueueName)
+@Processor(QueueNameEnum.VIDEO_CONVERSION)
 export class VideoTranscodeProcessor {
 export class VideoTranscodeProcessor {
   constructor(
   constructor(
     @InjectRepository(AssetEntity)
     @InjectRepository(AssetEntity)

File diff suppressed because 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 { ConfigModuleOptions } from '@nestjs/config';
 import Joi from 'joi';
 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 = {
 export const immichAppConfig: ConfigModuleOptions = {
   envFilePath: '.env',
   envFilePath: '.env',
@@ -9,7 +24,7 @@ export const immichAppConfig: ConfigModuleOptions = {
     DB_USERNAME: Joi.string().required(),
     DB_USERNAME: Joi.string().required(),
     DB_PASSWORD: Joi.string().required(),
     DB_PASSWORD: Joi.string().required(),
     DB_DATABASE_NAME: 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),
     DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
     REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
     REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
     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 exifExtractionProcessorName = 'exif-extraction';
 export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
 export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
 export const reverseGeocodingProcessorName = 'reverse-geocoding';
 export const reverseGeocodingProcessorName = 'reverse-geocoding';
-export const objectDetectionProcessorName = 'detect-object';
-export const imageTaggingProcessorName = 'tag-image';
+
+/**
+ * Machine learning Queue Jobs
+ */
+
+export enum MachineLearningJobNameEnum {
+  OBJECT_DETECTION = 'detect-object',
+  IMAGE_TAGGING = 'tag-image',
+}

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