浏览代码

merged thumbhash_mobile with upstream

covalent 1 年之前
父节点
当前提交
b76b18fd2f
共有 100 个文件被更改,包括 2301 次插入1356 次删除
  1. 2 2
      .github/workflows/docker-cleanup.yml
  2. 87 3
      .github/workflows/docker.yml
  3. 1 0
      .github/workflows/prepare-release.yml
  4. 2 2
      Makefile
  5. 1 0
      README.md
  6. 1 0
      README_ca_ES.md
  7. 108 0
      README_es_ES.md
  8. 2 0
      README_tr_TR.md
  9. 2 0
      README_zh_CN.md
  10. 0 0
      cli/asdf
  11. 0 8
      cli/jest.config.ts
  12. 79 143
      cli/package-lock.json
  13. 28 9
      cli/package.json
  14. 166 229
      cli/src/api/open-api/api.ts
  15. 1 1
      cli/src/api/open-api/base.ts
  16. 1 1
      cli/src/api/open-api/common.ts
  17. 1 1
      cli/src/api/open-api/configuration.ts
  18. 1 1
      cli/src/api/open-api/index.ts
  19. 2 0
      cli/src/commands/upload.ts
  20. 1 0
      cli/src/cores/dto/upload-options-dto.ts
  21. 3 1
      cli/src/index.ts
  22. 0 7
      cli/test/tsconfig.json
  23. 4 0
      cli/tsconfig.build.json
  24. 3 2
      docker/docker-compose.dev.yml
  25. 116 0
      docker/docker-compose.prod.yml
  26. 0 1
      docker/docker-compose.test.yml
  27. 3 3
      docker/docker-compose.yml
  28. 23 0
      docker/hwaccel.yml
  29. 1 1
      docs/blog/2023/06-24/update.mdx
  30. 二进制
      docs/blog/2023/07-29/images/web-shortcuts-panel.png
  31. 151 0
      docs/blog/2023/07-29/update.mdx
  32. 4 0
      docs/docs/administration/backup-and-restore.md
  33. 3 1
      docs/docs/administration/server-commands.md
  34. 89 20
      docs/docs/developer/architecture.md
  35. 22 0
      docs/docs/developer/directories.md
  36. 60 0
      docs/docs/features/hardware-transcoding.md
  37. 13 0
      docs/docs/install/docker-compose.md
  38. 21 0
      docs/docs/install/environment-variables.md
  39. 1 1
      docs/docs/overview/logo.md
  40. 2 2
      docs/docs/partials/_storage-template.md
  41. 4 1
      machine-learning/Dockerfile
  42. 21 0
      machine-learning/README_es_ES.md
  43. 445 383
      machine-learning/poetry.lock
  44. 3 3
      machine-learning/pyproject.toml
  45. 1 1
      mobile/android/.gitignore
  46. 2 2
      mobile/android/fastlane/Fastfile
  47. 3 3
      mobile/android/fastlane/report.xml
  48. 3 2
      mobile/assets/i18n/en-US.json
  49. 3 1
      mobile/ios/.gitignore
  50. 1 1
      mobile/ios/Podfile.lock
  51. 3 3
      mobile/ios/Runner.xcodeproj/project.pbxproj
  52. 2 2
      mobile/ios/Runner/Info.plist
  53. 1 1
      mobile/ios/fastlane/Fastfile
  54. 6 6
      mobile/ios/fastlane/report.xml
  55. 3 0
      mobile/lib/main.dart
  56. 49 0
      mobile/lib/modules/album/models/add_asset_response.model.dart
  57. 21 0
      mobile/lib/modules/album/providers/album_detail.provider.dart
  58. 0 18
      mobile/lib/modules/album/providers/shared_album.provider.dart
  59. 28 8
      mobile/lib/modules/album/services/album.service.dart
  60. 2 3
      mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart
  61. 2 1
      mobile/lib/modules/album/ui/album_viewer_appbar.dart
  62. 13 6
      mobile/lib/modules/album/views/album_viewer_page.dart
  63. 1 1
      mobile/lib/modules/album/views/create_album_page.dart
  64. 5 2
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  65. 1 1
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  66. 25 13
      mobile/lib/modules/backup/providers/backup.provider.dart
  67. 5 2
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart
  68. 46 11
      mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart
  69. 4 0
      mobile/lib/modules/home/views/home_page.dart
  70. 56 27
      mobile/lib/modules/login/providers/authentication.provider.dart
  71. 36 23
      mobile/lib/modules/login/ui/login_form.dart
  72. 176 51
      mobile/lib/modules/memories/views/memory_page.dart
  73. 0 9
      mobile/lib/modules/search/providers/search_page_state.provider.dart
  74. 2 1
      mobile/lib/modules/search/services/person.service.dart
  75. 0 55
      mobile/lib/modules/search/views/curated_object_page.dart
  76. 0 42
      mobile/lib/modules/search/views/search_page.dart
  77. 15 4
      mobile/lib/routing/auth_guard.dart
  78. 0 2
      mobile/lib/routing/router.dart
  79. 2 28
      mobile/lib/routing/router.gr.dart
  80. 0 1
      mobile/lib/routing/tab_navigation_observer.dart
  81. 1 1
      mobile/lib/shared/models/album.dart
  82. 5 0
      mobile/lib/shared/models/user.dart
  83. 29 0
      mobile/lib/shared/services/api.service.dart
  84. 57 7
      mobile/lib/shared/ui/immich_image.dart
  85. 22 2
      mobile/lib/shared/views/splash_screen.dart
  86. 21 24
      mobile/openapi/.openapi-generator/FILES
  87. 15 15
      mobile/openapi/README.md
  88. 1 1
      mobile/openapi/doc/APIKeyCreateResponseDto.md
  89. 1 1
      mobile/openapi/doc/APIKeyResponseDto.md
  90. 2 2
      mobile/openapi/doc/AdminSignupResponseDto.md
  91. 10 10
      mobile/openapi/doc/AlbumApi.md
  92. 1 1
      mobile/openapi/doc/AlbumCountResponseDto.md
  93. 7 7
      mobile/openapi/doc/AlbumResponseDto.md
  94. 6 6
      mobile/openapi/doc/AllJobStatusResponseDto.md
  95. 123 101
      mobile/openapi/doc/AssetApi.md
  96. 1 1
      mobile/openapi/doc/AssetBulkUploadCheckItem.md
  97. 2 2
      mobile/openapi/doc/AssetBulkUploadCheckResult.md
  98. 0 16
      mobile/openapi/doc/AssetCountByTimeBucketResponseDto.md
  99. 1 1
      mobile/openapi/doc/AssetFileUploadResponseDto.md
  100. 1 1
      mobile/openapi/doc/AssetIdsResponseDto.md

+ 2 - 2
.github/workflows/docker-cleanup.yml

@@ -38,7 +38,7 @@ jobs:
       -
         name: Clean temporary images
         if: "${{ env.TOKEN != '' }}"
-        uses: stumpylog/image-cleaner-action/ephemeral@v0.1.0
+        uses: stumpylog/image-cleaner-action/ephemeral@v0.2.0
         with:
           token: "${{ env.TOKEN }}"
           owner: "immich-app"
@@ -70,7 +70,7 @@ jobs:
       -
         name: Clean untagged images
         if: "${{ env.TOKEN != '' }}"
-        uses: stumpylog/image-cleaner-action/untagged@v0.1.0
+        uses: stumpylog/image-cleaner-action/untagged@v0.2.0
         with:
           token: "${{ env.TOKEN }}"
           owner: "immich-app"

+ 87 - 3
.github/workflows/docker.yml

@@ -26,17 +26,101 @@ jobs:
         include:
           - context: "server"
             image: "immich-server"
-            platforms: "linux/arm/v7,linux/amd64,linux/arm64"
+            platforms: "linux/amd64"
           - context: "web"
             image: "immich-web"
-            platforms: "linux/arm/v7,linux/amd64,linux/arm64"
+            platforms: "linux/amd64,linux/arm64"
           - context: "machine-learning"
             image: "immich-machine-learning"
             platforms: "linux/amd64,linux/arm64"
           - context: "nginx"
             image: "immich-proxy"
-            platforms: "linux/arm/v7,linux/amd64,linux/arm64"
+            platforms: "linux/amd64,linux/arm64"
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v2.2.0
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v2.9.1
+        # Workaround to fix error:
+        # failed to push: failed to copy: io: read/write on closed pipe
+        # See https://github.com/docker/build-push-action/issues/761
+        with:
+          driver-opts: |
+            image=moby/buildkit:v0.10.6
+
+      - name: Login to Docker Hub
+        # Only push to Docker Hub when making a release
+        if: ${{ github.event_name == 'release' }}
+        uses: docker/login-action@v2
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
 
+      - name: Login to GitHub Container Registry
+        uses: docker/login-action@v2
+        # Skip when PR from a fork
+        if: ${{ !github.event.pull_request.head.repo.fork }}
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Generate docker image tags
+        id: metadata
+        uses: docker/metadata-action@v4
+        with:
+          flavor: |
+            # Disable latest tag
+            latest=false
+          images: |
+            name=ghcr.io/${{ github.repository_owner }}/${{matrix.image}}
+            name=altran1502/${{matrix.image}},enable=${{ github.event_name == 'release' }}
+          tags: |
+            # Tag with branch name
+            type=ref,event=branch
+            # Tag with pr-number
+            type=ref,event=pr
+            # Tag with git tag on release
+            type=ref,event=tag
+            type=raw,value=release,enable=${{ github.event_name == 'release' }}
+
+      - name: Determine build cache output
+        id: cache-target
+        run: |
+          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+            # Essentially just ignore the cache output (PR can't write to registry cache)
+            echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
+          else
+            echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT
+          fi
+
+      - name: Build and push image
+        uses: docker/build-push-action@v4.1.1
+        with:
+          context: ${{ matrix.context }}
+          platforms: ${{ matrix.platforms }}
+          # Skip pushing when PR from a fork
+          push: ${{ !github.event.pull_request.head.repo.fork }}
+          cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
+          cache-to: ${{ steps.cache-target.outputs.cache-to }}
+          tags: ${{ steps.metadata.outputs.tags }}
+          labels: ${{ steps.metadata.outputs.labels }}
+
+  build_and_push_server_arm_64:
+    runs-on: self-hosted
+    strategy:
+      # Prevent a failure in one image from stopping the other builds
+      fail-fast: false
+      matrix:
+        include:
+          - context: "server"
+            image: "immich-server"
+            platforms: "linux/arm64"
     steps:
       - name: Checkout
         uses: actions/checkout@v3

+ 1 - 0
.github/workflows/prepare-release.yml

@@ -83,4 +83,5 @@ jobs:
           files: |
             docker/docker-compose.yml
             docker/example.env
+            docker/hwaccel.yml
             *.apk

+ 2 - 2
Makefile

@@ -23,10 +23,10 @@ test-e2e:
 	docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test -p immich-test-e2e up  --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
 
 prod:
-	docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
+	docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
 
 prod-scale:
-	docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
+	docker-compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
 
 api:
 	cd ./server && npm run api:generate

+ 1 - 0
README.md

@@ -21,6 +21,7 @@
   <a href="README_zh_CN.md">中文</a>
   <a href="README_tr_TR.md">Türkçe</a>
   <a href="README_ca_ES.md">Català</a>
+  <a href="README_es_ES.md">Español</a>
 </p>
 
 ## Disclaimer

+ 1 - 0
README_ca_ES.md

@@ -21,6 +21,7 @@
   <a href="README.md">English</a>
   <a href="README_zh_CN.md">中文</a>
   <a href="README_tr_TR.md">Türkçe</a>
+  <a href="README_ca_ES.md">Español</a>
 </p>
 
 ## Avís legal

+ 108 - 0
README_es_ES.md

@@ -0,0 +1,108 @@
+<p align="center">
+  <br/>
+  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Licencia: MIT"></a>
+  <a href="https://discord.gg/D8JsnBEuKb">
+    <img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
+  </a>
+  <br/>
+  <br/>
+</p>
+
+<p align="center">
+<img src="design/immich-logo.svg" width="150" title="Iniciar sesión con URL personalizada">
+</p>
+<h3 align="center">Immich: Una solución Self-Hosted de copia de seguridad de fotos y videos de alto rendimiento</h3>
+<br/>
+<a href="https://immich.app">
+<img src="design/immich-screenshots.png" title="Captura de pantalla principal">
+</a>
+<br/>
+<p align="center">
+  <a href="README.md">English</a>
+  <a href="README_zh_CN.md">中文</a>
+  <a href="README_tr_TR.md">Türkçe</a>
+  <a href="README_ca_ES.md">Català</a>
+</p>
+
+## Descargo de responsabilidad
+
+- ⚠️ El proyecto está en **desarrollo muy activo**.
+- ⚠️ Es probable que haya errores y cambios disruptivos.
+- ⚠️ **¡No utilices la aplicación como única forma de almacenar tus fotos y videos!**
+
+## Contenido
+
+- [Documentación oficial](https://immich.app/docs)
+- [Hoja de ruta](https://github.com/orgs/immich-app/projects/1)
+- [Demostración](#demo)
+- [Funciones](#features)
+- [Introducción](https://immich.app/docs/overview/introduction)
+- [Instalación](https://immich.app/docs/install/requirements)
+- [Directrices para contribuir](https://immich.app/docs/overview/support-the-project)
+- [Apoya el proyecto](#support-the-project)
+
+## Documentación
+
+Puedes encontrar la documentación principal, incluidas las guías de instalación, en <https://immich.app/>.
+
+## Demostración
+
+Puedes acceder a la demostración web en <https://demo.immich.app>
+
+Para la aplicación móvil, puedes usar `https://demo.immich.app/api` como `URL de la terminal del servidor`.
+
+```bash title="Credenciales de la demostración"
+Las credenciales son
+correo electrónico: demo@immich.app
+contraseña: demo
+```
+
+```bash
+Especificaciones: VM de nivel gratuito de Oracle - Ámsterdam - CPU ARM64 de cuatro núcleos a 2.4 GHz, 24 GB de RAM
+```
+
+## Funcionalidades
+
+| Funcionalidades                                       | Móvil | Web |
+| ----------------------------------------------------- | ------ | --- |
+| Cargar y ver videos y fotos                          | Sí     | Sí  |
+| Copia de seguridad automática al abrir la aplicación | Sí     | N/D |
+| Álbum(es) selectivo(s) para copia de seguridad       | Sí     | N/D |
+| Descargar fotos y videos al dispositivo local        | Sí     | Sí  |
+| Soporte multiusuario                                 | Sí     | Sí  |
+| Álbum y álbumes compartidos                          | Sí     | Sí  |
+| Barra de desplazamiento con función de búsqueda      | Sí     | Sí  |
+| Soporte para formatos RAW                            | Sí     | Sí  |
+| Visualización de metadatos (EXIF, map)              | Sí     | Sí  |
+| Búsqueda por metadatos, objetos, rostros y CLIP      | Sí     | Sí  |
+| Funciones administrativas (gestión de usuarios)      | No     | Sí  |
+| Copia de seguridad en segundo plano                  | Sí     | N/D |
+| Desplazamiento virtual                               | Sí     | Sí  |
+| Soporte de OAuth                                     | Sí     | Sí  |
+| Claves de API                                        | N/D    | Sí  |
+| Copia de seguridad y reproducción de LivePhoto       | iOS    | Sí  |
+| Estructura de almacenamiento definida por el usuario | Sí     | Sí  |
+| Compartir públicamente                               | No     | Sí  |
+| Archivar y marcar como favorito                      | Sí     | Sí  |
+| Mapa global                                          | No     | Sí  |
+| Compartir con colaboradores                          | Sí     | Sí  |
+| Reconocimiento facial y agrupación                   | Sí     | Sí  |
+| Recuerdos (hace x años)                              | Sí     | Sí  |
+| Soporte sin conexión                                 | Sí     | No  |
+| Galería de solo lectura                              | Sí     | Sí  |
+
+## Apoya el proyecto
+
+Me he comprometido con este proyecto, y no me detendré. Continuaré actualizando la documentación, agregando nuevas funcionalidades y corrigiendo errores. Pero no puedo hacerlo solo. Por eso, necesito tu ayuda para darme una motivación adicional para seguir adelante.
+
+Como dijeron nuestros anfitriones en [selfhosted.show - En el episodio 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418), esto es una gran tarea de lo que el equipo y yo estamos haciendo. Y me encantaría poder dedicarme a esto a tiempo completo algún día, así que te pido tu ayuda para que eso sea posible.
+
+Si consideras que esta es una causa justa y la aplicación es algo que te gustaría usar durante mucho tiempo, por favor, considera apoyar el proyecto con las siguientes opciones.
+
+## Donación
+
+- [Donación mensual](https://github.com/sponsors/alextran1502) a través de GitHub Sponsors
+- [Donación única](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) a través de GitHub Sponsors
+- [Librepay](https://liberapay.com/alex.tran1502/)
+- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
+- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

+ 2 - 0
README_tr_TR.md

@@ -20,6 +20,8 @@
 <p align="center">
   <a href="README.md">English</a>
   <a href="README_zh_CN.md">中文</a>
+  <a href="README_ca_ES.md">Català</a>
+  <a href="README_es_ES.md">Español</a>
 </p>
 
 ## Feragatname

+ 2 - 0
README_zh_CN.md

@@ -24,6 +24,8 @@
 <p align="center">
   <a href="README.md">English</a>
   <a href="README_tr_TR.md">Türkçe</a>
+  <a href="README_ca_ES.md">Català</a>
+  <a href="README_es_ES.md">Español</a>
 </p>
 
 

+ 0 - 0
cli/asdf


+ 0 - 8
cli/jest.config.ts

@@ -1,8 +0,0 @@
-import type { Config } from 'jest';
-
-const config: Config = {
-  preset: 'ts-jest',
-  setupFilesAfterEnv: ['jest-extended/all'],
-};
-
-export default config;

+ 79 - 143
cli/package-lock.json

@@ -8,9 +8,14 @@
       "name": "immich-cli",
       "dependencies": {
         "axios": "^1.4.0",
+        "byte-size": "^8.1.1",
+        "cli-progress": "^3.12.0",
+        "commander": "^11.0.0",
         "form-data": "^4.0.0",
-        "mime-types": "^2.1.35",
-        "systeminformation": "^5.18.4"
+        "glob": "^10.3.1",
+        "picomatch": "^2.3.1",
+        "systeminformation": "^5.18.4",
+        "yaml": "^2.3.1"
       },
       "devDependencies": {
         "@types/byte-size": "^8.1.0",
@@ -22,28 +27,23 @@
         "@types/mock-fs": "^4.13.1",
         "@types/node": "^20.3.1",
         "@typescript-eslint/eslint-plugin": "^5.60.1",
-        "byte-size": "^8.1.1",
+        "@typescript-eslint/parser": "^5.48.1",
         "chai": "^4.3.7",
-        "cli-progress": "^3.12.0",
-        "commander": "^11.0.0",
         "eslint": "^8.43.0",
         "eslint-config-prettier": "^8.8.0",
         "eslint-plugin-jest": "^27.2.2",
         "eslint-plugin-prettier": "^4.2.1",
         "eslint-plugin-unicorn": "^47.0.0",
-        "glob": "^10.3.1",
         "jest": "^29.5.0",
         "jest-extended": "^4.0.0",
         "jest-message-util": "^29.5.0",
         "jest-mock-axios": "^4.7.2",
         "jest-when": "^3.5.2",
         "mock-fs": "^5.2.0",
-        "picomatch": "^2.3.1",
         "ts-jest": "^29.1.0",
         "ts-node": "^10.9.1",
         "tslib": "^2.5.3",
-        "typescript": "^4.9.4",
-        "yaml": "^2.3.1"
+        "typescript": "^4.9.4"
       }
     },
     "node_modules/@ampproject/remapping": {
@@ -111,9 +111,9 @@
       }
     },
     "node_modules/@babel/core/node_modules/semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "dev": true,
       "bin": {
         "semver": "bin/semver.js"
@@ -154,9 +154,9 @@
       }
     },
     "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "dev": true,
       "bin": {
         "semver": "bin/semver.js"
@@ -772,7 +772,6 @@
       "version": "8.0.2",
       "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
       "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
-      "dev": true,
       "dependencies": {
         "string-width": "^5.1.2",
         "string-width-cjs": "npm:string-width@^4.2.0",
@@ -789,7 +788,6 @@
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
       "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-      "dev": true,
       "engines": {
         "node": ">=12"
       },
@@ -801,7 +799,6 @@
       "version": "6.2.1",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
       "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
-      "dev": true,
       "engines": {
         "node": ">=12"
       },
@@ -812,14 +809,12 @@
     "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
       "version": "9.2.2",
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
-      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
-      "dev": true
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
     },
     "node_modules/@isaacs/cliui/node_modules/string-width": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
       "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
-      "dev": true,
       "dependencies": {
         "eastasianwidth": "^0.2.0",
         "emoji-regex": "^9.2.2",
@@ -836,7 +831,6 @@
       "version": "7.1.0",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
       "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-      "dev": true,
       "dependencies": {
         "ansi-regex": "^6.0.1"
       },
@@ -851,7 +845,6 @@
       "version": "8.1.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
       "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
-      "dev": true,
       "dependencies": {
         "ansi-styles": "^6.1.0",
         "string-width": "^5.0.1",
@@ -1347,7 +1340,6 @@
       "version": "0.11.0",
       "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
       "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
-      "dev": true,
       "optional": true,
       "engines": {
         "node": ">=14"
@@ -1664,7 +1656,6 @@
       "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.60.1.tgz",
       "integrity": "sha512-pHWlc3alg2oSMGwsU/Is8hbm3XFbcrb6P5wIxcQW9NsYBfnrubl/GhVVD/Jm/t8HXhA2WncoIRfBtnCgRGV96Q==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@typescript-eslint/scope-manager": "5.60.1",
         "@typescript-eslint/types": "5.60.1",
@@ -1692,7 +1683,6 @@
       "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.60.1.tgz",
       "integrity": "sha512-Dn/LnN7fEoRD+KspEOV0xDMynEmR3iSHdgNsarlXNLGGtcUok8L4N71dxUgt3YvlO8si7E+BJ5Fe3wb5yUw7DQ==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@typescript-eslint/types": "5.60.1",
         "@typescript-eslint/visitor-keys": "5.60.1"
@@ -1710,7 +1700,6 @@
       "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.60.1.tgz",
       "integrity": "sha512-zDcDx5fccU8BA0IDZc71bAtYIcG9PowaOwaD8rjYbqwK7dpe/UMQl3inJ4UtUK42nOCT41jTSCwg76E62JpMcg==",
       "dev": true,
-      "peer": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       },
@@ -1724,7 +1713,6 @@
       "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.1.tgz",
       "integrity": "sha512-hkX70J9+2M2ZT6fhti5Q2FoU9zb+GeZK2SLP1WZlvUDqdMbEKhexZODD1WodNRyO8eS+4nScvT0dts8IdaBzfw==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@typescript-eslint/types": "5.60.1",
         "@typescript-eslint/visitor-keys": "5.60.1",
@@ -1752,7 +1740,6 @@
       "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.60.1.tgz",
       "integrity": "sha512-xEYIxKcultP6E/RMKqube11pGjXH1DCo60mQoWhVYyKfLkwbIVVjYxmOenNMxILx0TjCujPTjjnTIVzm09TXIw==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@typescript-eslint/types": "5.60.1",
         "eslint-visitor-keys": "^3.3.0"
@@ -2036,7 +2023,6 @@
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
       "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
@@ -2045,7 +2031,6 @@
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
       "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
       "dependencies": {
         "color-convert": "^2.0.1"
       },
@@ -2211,8 +2196,7 @@
     "node_modules/balanced-match": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
-      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
-      "dev": true
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
     },
     "node_modules/brace-expansion": {
       "version": "1.1.11",
@@ -2311,7 +2295,6 @@
       "version": "8.1.1",
       "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz",
       "integrity": "sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==",
-      "dev": true,
       "engines": {
         "node": ">=12.17"
       }
@@ -2464,7 +2447,6 @@
       "version": "3.12.0",
       "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
       "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
-      "dev": true,
       "dependencies": {
         "string-width": "^4.2.3"
       },
@@ -2506,7 +2488,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
       "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
       "dependencies": {
         "color-name": "~1.1.4"
       },
@@ -2517,8 +2498,7 @@
     "node_modules/color-name": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
     },
     "node_modules/combined-stream": {
       "version": "1.0.8",
@@ -2535,7 +2515,6 @@
       "version": "11.0.0",
       "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz",
       "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==",
-      "dev": true,
       "engines": {
         "node": ">=16"
       }
@@ -2562,7 +2541,6 @@
       "version": "7.0.3",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
       "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
-      "dev": true,
       "dependencies": {
         "path-key": "^3.1.0",
         "shebang-command": "^2.0.0",
@@ -2675,8 +2653,7 @@
     "node_modules/eastasianwidth": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
-      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
-      "dev": true
+      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
     },
     "node_modules/electron-to-chromium": {
       "version": "1.4.440",
@@ -2699,8 +2676,7 @@
     "node_modules/emoji-regex": {
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-      "dev": true
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
     },
     "node_modules/error-ex": {
       "version": "1.3.2",
@@ -2801,9 +2777,9 @@
       }
     },
     "node_modules/eslint-plugin-jest": {
-      "version": "27.2.2",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.2.2.tgz",
-      "integrity": "sha512-euzbp06F934Z7UDl5ZUaRPLAc9MKjh0rMPERrHT7UhlCEwgb25kBj37TvMgWeHZVkR5I9CayswrpoaqZU1RImw==",
+      "version": "27.2.3",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.2.3.tgz",
+      "integrity": "sha512-sRLlSCpICzWuje66Gl9zvdF6mwD5X86I4u55hJyFBsxYOsBCmT5+kSUjf+fkFWVMMgpzNEupjW8WzUqi83hJAQ==",
       "dev": true,
       "dependencies": {
         "@typescript-eslint/utils": "^5.10.0"
@@ -2812,7 +2788,7 @@
         "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
       },
       "peerDependencies": {
-        "@typescript-eslint/eslint-plugin": "^5.0.0",
+        "@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0",
         "eslint": "^7.0.0 || ^8.0.0",
         "jest": "*"
       },
@@ -3203,7 +3179,6 @@
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
       "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
-      "dev": true,
       "dependencies": {
         "cross-spawn": "^7.0.0",
         "signal-exit": "^4.0.1"
@@ -3219,7 +3194,6 @@
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz",
       "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==",
-      "dev": true,
       "engines": {
         "node": ">=14"
       },
@@ -3318,7 +3292,6 @@
       "version": "10.3.1",
       "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.1.tgz",
       "integrity": "sha512-9BKYcEeIs7QwlCYs+Y3GBvqAMISufUS0i2ELd11zpZjxI5V9iyRj0HgzB5/cLf2NY4vcYBTYzJ7GIui7j/4DOw==",
-      "dev": true,
       "dependencies": {
         "foreground-child": "^3.1.0",
         "jackspeak": "^2.0.3",
@@ -3352,7 +3325,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
       "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
-      "dev": true,
       "dependencies": {
         "balanced-match": "^1.0.0"
       }
@@ -3361,7 +3333,6 @@
       "version": "9.0.2",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.2.tgz",
       "integrity": "sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==",
-      "dev": true,
       "dependencies": {
         "brace-expansion": "^2.0.1"
       },
@@ -3458,6 +3429,12 @@
         "node": ">=8"
       }
     },
+    "node_modules/hosted-git-info": {
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+      "dev": true
+    },
     "node_modules/html-escaper": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -3597,7 +3574,6 @@
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
       "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
@@ -3656,8 +3632,7 @@
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
-      "dev": true
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
     },
     "node_modules/istanbul-lib-coverage": {
       "version": "3.2.0",
@@ -3685,9 +3660,9 @@
       }
     },
     "node_modules/istanbul-lib-instrument/node_modules/semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "dev": true,
       "bin": {
         "semver": "bin/semver.js"
@@ -3750,7 +3725,6 @@
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.2.1.tgz",
       "integrity": "sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==",
-      "dev": true,
       "dependencies": {
         "@isaacs/cliui": "^8.0.2"
       },
@@ -3933,24 +3907,6 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
-    "node_modules/jest-config/node_modules/parse-json": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
-      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
-      "dev": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.0.0",
-        "error-ex": "^1.3.1",
-        "json-parse-even-better-errors": "^2.3.0",
-        "lines-and-columns": "^1.1.6"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/jest-diff": {
       "version": "29.5.0",
       "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.5.0.tgz",
@@ -4571,9 +4527,9 @@
       }
     },
     "node_modules/make-dir/node_modules/semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "dev": true,
       "bin": {
         "semver": "bin/semver.js"
@@ -4675,7 +4631,6 @@
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/minipass/-/minipass-6.0.2.tgz",
       "integrity": "sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==",
-      "dev": true,
       "engines": {
         "node": ">=16 || 14 >=14.17"
       }
@@ -4719,6 +4674,27 @@
       "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==",
       "dev": true
     },
+    "node_modules/normalize-package-data": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+      "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+      "dev": true,
+      "dependencies": {
+        "hosted-git-info": "^2.1.4",
+        "resolve": "^1.10.0",
+        "semver": "2 || 3 || 4 || 5",
+        "validate-npm-package-license": "^3.0.1"
+      }
+    },
+    "node_modules/normalize-package-data/node_modules/semver": {
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
     "node_modules/normalize-path": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4838,6 +4814,24 @@
         "node": ">=6"
       }
     },
+    "node_modules/parse-json": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.0.0",
+        "error-ex": "^1.3.1",
+        "json-parse-even-better-errors": "^2.3.0",
+        "lines-and-columns": "^1.1.6"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4860,7 +4854,6 @@
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
       "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
@@ -4875,7 +4868,6 @@
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.0.tgz",
       "integrity": "sha512-tZFEaRQbMLjwrsmidsGJ6wDMv0iazJWk6SfIKnY4Xru8auXgmJkOBa5DUbYFcFD2Rzk2+KDlIiF0GVXNCbgC7g==",
-      "dev": true,
       "dependencies": {
         "lru-cache": "^9.1.1 || ^10.0.0",
         "minipass": "^5.0.0 || ^6.0.2"
@@ -4891,7 +4883,6 @@
       "version": "10.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.0.tgz",
       "integrity": "sha512-svTf/fzsKHffP42sujkO/Rjs37BCIsQVRCeNYIm9WN8rgT7ffoUnRtZCqU+6BqcSBdv8gwJeTz8knJpgACeQMw==",
-      "dev": true,
       "engines": {
         "node": "14 || >=16.14"
       }
@@ -4924,7 +4915,6 @@
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
       "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
       "engines": {
         "node": ">=8.6"
       },
@@ -5239,51 +5229,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/read-pkg/node_modules/hosted-git-info": {
-      "version": "2.8.9",
-      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
-      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
-      "dev": true
-    },
-    "node_modules/read-pkg/node_modules/normalize-package-data": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
-      "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
-      "dev": true,
-      "dependencies": {
-        "hosted-git-info": "^2.1.4",
-        "resolve": "^1.10.0",
-        "semver": "2 || 3 || 4 || 5",
-        "validate-npm-package-license": "^3.0.1"
-      }
-    },
-    "node_modules/read-pkg/node_modules/parse-json": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
-      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
-      "dev": true,
-      "dependencies": {
-        "@babel/code-frame": "^7.0.0",
-        "error-ex": "^1.3.1",
-        "json-parse-even-better-errors": "^2.3.0",
-        "lines-and-columns": "^1.1.6"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/read-pkg/node_modules/semver": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
-      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
-      "dev": true,
-      "bin": {
-        "semver": "bin/semver"
-      }
-    },
     "node_modules/read-pkg/node_modules/type-fest": {
       "version": "0.6.0",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
@@ -5502,7 +5447,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-      "dev": true,
       "dependencies": {
         "shebang-regex": "^3.0.0"
       },
@@ -5514,7 +5458,6 @@
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
       "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
@@ -5635,7 +5578,6 @@
       "version": "4.2.3",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
       "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-      "dev": true,
       "dependencies": {
         "emoji-regex": "^8.0.0",
         "is-fullwidth-code-point": "^3.0.0",
@@ -5650,7 +5592,6 @@
       "version": "4.2.3",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
       "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-      "dev": true,
       "dependencies": {
         "emoji-regex": "^8.0.0",
         "is-fullwidth-code-point": "^3.0.0",
@@ -5664,7 +5605,6 @@
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
       "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
       "dependencies": {
         "ansi-regex": "^5.0.1"
       },
@@ -5677,7 +5617,6 @@
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
       "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
       "dependencies": {
         "ansi-regex": "^5.0.1"
       },
@@ -6111,7 +6050,6 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-      "dev": true,
       "dependencies": {
         "isexe": "^2.0.0"
       },
@@ -6123,9 +6061,9 @@
       }
     },
     "node_modules/word-wrap": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
+      "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
       "dev": true,
       "engines": {
         "node": ">=0.10.0"
@@ -6153,7 +6091,6 @@
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
       "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-      "dev": true,
       "dependencies": {
         "ansi-styles": "^4.0.0",
         "string-width": "^4.1.0",
@@ -6204,7 +6141,6 @@
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz",
       "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==",
-      "dev": true,
       "engines": {
         "node": ">= 14"
       }

+ 28 - 9
cli/package.json

@@ -2,9 +2,14 @@
   "name": "immich-cli",
   "dependencies": {
     "axios": "^1.4.0",
+    "byte-size": "^8.1.1",
+    "cli-progress": "^3.12.0",
+    "commander": "^11.0.0",
     "form-data": "^4.0.0",
-    "mime-types": "^2.1.35",
-    "systeminformation": "^5.18.4"
+    "glob": "^10.3.1",
+    "picomatch": "^2.3.1",
+    "systeminformation": "^5.18.4",
+    "yaml": "^2.3.1"
   },
   "devDependencies": {
     "@types/byte-size": "^8.1.0",
@@ -16,34 +21,48 @@
     "@types/mock-fs": "^4.13.1",
     "@types/node": "^20.3.1",
     "@typescript-eslint/eslint-plugin": "^5.60.1",
-    "byte-size": "^8.1.1",
+    "@typescript-eslint/parser": "^5.48.1",
     "chai": "^4.3.7",
-    "cli-progress": "^3.12.0",
-    "commander": "^11.0.0",
     "eslint": "^8.43.0",
     "eslint-config-prettier": "^8.8.0",
     "eslint-plugin-jest": "^27.2.2",
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-unicorn": "^47.0.0",
-    "glob": "^10.3.1",
     "jest": "^29.5.0",
     "jest-extended": "^4.0.0",
     "jest-message-util": "^29.5.0",
     "jest-mock-axios": "^4.7.2",
     "jest-when": "^3.5.2",
     "mock-fs": "^5.2.0",
-    "picomatch": "^2.3.1",
     "ts-jest": "^29.1.0",
     "ts-node": "^10.9.1",
     "tslib": "^2.5.3",
-    "typescript": "^4.9.4",
-    "yaml": "^2.3.1"
+    "typescript": "^4.9.4"
   },
   "scripts": {
+    "build": "tsc --project tsconfig.build.json",
     "lint": "eslint \"src/**/*.ts\" --max-warnings 0",
     "prepack": "yarn build ",
     "test": "jest",
     "test:cov": "jest --coverage",
     "format": "prettier --check ."
+  },
+  "jest": {
+    "clearMocks": true,
+    "moduleFileExtensions": [
+      "js",
+      "json",
+      "ts"
+    ],
+    "rootDir": ".",
+    "testRegex": ".*\\.spec\\.ts$",
+    "transform": {
+      "^.+\\.ts$": "ts-jest"
+    },
+    "collectCoverageFrom": [
+      "<rootDir>/src/**/*.(t|j)s"
+    ],
+    "coverageDirectory": "./coverage",
+    "testEnvironment": "node"
   }
 }

文件差异内容过多而无法显示
+ 166 - 229
cli/src/api/open-api/api.ts


+ 1 - 1
cli/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.67.2
+ * The version of the OpenAPI document: 1.71.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.67.2
+ * The version of the OpenAPI document: 1.71.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.67.2
+ * The version of the OpenAPI document: 1.71.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.67.2
+ * The version of the OpenAPI document: 1.71.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 2 - 0
cli/src/commands/upload.ts

@@ -70,11 +70,13 @@ export default class Upload extends BaseCommand {
         if (options.import) {
           const importData = {
             assetPath: asset.path,
+            sidecarPath: asset.sidecarPath,
             deviceAssetId: asset.deviceAssetId,
             deviceId: this.deviceId,
             fileCreatedAt: asset.fileCreatedAt,
             fileModifiedAt: asset.fileModifiedAt,
             isFavorite: false,
+            isReadOnly: options.readOnly,
           };
 
           if (!this.dryRun) {

+ 1 - 0
cli/src/cores/dto/upload-options-dto.ts

@@ -5,4 +5,5 @@ export class UploadOptionsDto {
   skipHash = false;
   delete = false;
   import = false;
+  readOnly = true;
 }

+ 3 - 1
cli/src/index.ts

@@ -35,9 +35,11 @@ program
       .default(false),
   )
   .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
-  .argument('[paths...]', 'One or more paths to assets to be uploaded')
+  .addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
+  .argument('[paths...]', 'One or more paths to assets to be imported')
   .action((paths, options) => {
     options.import = true;
+    options.excludePatterns = options.ignore;
     new Upload().run(paths, options);
   });
 

+ 0 - 7
cli/test/tsconfig.json

@@ -1,7 +0,0 @@
-{
-  "extends": "../tsconfig",
-  "compilerOptions": {
-    "noEmit": true
-  },
-  "references": [{ "path": ".." }]
-}

+ 4 - 0
cli/tsconfig.build.json

@@ -0,0 +1,4 @@
+{
+  "extends": "./tsconfig.json",
+  "exclude": ["dist", "node_modules", "upload", "test", "**/*spec.ts"]
+}

+ 3 - 2
docker/docker-compose.dev.yml

@@ -31,7 +31,6 @@ services:
     build:
       context: ../machine-learning
       dockerfile: Dockerfile
-    command: python main.py
     ports:
       - 3003:3003
     volumes:
@@ -48,6 +47,9 @@ services:
   immich-microservices:
     container_name: immich_microservices
     image: immich-microservices:latest
+    # extends:
+    #   file: hwaccel.yml
+    #   service: hwaccel
     build:
       context: ../server
       dockerfile: Dockerfile
@@ -116,7 +118,6 @@ services:
       POSTGRES_PASSWORD: ${DB_PASSWORD}
       POSTGRES_USER: ${DB_USERNAME}
       POSTGRES_DB: ${DB_DATABASE_NAME}
-      PG_DATA: /var/lib/postgresql/data
     volumes:
       - pgdata:/var/lib/postgresql/data
     ports:

+ 116 - 0
docker/docker-compose.prod.yml

@@ -0,0 +1,116 @@
+version: "3.8"
+
+services:
+  immich-server:
+    container_name: immich_server
+    image: immich-server:latest
+    build:
+      context: ../server
+      dockerfile: Dockerfile
+    command: ["./start-server.sh"]
+    volumes:
+      - ${UPLOAD_LOCATION}:/usr/src/app/upload
+    env_file:
+      - .env
+    depends_on:
+      - redis
+      - database
+      - typesense
+
+  immich-machine-learning:
+    container_name: immich_machine_learning
+    image: immich-machine-learning:latest
+    build:
+      context: ../machine-learning
+      dockerfile: Dockerfile
+    volumes:
+      - ${UPLOAD_LOCATION}:/usr/src/app/upload
+      - model-cache:/cache
+    env_file:
+      - .env
+    restart: always
+  
+  immich-microservices:
+    container_name: immich_microservices
+    image: immich-microservices:latest
+    # extends:
+    #   file: hwaccel.yml
+    #   service: hwaccel
+    build:
+      context: ../server
+      dockerfile: Dockerfile
+    command: ["./start-microservices.sh"]
+    volumes:
+      - ${UPLOAD_LOCATION}:/usr/src/app/upload
+    env_file:
+      - .env
+    depends_on:
+      - database
+      - immich-server
+      - typesense
+    restart: always
+
+  immich-web:
+    container_name: immich_web
+    image: immich-web:latest
+    build:
+      context: ../web
+      dockerfile: Dockerfile
+    env_file:
+      - .env
+    restart: always
+    depends_on:
+      - immich-server
+
+  typesense:
+    container_name: immich_typesense
+    image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
+    environment:
+      - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
+      - TYPESENSE_DATA_DIR=/data
+    logging:
+      driver: none
+    volumes:
+      - tsdata:/data
+    restart: always
+
+  redis:
+    container_name: immich_redis
+    image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
+    restart: always
+
+  database:
+    container_name: immich_postgres
+    image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
+    env_file:
+      - .env
+    environment:
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+      POSTGRES_USER: ${DB_USERNAME}
+      POSTGRES_DB: ${DB_DATABASE_NAME}
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    restart: always
+
+  immich-proxy:
+    container_name: immich_proxy
+    image: immich-proxy:latest
+    environment:
+      # Make sure these values get passed through from the env file
+      - IMMICH_SERVER_URL
+      - IMMICH_WEB_URL
+    build:
+      context: ../nginx
+      dockerfile: Dockerfile
+    ports:
+      - 2283:8080
+    logging:
+      driver: none
+    depends_on:
+      - immich-server
+    restart: always
+
+volumes:
+  pgdata:
+  model-cache:
+  tsdata:

+ 0 - 1
docker/docker-compose.test.yml

@@ -37,7 +37,6 @@ services:
       POSTGRES_PASSWORD: ${DB_PASSWORD}
       POSTGRES_USER: ${DB_USERNAME}
       POSTGRES_DB: ${DB_DATABASE_NAME}
-      PG_DATA: /var/lib/postgresql/data
     volumes:
       - /var/lib/postgresql/data
     networks:

+ 3 - 3
docker/docker-compose.yml

@@ -18,6 +18,9 @@ services:
   immich-microservices:
     container_name: immich_microservices
     image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
+    # extends:
+    #   file: hwaccel.yml
+    #   service: hwaccel
     command: [ "start.sh", "microservices" ]
     volumes:
       - ${UPLOAD_LOCATION}:/usr/src/app/upload
@@ -51,8 +54,6 @@ services:
     environment:
       - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
       - TYPESENSE_DATA_DIR=/data
-    logging:
-      driver: none
     volumes:
       - tsdata:/data
     restart: always
@@ -71,7 +72,6 @@ services:
       POSTGRES_PASSWORD: ${DB_PASSWORD}
       POSTGRES_USER: ${DB_USERNAME}
       POSTGRES_DB: ${DB_DATABASE_NAME}
-      PG_DATA: /var/lib/postgresql/data
     volumes:
       - pgdata:/var/lib/postgresql/data
     restart: always

+ 23 - 0
docker/hwaccel.yml

@@ -0,0 +1,23 @@
+version: "3.8"
+
+# Hardware acceleration for transcoding - Optional
+# This is only needed if you want to use hardware acceleration for transcoding.
+# Depending on your hardware, you should uncomment the relevant lines below.
+
+services:
+  hwaccel:
+    # devices:
+    #   - /dev/dri:/dev/dri  # If using Intel QuickSync or VAAPI
+    # volumes:
+    #   - /usr/lib/wsl:/usr/lib/wsl # If using VAAPI in WSL2
+    # environment:
+    #   - NVIDIA_DRIVER_CAPABILITIES=all # If using NVIDIA GPU
+    #   - LD_LIBRARY_PATH=/usr/lib/wsl/lib # If using VAAPI in WSL2
+    #   - LIBVA_DRIVER_NAME=d3d12 # If using VAAPI in WSL2
+    # deploy: # Uncomment this section if using NVIDIA GPU
+    #   resources:
+    #     reservations:
+    #       devices:
+    #         - driver: nvidia
+    #           count: 1
+    #           capabilities: [gpu]

+ 1 - 1
docs/blog/2023/06-24/update.mdx

@@ -1,5 +1,5 @@
 ---
-title: June 2023 update
+title: Immich Update - June 2023
 authors: [alextran]
 tags: [update]
 ---

二进制
docs/blog/2023/07-29/images/web-shortcuts-panel.png


+ 151 - 0
docs/blog/2023/07-29/update.mdx

@@ -0,0 +1,151 @@
+---
+title: Immich Update - July 2023
+authors: [alextran]
+tags: [update, v1.64.0-v1.71.0]
+---
+
+Hello, Immich fans, another month, another milestone. We hope you are staying cool and safe in this scorching hot summer across the globe.
+
+Immich recently got some good recognition when getting to the front page of HackerNews, which helped to let more people know about the project's existence. The project will help more and more people find a solution to control the privacy of their most precious moments. And with the gain in popularity and recognition, we have gotten new users and more questions from the community than ever.
+
+I want to express my gratitude to all the contributors and the community who have been tremendously helpful to new users' questions and provided technical support.
+
+Below are the highlights of new features we added to the application over the past month, along with countless bug fixes and improvements across the board, from developer experience to resource optimization and UI/UX improvement. I hope you find these topics as exciting as I am.
+
+## Highlights
+
+- Memories feature.
+- Facial recognition improvements.
+- Improvements on multi selection behavior on the web.
+- Shortcuts for common actions on the web.
+- Support viewer for 360-panorama photos.
+
+<!--truncate-->
+
+---
+
+### Memories feature
+
+We've added the memory feature on the mobile app, so you can reminisce about your past memories.
+
+<iframe
+  width="560"
+  height="315"
+  src="https://youtube.com/embed/c7OTl-RqNRE"
+  title="YouTube video player"
+  frameborder="0"
+  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+  allowfullscreen
+></iframe>
+
+### Facial recognition improvements
+
+Over the past few releases, we have added many UI improvements to the facial recognition feature to help you manage the recognized people better. Some of the highlights:
+
+#### Choose a new feature photo for a person.
+
+<iframe
+  width="560"
+  height="315"
+  src="https://youtube.com/embed/PmJp8DmSh1U"
+  title="YouTube video player"
+  frameborder="0"
+  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+  allowfullscreen
+></iframe>
+
+#### Hide and show faces.
+
+You can now select irrelevant faces to hide them. The hidden faces won’t be displayed in search results and the people section in the info panel.
+
+#### Merge faces.
+
+This is useful when you have multiple faces of the same person in your photos, and you want to merge them into one.
+
+<iframe
+  width="560"
+  height="315"
+  src="https://youtube.com/embed/-Xskhw-vpc4"
+  title="YouTube video player"
+  frameborder="0"
+  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+  allowfullscreen
+></iframe>
+
+We also added a nifty mechanism that when naming a face, similar names will prompt you a merge face option for the convenience.
+
+<iframe
+  width="560"
+  height="315"
+  src="https://youtube.com/embed/XzE6wficbl4"
+  title="YouTube video player"
+  frameborder="0"
+  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+  allowfullscreen
+></iframe>
+
+### Improvements on multi selection behavior on the web
+
+We have added a new multi selection behavior on the web to help you select multiple items easier. You can now select a range of photos and videos by holding the `Shift` key.
+
+<iframe
+  width="560"
+  height="315"
+  src="https://youtube.com/embed/e_SiuHpVnmM"
+  title="YouTube video player"
+  frameborder="0"
+  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+  allowfullscreen
+></iframe>
+
+### Shortcuts for common actions on the web.
+
+Some of us only navigate the world and the web with a keyboard (looking at you, Vim and Emacs users). So it would take away the sacred weapon of choice to require many clicks to perform repetitive actions. So we added quick shortcuts for the following action on the web.
+
+<img
+  src={require('./images/web-shortcuts-panel.png').default}
+  width="100%"
+  style={{ borderRadius: '25px' }}
+  alt="Dot Env Example"
+/>
+
+### Support viewer for 360-panorama photos.
+
+Photos with the EXIF property of `ProjectionType` will now have a special viewer on the web to view all the angles of the panorama.
+
+The thumbnail of the 360 degrees panoramas will have a special icon on the top right of the thumbnail
+
+<img
+  src="https://github.com/immich-app/immich/assets/61410067/728ca1b0-375c-4631-8081-a609843e702f"
+  width="50%"
+  style={{ borderRadius: '25px' }}
+  alt="Dot Env Example"
+/>
+
+Panorama in the detail view
+
+<img
+  src="https://github.com/immich-app/immich/assets/61410067/3c89dac4-395d-45fa-9bc5-98a6248fd476"
+  width="50%"
+  style={{ borderRadius: '25px' }}
+  alt="Dot Env Example"
+/>
+
+---
+
+Thank you, and I am asking for your support for the project. I hope to be a full-time maintainer of Immich one day to dedicate myself to the project as my life's work for the community and my family. You can find the support channels below:
+
+- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502)
+- One-time donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
+- [Liberapay](https://liberapay.com/alex.tran1502/)
+- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
+- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
+- Give a project a star - the contributors love gazing at the stars and seeing their creations shining in the sky.
+
+Join our friendly [Discord](https://discord.gg/D8JsnBEuKb) to talk and discuss Immich, tech, or anything
+
+Cheer!
+
+Until next time!
+
+Alex

+ 4 - 0
docs/docs/administration/backup-and-restore.md

@@ -2,6 +2,10 @@
 
 ## Database
 
+:::caution
+Immich saves [file paths in the database](https://github.com/immich-app/immich/discussions/3299), it does not scan the library folder to update the database so backups are crucial.
+:::
+
 :::info
 Refer to the official [postgres documentation](https://www.postgresql.org/docs/current/backup.html) for details about backing up and restoring a postgres database.
 :::

+ 3 - 1
docs/docs/administration/server-commands.md

@@ -12,10 +12,12 @@ The `immich-server` docker image comes preinstalled with an administrative CLI (
 
 ## How to run a command
 
-To run a command, [connect](/docs/guides/docker-help.md#attach-to-a-container) to the `immich_server` container and then execute the command via `immich <command>`.
+To run a command, [connect](/docs/guides/docker-help.md#attach-to-a-container) to the `immich_server` container and then execute the command via `immich-admin <command>`.
 
 ## Examples
 
+Note that the commands below should begin with `immich-admin`.
+
 Reset Admin Password
 
 ![Reset Admin Password](./img/reset-admin-password.png)

+ 89 - 20
docs/docs/developer/architecture.md

@@ -4,38 +4,107 @@ sidebar_position: 1
 
 # Architecture
 
+Immich uses a traditional client-server design, with a dedicated database for data persistence. The frontend clients communicate with backend services over HTTP using REST APIs.
+
 ## High Level Diagram
 
 ![Immich Architecture](./img/app-architecture.png)
 
-## Technology
+The diagram shows clients communicating with the server via REST, as well as the flow of database between backend services.
+
+## Clients
+
+Immich has three main clients:
+
+1. Mobile app - Android, iOS
+2. Web app - Responsive website
+3. CLI - Command-line utility for bulk upload
+
+:::info
+All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](./open-api.md).
+:::
+
+### Mobile App
+
+The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management.
+
+### Web Client
+
+The web app is a [TypeScript](https://www.typescriptlang.org/) project that uses [SvelteKit](https://kit.svelte.dev) and [Tailwindcss](https://tailwindcss.com/).
+
+### CLI
+
+The CLI is a [TypeScript](https://www.typescriptlang.org/) project that parses command line arguments to programmatically upload/import assets to an Immich server. See [Bulk Upload](/docs/features/bulk-upload.md) for more information about its usage.
+
+## Server
+
+The Immich backend is divided into several services, which are run as individual docker containers.
+
+1. `immich-server` - Handle and respond to REST API requests
+1. `immich-microservices` - Execute background jobs (thumbnail generation, metadata extraction, transcoding, etc.)
+1. `immich-machine-learning` - Execute machine learning models
+1. `postgres` - Persistent data storage
+1. `redis`- Queue management for `immich-microservices`
+1. `typesense`- Specialized database for search, specifically with vector comparison features
+
+### Immich Server
+
+The Immich Server is a [TypeScript](https://www.typescriptlang.org/) project written for [Node.js](https://nodejs.org/). It uses the [Nest.js](https://nestjs.com) framework, with [TypeORM](https://typeorm.io/) for database management. The server codebase also loosely follows the [Hexagonal Architecture](<https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)>). Specifically, we aim to separate technology specific implementations (`infra/`) from core business logic (`domain/`).
+
+#### REST Endpoints
+
+The server is a list of HTTP endpoints and associated handlers (controllers). Each controller usually implements the following CRUD operations:
+
+- `POST` `/<type>` - **Create**
+- `GET` `/<type>` - **Read** (all)
+- `GET` `/<type>/:id` - **Read** (by id)
+- `PUT` `/<type>/:id` - **Updated** (by id)
+- `DELETE` `/<type>/:id` - **Delete** (by id)
+
+#### DTOs
+
+The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](./open-api.md) schemas and control the generated code used by each client.
+
+### Microservices
+
+The Immich Microservices image uses the same `Dockerfile` as the Immich Server, but with a different entrypoint. The Immich Microservices service mainly handles executing jobs, which include the following:
+
+- Thumbnail Generation
+- Metadata Extraction
+- Video Transcoding
+- Object Tagging
+- Facial Recognition
+- Storage Template Migration
+- Search (Typesense synchronization)
+- Sidecar (see [XMP Sidecars](/docs/features/xmp-sidecars.md))
+- Background jobs (file deletion, user deletion)
+
+:::info
+This list closely matches what is available on the [Administration > Jobs](/docs/administration/jobs.md) page, which provides some remote queue management capabilities.
+:::
+
+### Machine Learning
 
-Immich is a full-stack [TypeScript](https://www.typescriptlang.org/) application, with a [Flutter](https://flutter.dev/) mobile app.
+The machine learning service is written in [Python](https://www.python.org/) and uses [FastAPI](https://fastapi.tiangolo.com/) for HTTP communication.
 
-### Mobile
+All machine learning related operations have been externalized to this service, `immich-machine-learning`. Python is a natural choice for AI and machine learning. It also has some pretty specific hardware requirements. Running it as a separate container makes it possible to run the container on a separate machine, or easily disable it entirely.
 
-- [Flutter](https://flutter.dev/)
-- [Riverpod](https://riverpod.dev/) for state management.
+Machine learning models are also quite _large_, requiring _quite a bit_ of memory. We are always looking for ways to improve and optimize this aspect of this container specifically.
 
-### Web
+### Postgres
 
-- [SvelteKit](https://kit.svelte.dev/)
-- [Tailwindcss](https://tailwindcss.com/)
+Immich persists data in Postgres, which includes information about access and authorization, users, albums, asset, sharing settings, etc.
 
-### Server
+:::info
+See [Database Migrations](./database-migrations.md) for more information about how to modify the database to create an index, modify a table, add a new column, etc.
+:::
 
-- [Node.js](https://nodejs.org/)
-- [Nest.js](https://nestjs.com/)
-- [TypeORM](https://typeorm.io/) for database management.
-- [Jest](https://jestjs.io/) for testing.
-- [Python](https://www.python.org/) for Machine Learning.
+### Redis
 
-### Database
+Immich uses [Redis](https://redis.com/) via [BullMQ](https://docs.bullmq.io/) to manage job queues. Some jobs trigger subsequent jobs. For example, object detection relies on thumbnail generation and automatically run after one is generated.
 
-- [PostgreSQL](https://www.postgresql.org/)
-- [Redis](https://redis.io/) for job queuing.
-- [Typesense](https://typesense.org/) for search.
+### Typesense
 
-### Web Server
+Immich synchronizes some of the Postgres data into Typesense, so it can execute vector related queries in order to implement certain features including, facial recognition and CLIP search.
 
-- [NGINX](https://www.nginx.com/) for internal communication between containers and load balancing when scaling.
+<!-- - [NGINX](https://www.nginx.com/) for internal communication between containers and load balancing when scaling. -->

+ 22 - 0
docs/docs/developer/directories.md

@@ -0,0 +1,22 @@
+---
+title: Directories
+---
+
+# Repository Folder Structure
+
+Our [GitHub Repository](https://github.com/immich-app/immich) is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) and includes the following folders:
+
+| Folder              | Description                                                          |
+| :------------------ | :------------------------------------------------------------------- |
+| `.github/`          | Github templates and action workflows                                |
+| `.vscode/`          | VSCode debug launch profiles                                         |
+| `cli/`              | Source code for the work-in-progress CLI rewrite                     |
+| `docker/`           | Docker compose resources for dev, test, production                   |
+| `design/`           | Screenshots and logos for the README                                 |
+| `docs/`             | Source code for the [https://immich.app](https://immich.app) website |
+| `machine-learning/` | Source code for the `immich-machine-learning` docker image           |
+| `misc/release/`     | Scripts for version pumps and draft releases                         |
+| `mobile/`           | Source code for the mobile app, both Android and iOS                 |
+| `nginx/`            | Source code for the `immich-proxy` docker image                      |
+| `server/`           | Source code for the `immich-server` docker image                     |
+| `web/`              | Source code for the `immich-web` docker image                        |

+ 60 - 0
docs/docs/features/hardware-transcoding.md

@@ -0,0 +1,60 @@
+# Hardware Transcoding [Experimental]
+
+This feature allows you to use a GPU or Intel Quick Sync to accelerate transcoding and reduce CPU load.
+Note that hardware transcoding is much less efficient for file sizes.
+As this is a new feature, it is still experimental and may not work on all systems.
+
+## Supported APIs
+
+- NVENC
+  - NVIDIA GPUs
+- Quick Sync
+  - Intel CPUs
+- VAAPI
+  - GPUs
+
+## Limitations
+
+- The instructions and configurations here are specific to Docker Compose. Other container engines may require different configuration.
+- Only Linux and Windows (through WSL2) servers are supported.
+- WSL2 does not support Quick Sync.
+- Raspberry Pi is currently not supported.
+- Two-pass mode is only supported for NVENC. Other APIs will ignore this setting.
+- Only encoding is currently hardware accelerated, so the CPU is still used for software decoding.
+  - This is mainly because the original video may not be hardware-decodable.
+- Hardware dependent
+  - Codec support varies, but H.264 and HEVC are usually supported.
+    - Notably, NVIDIA and AMD GPUs do not support VP9 encoding.
+  - Newer devices tend to have higher transcoding quality.
+
+## Prerequisites
+
+#### NVENC
+
+- You must have the official NVIDIA driver installed on the server.
+- On Linux (except for WSL2), you also need to have [NVIDIA Container Runtime][nvcr] installed.
+
+#### QSV
+
+- For VP9 to work:
+  - You must have a 9th gen Intel CPU or newer
+  - If you have an 11th gen CPU or older, then you may need to follow [these][jellyfin-lp] instructions as Low-Power mode is required
+  - Additionally, if the server specifically has an 11th gen CPU and is running kernel 5.15 (shipped with Ubuntu 22.04 LTS), then you will need to upgrade this kernel (from [Jellyfin docs][jellyfin-kernel-bug])
+
+## Setup
+
+1. If you do not already have it, download the latest [`hwaccel.yml`][hw-file] file and ensure it's in the same folder as the `docker-compose.yml`.
+2. Uncomment the lines that apply to your system and desired usage.
+3. In the `docker-compose.yml` under `immich-microservices`, uncomment the lines relating to the `hwaccel.yml` file.
+4. Redeploy the `immich-microservices` container with these updated settings.
+5. In the Admin page under `FFmpeg settings`, change the hardware acceleration setting to the appropriate option and save.
+
+## Tips
+
+- You may want to choose a slower preset than for software transcoding to maintain quality and efficiency
+- While you can use VAAPI with Nvidia GPUs and Intel CPUs, prefer the more specific APIs since they're more optimized for their respective devices
+
+[hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.yml
+[nvcr]: https://github.com/NVIDIA/nvidia-container-runtime/
+[jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux
+[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations

+ 13 - 0
docs/docs/install/docker-compose.md

@@ -25,10 +25,18 @@ wget https://github.com/immich-app/immich/releases/latest/download/docker-compos
 wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
 ```
 
+```bash title="(Optional) Get hwaccel.yml file"
+wget https://github.com/immich-app/immich/releases/latest/download/hwaccel.yml
+```
+
 or by downloading from your browser and moving the files to the directory that you created.
 
 Note: If you downloaded the files from your browser, also ensure that you rename `example.env` to `.env`.
 
+:::info
+Optionally, you can use the [`hwaccel.yml`][hw-file] file to enable hardware acceleration for transcoding. See the [Hardware Transcoding](/docs/features/hardware-transcoding.md) guide for info on how to set this up.
+:::
+
 ### Step 2 - Populate the .env file with custom values
 
 <details>
@@ -166,6 +174,10 @@ docker-compose up -d     # or `docker compose up -d` based on your docker-compos
 For more information on how to use the application, please refer to the [Post Installation](/docs/install/post-install.mdx) guide.
 :::
 
+:::tip
+Note that downloading container images might require you to authenticate to the GitHub Container Registry ([steps here](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry)).
+:::
+
 ### Step 4 - Upgrading
 
 If `IMMICH_VERSION` is set, it will need to be updated to the latest or desired version.
@@ -182,4 +194,5 @@ Immich is currently under heavy development, which means you can expect breaking
 
 [compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
 [env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
+[hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.yml
 [watchtower]: https://containrrr.dev/watchtower/

+ 21 - 0
docs/docs/install/environment-variables.md

@@ -184,3 +184,24 @@ Typesense URL example JSON before encoding:
 | `MACHINE_LEARNING_CLASSIFICATION_MODEL`     | Classification Model           | `microsoft/resnet-50` | machine learning |
 | `MACHINE_LEARNING_CACHE_FOLDER`             | ML Cache Location              |       `/cache`        | machine learning |
 | `TRANSFORMERS_CACHE`                        | ML Transformers Cache Location |       `/cache`        | machine learning |
+
+## Docker Secrets
+
+The following variables support the use of [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for additional security.
+
+To use any of these, replace the regular environment variable with the equivalent `_FILE` environment variable. The value of
+the `_FILE` variable should be set to the path of a file containing the variable value.
+
+|  Regular Variable  | Equivalent Docker Secrets '\_FILE' Variable |
+| :----------------: | :-----------------------------------------: |
+|   `DB_HOSTNAME`    |      `DB_HOSTNAME_FILE`<sup>\*1</sup>       |
+| `DB_DATABASE_NAME` |    `DB_DATABASE_NAME_FILE`<sup>\*1</sup>    |
+|   `DB_USERNAME`    |      `DB_USERNAME_FILE`<sup>\*1</sup>       |
+|   `DB_PASSWORD`    |      `DB_PASSWORD_FILE`<sup>\*1</sup>       |
+|  `REDIS_PASSWORD`  |     `REDIS_PASSWORD_FILE`<sup>\*2</sup>     |
+
+\*1: See the [official documentation](https://github.com/docker-library/docs/tree/master/postgres#docker-secrets) for
+details on how to use Docker Secrets in the Postgres image.
+
+\*2: See [this comment](https://github.com/docker-library/redis/issues/46#issuecomment-335326234) for an example of how
+to use use a Docker secret for the password in the Redis container.

+ 1 - 1
docs/docs/overview/logo.md

@@ -10,7 +10,7 @@ I really like the Japanese culture, especially the books, history, and food. The
 
 ![Oda_emblem](https://user-images.githubusercontent.com/27055614/182044504-a5ed33a8-5640-42de-b359-18fdbee9fb90.svg)
 
-One of my favorite books is [Taikō](https://www.goodreads.com/book/show/336228.Taiko), it is a story about a prominent figure in the history of Japan, [Toyotomy Hideyoshi](https://www.britannica.com/biography/Toyotomi-Hideyoshi). He came from nothing, and through his resilience and wonderful mind, he has become one of the most powerful rulers in Japan's history. I enjoy his personality and the way he moved through life.
+One of my favorite books is [Taikō](https://www.goodreads.com/book/show/336228.Taiko), it is a story about a prominent figure in the history of Japan, [Toyotomi Hideyoshi](https://www.britannica.com/biography/Toyotomi-Hideyoshi). He came from nothing, and through his resilience and wonderful mind, he has become one of the most powerful rulers in Japan's history. I enjoy his personality and the way he moved through life.
 
 The color is an adaptation of **_App-Which-Must-Not-Be-Named_**'s color scheme, with an extra color (pink) to complete the flower's fifth petal. The petal layers are the same color scheme as the main layer rotating back and forth to "bring the flower to life."
 

+ 2 - 2
docs/docs/partials/_storage-template.md

@@ -1,6 +1,6 @@
 Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level.
 
-The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text.
+The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
 
 ```bash title="Default template"
 Year/Year-Month-Day/Filename.Extension
@@ -8,4 +8,4 @@ Year/Year-Month-Day/Filename.Extension
 
 <img src={require('./img/storage-template.png').default} width="100%" title="Storage Template Setting" />
 
-Immich also provides a mechanism to migrate between template so that if the template you set now doesn't work in the future, you can always migrate all the existing files to the new template. The mechanism is run as a job in the Job page.
+Immich also provides a mechanism to migrate between templates so that if the template you set now doesn't work in the future, you can always migrate all the existing files to the new template. The mechanism is run as a job on the Job page.

+ 4 - 1
machine-learning/Dockerfile

@@ -15,6 +15,8 @@ RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
 
 FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd
 
+RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
+
 WORKDIR /usr/src/app
 ENV NODE_ENV=production \
   TRANSFORMERS_CACHE=/cache \
@@ -25,4 +27,5 @@ ENV NODE_ENV=production \
 
 COPY --from=builder /opt/venv /opt/venv
 COPY app .
-ENTRYPOINT ["python", "-m", "app.main"]
+ENTRYPOINT ["tini", "--"]
+CMD ["python", "-m", "app.main"]

+ 21 - 0
machine-learning/README_es_ES.md

@@ -0,0 +1,21 @@
+# Immich Machine Learning
+
+- Clasificación de imágenes
+- Incorporación de CLIP
+- Reconocimiento facial
+
+# Configuración
+
+Este proyecto utiliza [Poetry](https://python-poetry.org/docs/#installation), así que asegúrate de instalarlo primero.
+Ejecutar `poetry install --no-root --with dev` instalará todo lo necesario en un entorno virtual aislado.
+
+Para agregar o eliminar dependencias, puedes utilizar los comandos `poetry add $PACKAGE_NAME` y `poetry remove $PACKAGE_NAME`, respectivamente.
+Asegúrate de hacer commit de los archivos `poetry.lock` y `pyproject.toml` para reflejar cualquier cambio en las dependencias.
+
+# Pruebas de carga
+
+Para medir la velocidad y latencia de inferencia, puedes utilizar [Locust](https://locust.io/) con el archivo `locustfile.py` proporcionado.
+Locust funciona haciendo consultas a los puntos finales del modelo y agregando estadísticas, lo que significa que la aplicación debe estar desplegada.
+Puedes ejecutar `load_test.sh` para implementar automáticamente la aplicación localmente e iniciar Locust, ajustando opcionalmente sus variables de entorno según sea necesario.
+
+Alternativamente, para pruebas más personalizadas, también puedes ejecutar `locust` directamente: consulta la [documentación](https://docs.locust.io/en/stable/index.html). Ten en cuenta que, en la jerga de Locust, la concurrencia se mide en `usuarios`, y cada usuario ejecuta una tarea a la vez. Para lograr una concurrencia específica por punto final, multiplica ese número por la cantidad de puntos finales que se desean consultar. Por ejemplo, si hay 3 puntos finales y deseas que cada uno de ellos reciba 8 solicitudes al mismo tiempo, debes configurar el número de usuarios en 24.

文件差异内容过多而无法显示
+ 445 - 383
machine-learning/poetry.lock


+ 3 - 3
machine-learning/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.67.2"
+version = "1.71.0"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
@@ -22,8 +22,6 @@ fastapi = "^0.95.2"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 pydantic = "^1.10.8"
 aiocache = "^0.12.1"
-pytest-cov = "^4.1.0"
-ruff = "^0.0.272"
 
 [tool.poetry.group.dev.dependencies]
 mypy = "^1.3.0"
@@ -33,6 +31,8 @@ locust = "^2.15.1"
 gunicorn = "^20.1.0"
 httpx = "^0.24.1"
 pytest-asyncio = "^0.21.0"
+pytest-cov = "^4.1.0"
+ruff = "^0.0.272"
 
 [[tool.poetry.source]]
 name = "pytorch-cpu"

+ 1 - 1
mobile/android/.gitignore

@@ -13,4 +13,4 @@ key.properties
 **/*.jks
 
 # Fastlane
-/fastlane/report.xml
+fastlane/report.xml

+ 2 - 2
mobile/android/fastlane/Fastfile

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

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

@@ -5,17 +5,17 @@
     
     
       
-      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000296">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="64.042552">
+      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="68.788432">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.676557">
+      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.76592">
         
       </testcase>
     

+ 3 - 2
mobile/assets/i18n/en-US.json

@@ -84,7 +84,7 @@
   "backup_controller_page_status_off": "Automatic foreground backup is off",
   "backup_controller_page_status_on": "Automatic foreground backup is on",
   "backup_controller_page_storage_format": "{} of {} used",
-  "backup_controller_page_to_backup": "Albums to be backup",
+  "backup_controller_page_to_backup": "Albums to be backed up",
   "backup_controller_page_total": "Total",
   "backup_controller_page_total_sub": "All unique photos and videos from selected albums",
   "backup_controller_page_turn_off": "Turn off foreground backup",
@@ -186,6 +186,7 @@
   "login_form_save_login": "Stay logged in",
   "login_form_server_empty": "Enter a server URL.",
   "login_form_server_error": "Could not connect to server.",
+  "login_disabled": "Login has been disabled",
   "monthly_title_text_date_format": "MMMM y",
   "motion_photos_page_title": "Motion Photos",
   "notification_permission_dialog_cancel": "Cancel",
@@ -290,4 +291,4 @@
   "version_announcement_overlay_text_2": "please take your time to visit the ",
   "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
   "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
-}
+}

+ 3 - 1
mobile/ios/.gitignore

@@ -31,4 +31,6 @@ Runner/GeneratedPluginRegistrant.*
 !default.mode1v3
 !default.mode2v3
 !default.pbxuser
-!default.perspectivev3
+!default.perspectivev3
+
+fastlane/report.xml

+ 1 - 1
mobile/ios/Podfile.lock

@@ -157,4 +157,4 @@ SPEC CHECKSUMS:
 
 PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
 
-COCOAPODS: 1.11.3
+COCOAPODS: 1.12.1

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

@@ -379,7 +379,7 @@
 				CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 110;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 110;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 110;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;

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

@@ -59,11 +59,11 @@
     <key>CFBundlePackageType</key>
     <string>APPL</string>
     <key>CFBundleShortVersionString</key>
-    <string>1.57.0</string>
+    <string>1.70.0</string>
     <key>CFBundleSignature</key>
     <string>????</string>
     <key>CFBundleVersion</key>
-    <string>97</string>
+    <string>110</string>
     <key>FLTEnableImpeller</key>
     <true />
     <key>ITSAppUsesNonExemptEncryption</key>

+ 1 - 1
mobile/ios/fastlane/Fastfile

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

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

@@ -5,32 +5,32 @@
     
     
       
-      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000407">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.988375">
+      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.108738">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="45.42439">
+      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="28.952846">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.381359">
+      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.821481">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="4: build_app" time="94.653021">
+      <testcase classname="fastlane.lanes" name="4: build_app" time="99.212621">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.237354">
+      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.366701">
         
       </testcase>
     

+ 3 - 0
mobile/lib/main.dart

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'
 import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
+import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
 import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
 import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
@@ -156,6 +157,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
 
         ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
 
+        ref.invalidate(memoryFutureProvider);
+
         break;
 
       case AppLifecycleState.inactive:

+ 49 - 0
mobile/lib/modules/album/models/add_asset_response.model.dart

@@ -0,0 +1,49 @@
+// ignore_for_file: public_member_api_docs, sort_constructors_first
+import 'dart:convert';
+
+import 'package:collection/collection.dart';
+
+class AddAssetsResponse {
+  List<String> alreadyInAlbum;
+  int successfullyAdded;
+
+  AddAssetsResponse({
+    required this.alreadyInAlbum,
+    required this.successfullyAdded,
+  });
+
+  AddAssetsResponse copyWith({
+    List<String>? alreadyInAlbum,
+    int? successfullyAdded,
+  }) {
+    return AddAssetsResponse(
+      alreadyInAlbum: alreadyInAlbum ?? this.alreadyInAlbum,
+      successfullyAdded: successfullyAdded ?? this.successfullyAdded,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return <String, dynamic>{
+      'alreadyInAlbum': alreadyInAlbum,
+      'successfullyAdded': successfullyAdded,
+    };
+  }
+
+  String toJson() => json.encode(toMap());
+
+  @override
+  String toString() =>
+      'AddAssetsResponse(alreadyInAlbum: $alreadyInAlbum, successfullyAdded: $successfullyAdded)';
+
+  @override
+  bool operator ==(covariant AddAssetsResponse other) {
+    if (identical(this, other)) return true;
+    final listEquals = const DeepCollectionEquality().equals;
+
+    return listEquals(other.alreadyInAlbum, alreadyInAlbum) &&
+        other.successfullyAdded == successfullyAdded;
+  }
+
+  @override
+  int get hashCode => alreadyInAlbum.hashCode ^ successfullyAdded.hashCode;
+}

+ 21 - 0
mobile/lib/modules/album/providers/album_detail.provider.dart

@@ -0,0 +1,21 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/services/album.service.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/shared/models/album.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
+
+final albumDetailProvider =
+    StreamProvider.family<Album, int>((ref, albumId) async* {
+  final user = ref.watch(currentUserProvider);
+  if (user == null) return;
+  final AlbumService service = ref.watch(albumServiceProvider);
+
+  await for (final a in service.watchAlbum(albumId)) {
+    if (a == null) {
+      throw Exception("Album with ID=$albumId does not exist anymore!");
+    }
+    await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
+      yield a;
+    }
+  }
+});

+ 0 - 18
mobile/lib/modules/album/providers/shared_album.provider.dart

@@ -3,12 +3,10 @@ import 'dart:async';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
-import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
-import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:isar/isar.dart';
 
 class SharedAlbumNotifier extends StateNotifier<List<Album>> {
@@ -72,19 +70,3 @@ final sharedAlbumProvider =
     ref.watch(dbProvider),
   );
 });
-
-final sharedAlbumDetailProvider =
-    StreamProvider.family<Album, int>((ref, albumId) async* {
-  final user = ref.watch(currentUserProvider);
-  if (user == null) return;
-  final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
-
-  await for (final a in sharedAlbumService.watchAlbum(albumId)) {
-    if (a == null) {
-      throw Exception("Album with ID=$albumId does not exist anymore!");
-    }
-    await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
-      yield a;
-    }
-  }
-});

+ 28 - 8
mobile/lib/modules/album/services/album.service.dart

@@ -5,6 +5,7 @@ import 'dart:io';
 import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart';
 import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
 import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 import 'package:immich_mobile/shared/models/album.dart';
@@ -219,24 +220,43 @@ class AlbumService {
     yield* _db.albums.watchObject(albumId);
   }
 
-  Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
+  Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
     Iterable<Asset> assets,
     Album album,
   ) async {
     try {
-      var result = await _apiService.albumApi.addAssetsToAlbum(
+      var response = await _apiService.albumApi.addAssetsToAlbum(
         album.remoteId!,
-        AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
+        BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
       );
-      if (result != null && result.successfullyAdded > 0) {
-        album.assets.addAll(assets);
+
+      if (response != null) {
+        List<Asset> successAssets = [];
+        List<String> duplicatedAssets = [];
+
+        for (final result in response) {
+          if (result.success) {
+            successAssets
+                .add(assets.firstWhere((asset) => asset.remoteId == result.id));
+          } else if (!result.success &&
+              result.error == BulkIdResponseDtoErrorEnum.duplicate) {
+            duplicatedAssets.add(result.id);
+          }
+        }
+
+        album.assets.addAll(successAssets);
         await _db.writeTxn(() => album.assets.save());
+
+        return AddAssetsResponse(
+          alreadyInAlbum: duplicatedAssets,
+          successfullyAdded: successAssets.length,
+        );
       }
-      return result;
     } catch (e) {
       debugPrint("Error addAdditionalAssetToAlbum  ${e.toString()}");
       return null;
     }
+    return null;
   }
 
   Future<bool> addAdditionalUserToAlbum(
@@ -314,8 +334,8 @@ class AlbumService {
     try {
       await _apiService.albumApi.removeAssetFromAlbum(
         album.remoteId!,
-        RemoveAssetsDto(
-          assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
+        BulkIdsDto(
+          ids: assets.map((asset) => asset.remoteId!).toList(),
         ),
       );
       album.assets.removeAll(assets);

+ 2 - 3
mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
+import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
 import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
@@ -63,9 +64,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
         }
       }
 
-      ref.read(albumProvider.notifier).getAllAlbums();
-      ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
-
+      ref.invalidate(albumDetailProvider(album.id));
       Navigator.pop(context);
     }
 

+ 2 - 1
mobile/lib/modules/album/ui/album_viewer_appbar.dart

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
+import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
 import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
 import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
@@ -99,7 +100,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         Navigator.pop(context);
         selectionDisabled();
         ref.watch(albumProvider.notifier).getAllAlbums();
-        ref.invalidate(sharedAlbumDetailProvider(album.id));
+        ref.invalidate(albumDetailProvider(album.id));
       } else {
         Navigator.pop(context);
         ImmichToast.show(

+ 13 - 6
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -6,13 +6,12 @@ import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
-import 'package:immich_mobile/modules/album/providers/album.provider.dart';
+import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
 import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
-import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
 import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
@@ -28,11 +27,20 @@ class AlbumViewerPage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     FocusNode titleFocusNode = useFocusNode();
-    final album = ref.watch(sharedAlbumDetailProvider(albumId));
+    final album = ref.watch(albumDetailProvider(albumId));
     final userId = ref.watch(authenticationProvider).userId;
     final selection = useState<Set<Asset>>({});
     final multiSelectEnabled = useState(false);
 
+    useEffect(
+      () {
+        // Fetch album updates, e.g., cover image
+        ref.invalidate(albumDetailProvider(albumId));
+        return null;
+      },
+      [],
+    );
+
     Future<bool> onWillPop() async {
       if (multiSelectEnabled.value) {
         selection.value = {};
@@ -77,8 +85,7 @@ class AlbumViewerPage extends HookConsumerWidget {
 
           if (addAssetsResult != null &&
               addAssetsResult.successfullyAdded > 0) {
-            ref.watch(albumProvider.notifier).getAllAlbums();
-            ref.invalidate(sharedAlbumDetailProvider(albumId));
+            ref.invalidate(albumDetailProvider(albumId));
           }
 
           ImmichLoadingOverlayController.appLoader.hide();
@@ -100,7 +107,7 @@ class AlbumViewerPage extends HookConsumerWidget {
             .addAdditionalUserToAlbum(sharedUserIds, album);
 
         if (isSuccess) {
-          ref.invalidate(sharedAlbumDetailProvider(album.id));
+          ref.invalidate(albumDetailProvider(album.id));
         }
 
         ImmichLoadingOverlayController.appLoader.hide();

+ 1 - 1
mobile/lib/modules/album/views/create_album_page.dart

@@ -30,7 +30,7 @@ class CreateAlbumPage extends HookConsumerWidget {
     final albumTitleTextFieldFocusNode = useFocusNode();
     final isAlbumTitleTextFieldFocus = useState(false);
     final isAlbumTitleEmpty = useState(true);
-    final selectedAssets = useState<Set<Asset>>(const {});
+    final selectedAssets = useState<Set<Asset>>(initialAssets != null ? Set.from(initialAssets!) : const {});
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
 
     showSelectUserPage() async {

+ 5 - 2
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -239,6 +239,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     void handleSwipeUpDown(DragUpdateDetails details) {
       int sensitivity = 15;
       int dxThreshold = 50;
+      double ratioThreshold = 3.0;
 
       if (isZoomed.value) {
         return;
@@ -256,9 +257,10 @@ class GalleryViewerPage extends HookConsumerWidget {
         return;
       }
 
-      if (details.delta.dy > sensitivity) {
+      final ratio = d.dy / max(d.dx.abs(), 1);
+      if (d.dy > sensitivity && ratio > ratioThreshold) {
         AutoRouter.of(context).pop();
-      } else if (details.delta.dy < -sensitivity) {
+      } else if (d.dy < -sensitivity && ratio < -ratioThreshold) {
         showInfo();
       }
     }
@@ -499,6 +501,7 @@ class GalleryViewerPage extends HookConsumerWidget {
             PhotoViewGallery.builder(
               scaleStateChangedCallback: (state) {
                 isZoomed.value = state != PhotoViewScaleState.initial;
+                ref.read(showControlsProvider.notifier).show = !isZoomed.value;
               },
               pageController: controller,
               scrollPhysics: isZoomed.value

+ 1 - 1
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -34,7 +34,7 @@ class VideoViewerPage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    if (asset.storage == AssetState.local && asset.livePhotoVideoId == null) {
+    if (asset.isLocal && asset.livePhotoVideoId == null) {
       final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
       return videoFile.when(
         data: (data) => VideoPlayer(

+ 25 - 13
mobile/lib/modules/backup/providers/backup.provider.dart

@@ -207,6 +207,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       type: RequestType.common,
     );
 
+    // Map of id -> album for quick album lookup later on.
+    Map<String, AssetPathEntity> albumMap = {};
+
     log.info('Found ${albums.length} local albums');
 
     for (AssetPathEntity album in albums) {
@@ -235,6 +238,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         }
 
         availableAlbums.add(availableAlbum);
+
+        albumMap[album.id] = album;
       }
     }
 
@@ -270,30 +275,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     }
 
     // Generate AssetPathEntity from id to add to local state
-    try {
-      final Set<AvailableAlbum> selectedAlbums = {};
-      for (final BackupAlbum ba in selectedBackupAlbums) {
-        final albumAsset = await AssetPathEntity.fromId(ba.id);
+    final Set<AvailableAlbum> selectedAlbums = {};
+    for (final BackupAlbum ba in selectedBackupAlbums) {
+      final albumAsset = albumMap[ba.id];
+
+      if (albumAsset != null) {
         selectedAlbums.add(
           AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
         );
+      } else {
+        log.severe('Selected album not found');
       }
+    }
+
+    final Set<AvailableAlbum> excludedAlbums = {};
+    for (final BackupAlbum ba in excludedBackupAlbums) {
+      final albumAsset = albumMap[ba.id];
 
-      final Set<AvailableAlbum> excludedAlbums = {};
-      for (final BackupAlbum ba in excludedBackupAlbums) {
-        final albumAsset = await AssetPathEntity.fromId(ba.id);
+      if (albumAsset != null) {
         excludedAlbums.add(
           AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
         );
+      } else {
+        log.severe('Excluded album not found');
       }
-      state = state.copyWith(
-        selectedBackupAlbums: selectedAlbums,
-        excludedBackupAlbums: excludedAlbums,
-      );
-    } catch (e, stackTrace) {
-      log.severe("Failed to generate album from id", e, stackTrace);
     }
 
+    state = state.copyWith(
+      selectedBackupAlbums: selectedAlbums,
+      excludedBackupAlbums: excludedAlbums,
+    );
+
     debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
   }
 

+ 5 - 2
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -341,8 +341,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
             backgroundColor: Theme.of(context).hintColor,
             labelTextBuilder: _labelBuilder,
             labelConstraints: const BoxConstraints(maxHeight: 28),
-            scrollbarAnimationDuration: const Duration(seconds: 1),
-            scrollbarTimeToFade: const Duration(seconds: 4),
+            scrollbarAnimationDuration: const Duration(milliseconds: 300),
+            scrollbarTimeToFade: const Duration(milliseconds: 1000),
             child: listWidget,
           )
         : listWidget;
@@ -381,6 +381,9 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
     if (widget.visibleItemsListener != null) {
       _itemPositionsListener.itemPositions.addListener(_positionListener);
     }
+    if (widget.preselectedAssets != null) {
+      _selectedAssets.addAll(widget.preselectedAssets!);
+    }
   }
 
   @override

+ 46 - 11
mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart

@@ -37,11 +37,21 @@ class ThumbnailImage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
+    final assetContainerColor =
+        isDarkTheme ? Colors.blueGrey : Theme.of(context).primaryColorLight;
+
     Widget buildSelectionIcon(Asset asset) {
       if (isSelected) {
-        return Icon(
-          Icons.check_circle,
-          color: Theme.of(context).primaryColor,
+        return Container(
+          decoration: BoxDecoration(
+            shape: BoxShape.circle,
+            color: assetContainerColor,
+          ),
+          child: Icon(
+            Icons.check_circle_rounded,
+            color: Theme.of(context).primaryColor,
+          ),
         );
       } else {
         return const Icon(
@@ -51,6 +61,36 @@ class ThumbnailImage extends HookConsumerWidget {
       }
     }
 
+    Widget buildImage(Asset asset) {
+      var image = ImmichImage(
+        asset,
+        width: 300,
+        height: 300,
+        useGrayBoxPlaceholder: useGrayBoxPlaceholder,
+      );
+      if (!multiselectEnabled || !isSelected) {
+        return image;
+      }
+      return Container(
+        decoration: BoxDecoration(
+          border: Border.all(
+            width: 0,
+            color: assetContainerColor,
+          ),
+          color: assetContainerColor,
+        ),
+        child: ClipRRect(
+          borderRadius: const BorderRadius.only(
+            topRight: Radius.circular(15.0),
+            bottomRight: Radius.circular(15.0),
+            bottomLeft: Radius.circular(15.0),
+            topLeft: Radius.zero,
+          ),
+          child: image,
+        ),
+      );
+    }
+
     return GestureDetector(
       onTap: () {
         if (multiselectEnabled) {
@@ -84,17 +124,12 @@ class ThumbnailImage extends HookConsumerWidget {
                     ? Border.all(
                         color: onDeselect == null
                             ? Colors.grey
-                            : Theme.of(context).primaryColorLight,
-                        width: 10,
+                            : assetContainerColor,
+                        width: 8,
                       )
                     : const Border(),
               ),
-              child: ImmichImage(
-                asset,
-                width: 300,
-                height: 300,
-                useGrayBoxPlaceholder: useGrayBoxPlaceholder,
-              ),
+              child: buildImage(asset),
             ),
             if (multiselectEnabled)
               Padding(

+ 4 - 0
mobile/lib/modules/home/views/home_page.dart

@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
+import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
 import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
@@ -208,6 +209,9 @@ class HomePage extends HookConsumerWidget {
                 ),
                 toastType: ToastType.success,
               );
+
+              ref.watch(albumProvider.notifier).getAllAlbums();
+              ref.invalidate(albumDetailProvider(album.id));
             }
           }
         } finally {

+ 56 - 27
mobile/lib/modules/login/providers/authentication.provider.dart

@@ -37,6 +37,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
 
   final ApiService _apiService;
   final Isar _db;
+  final _log = Logger("AuthenticationNotifier");
 
   Future<bool> login(
     String email,
@@ -145,38 +146,66 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
   Future<bool> setSuccessLoginInfo({
     required String accessToken,
     required String serverUrl,
+    bool offlineLogin = false,
   }) async {
     _apiService.setAccessToken(accessToken);
-    UserResponseDto? userResponseDto;
-    try {
-      userResponseDto = await _apiService.userApi.getMyUserInfo();
-    } on ApiException catch (e) {
-      if (e.innerException is SocketException) {
-        state = state.copyWith(isAuthenticated: true);
+
+    // Get the deviceid from the store if it exists, otherwise generate a new one
+    String deviceId =
+        Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
+
+    bool shouldChangePassword = false;
+    User? user;
+
+    bool retResult = false;
+    User? offlineUser = Store.tryGet(StoreKey.currentUser);
+
+    // If the user is offline and there is a user saved on the device,
+    // if not try an online login
+    if (offlineLogin && offlineUser != null) {
+      user = offlineUser;
+      retResult = false;
+    } else {
+      UserResponseDto? userResponseDto;
+      try {
+        userResponseDto = await _apiService.userApi.getMyUserInfo();
+      } on ApiException catch (e) {
+        if (e.innerException is SocketException) {
+          state = state.copyWith(isAuthenticated: true);
+        }
       }
-    }
 
-    if (userResponseDto != null) {
-      final deviceId = await FlutterUdid.consistentUdid;
-      Store.put(StoreKey.deviceId, deviceId);
-      Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
-      Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
-      Store.put(StoreKey.serverUrl, serverUrl);
-      Store.put(StoreKey.accessToken, accessToken);
-
-      state = state.copyWith(
-        isAuthenticated: true,
-        userId: userResponseDto.id,
-        userEmail: userResponseDto.email,
-        firstName: userResponseDto.firstName,
-        lastName: userResponseDto.lastName,
-        profileImagePath: userResponseDto.profileImagePath,
-        isAdmin: userResponseDto.isAdmin,
-        shouldChangePassword: userResponseDto.shouldChangePassword,
-        deviceId: deviceId,
-      );
+      if (userResponseDto != null) {
+        Store.put(StoreKey.deviceId, deviceId);
+        Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
+        Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
+        Store.put(StoreKey.serverUrl, serverUrl);
+        Store.put(StoreKey.accessToken, accessToken);
+
+        shouldChangePassword = userResponseDto.shouldChangePassword;
+        user = User.fromDto(userResponseDto);
+
+        retResult = true;
+      }
+      else {
+        _log.severe("Unable to get user information from the server.");
+        return false;
+      }
     }
-    return true;
+
+    state = state.copyWith(
+      isAuthenticated: true,
+      userId: user.id,
+      userEmail: user.email,
+      firstName: user.firstName,
+      lastName: user.lastName,
+      profileImagePath: user.profileImagePath,
+      isAdmin: user.isAdmin,
+      shouldChangePassword: shouldChangePassword,
+      deviceId: deviceId,
+    );
+
+    return retResult;
   }
 }
 

+ 36 - 23
mobile/lib/modules/login/ui/login_form.dart

@@ -36,6 +36,7 @@ class LoginForm extends HookConsumerWidget {
     final isLoading = useState<bool>(false);
     final isLoadingServer = useState<bool>(false);
     final isOauthEnable = useState<bool>(false);
+    final isPasswordLoginEnable = useState<bool>(false);
     final oAuthButtonLabel = useState<String>('OAuth');
     final logoAnimationController = useAnimationController(
       duration: const Duration(seconds: 60),
@@ -69,9 +70,11 @@ class LoginForm extends HookConsumerWidget {
 
         if (loginConfig != null) {
           isOauthEnable.value = loginConfig.enabled;
+          isPasswordLoginEnable.value = loginConfig.passwordLoginEnabled;
           oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
         } else {
           isOauthEnable.value = false;
+          isPasswordLoginEnable.value = true;
         }
 
         serverEndpoint.value = endpoint;
@@ -82,6 +85,7 @@ class LoginForm extends HookConsumerWidget {
           toastType: ToastType.error,
         );
         isOauthEnable.value = false;
+        isPasswordLoginEnable.value = true;
         isLoadingServer.value = false;
         return false;
       } catch (e) {
@@ -91,6 +95,7 @@ class LoginForm extends HookConsumerWidget {
           toastType: ToastType.error,
         );
         isOauthEnable.value = false;
+        isPasswordLoginEnable.value = true;
         isLoadingServer.value = false;
         return false;
       }
@@ -262,18 +267,20 @@ class LoginForm extends HookConsumerWidget {
               style: Theme.of(context).textTheme.displaySmall,
               textAlign: TextAlign.center,
             ),
-            const SizedBox(height: 18),
-            EmailInput(
-              controller: usernameController,
-              focusNode: emailFocusNode,
-              onSubmit: passwordFocusNode.requestFocus,
-            ),
-            const SizedBox(height: 8),
-            PasswordInput(
-              controller: passwordController,
-              focusNode: passwordFocusNode,
-              onSubmit: login,
-            ),
+            if (isPasswordLoginEnable.value) ...[
+              const SizedBox(height: 18),
+              EmailInput(
+                controller: usernameController,
+                focusNode: emailFocusNode,
+                onSubmit: passwordFocusNode.requestFocus,
+              ),
+              const SizedBox(height: 8),
+              PasswordInput(
+                controller: passwordController,
+                focusNode: passwordFocusNode,
+                onSubmit: login,
+              ),
+            ],
 
             // Note: This used to have an AnimatedSwitcher, but was removed
             // because of https://github.com/flutter/flutter/issues/120874
@@ -295,19 +302,21 @@ class LoginForm extends HookConsumerWidget {
                     mainAxisAlignment: MainAxisAlignment.center,
                     children: [
                       const SizedBox(height: 18),
-                      LoginButton(onPressed: login),
+                      if (isPasswordLoginEnable.value)
+                        LoginButton(onPressed: login),
                       if (isOauthEnable.value) ...[
-                        Padding(
-                          padding: const EdgeInsets.symmetric(
-                            horizontal: 16.0,
+                        if (isPasswordLoginEnable.value)
+                          Padding(
+                            padding: const EdgeInsets.symmetric(
+                              horizontal: 16.0,
+                            ),
+                            child: Divider(
+                              color: Brightness.dark ==
+                                      Theme.of(context).brightness
+                                  ? Colors.white
+                                  : Colors.black,
+                            ),
                           ),
-                          child: Divider(
-                            color:
-                                Brightness.dark == Theme.of(context).brightness
-                                    ? Colors.white
-                                    : Colors.black,
-                          ),
-                        ),
                         OAuthLoginButton(
                           serverEndpointController: serverEndpointController,
                           buttonLabel: oAuthButtonLabel.value,
@@ -317,6 +326,10 @@ class LoginForm extends HookConsumerWidget {
                       ],
                     ],
                   ),
+            if (!isOauthEnable.value && !isPasswordLoginEnable.value)
+              Center(
+                child: const Text('login_disabled').tr(),
+              ),
             const SizedBox(height: 12),
             TextButton.icon(
               icon: const Icon(Icons.arrow_back),

+ 176 - 51
mobile/lib/modules/memories/views/memory_page.dart

@@ -5,7 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/memories/models/memory.dart';
 import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
 import 'package:intl/intl.dart';
+import 'package:openapi/api.dart' as api;
 
 class MemoryPage extends HookConsumerWidget {
   final List<Memory> memories;
@@ -22,6 +25,7 @@ class MemoryPage extends HookConsumerWidget {
     final memoryPageController = usePageController(initialPage: memoryIndex);
     final memoryAssetPageController = usePageController();
     final currentMemory = useState(memories[memoryIndex]);
+    final previousMemoryIndex = useState(memoryIndex);
     final currentAssetPage = useState(0);
     final assetProgress = useState(
       "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
@@ -36,11 +40,16 @@ class MemoryPage extends HookConsumerWidget {
     }
 
     toNextAsset(int currentAssetIndex) {
-      (currentAssetIndex + 1 < currentMemory.value.assets.length)
-          ? memoryAssetPageController.jumpToPage(
-              (currentAssetIndex + 1),
-            )
-          : toNextMemory();
+      if (currentAssetIndex + 1 < currentMemory.value.assets.length) {
+        // Go to the next asset
+        memoryAssetPageController.nextPage(
+          curve: Curves.easeInOut,
+          duration: const Duration(milliseconds: 500),
+        );
+      } else {
+        // Go to the next memory since we are at the end of our assets
+        toNextMemory();
+      }
     }
 
     updateProgressText() {
@@ -48,21 +57,76 @@ class MemoryPage extends HookConsumerWidget {
           "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
     }
 
-    onMemoryChanged(int otherIndex) {
-      HapticFeedback.mediumImpact();
-      currentMemory.value = memories[otherIndex];
-      currentAssetPage.value = 0;
-      updateProgressText();
+    /// Downloads and caches the image for the asset at this [currentMemory]'s index
+    precacheAsset(int index) async {
+      // Guard index out of range
+      if (index < 0) {
+        return;
+      }
+
+      // Context might be removed due to popping out of Memory Lane during Scroll handling
+      if (!context.mounted) {
+        return;
+      }
+
+      late Asset asset;
+      if (index < currentMemory.value.assets.length) {
+        // Uses the next asset in this current memory
+        asset = currentMemory.value.assets[index];
+      } else {
+        // Precache the first asset in the next memory if available
+        final currentMemoryIndex = memories.indexOf(currentMemory.value);
+
+        // Guard no memory found
+        if (currentMemoryIndex == -1) {
+          return;
+        }
+
+        final nextMemoryIndex = currentMemoryIndex + 1;
+        // Guard no next memory
+        if (nextMemoryIndex >= memories.length) {
+          return;
+        }
+
+        // Get the first asset from the next memory
+        asset = memories[nextMemoryIndex].assets.first;
+      }
+
+      // Gets the thumbnail url and precaches it
+      final precaches = <Future<dynamic>>[];
+
+      precaches.add(
+        ImmichImage.precacheAsset(
+          asset,
+          context,
+          type: api.ThumbnailFormat.WEBP,
+        ),
+      );
+      precaches.add(
+        ImmichImage.precacheAsset(
+          asset,
+          context,
+          type: api.ThumbnailFormat.JPEG,
+        ),
+      );
+
+      await Future.wait(precaches);
+    }
+
+    // Precache the next page right away if we are on the first page
+    if (currentAssetPage.value == 0) {
+      Future.delayed(const Duration(milliseconds: 200))
+          .then((_) => precacheAsset(1));
     }
 
     onAssetChanged(int otherIndex) {
       HapticFeedback.selectionClick();
-
       currentAssetPage.value = otherIndex;
+      precacheAsset(otherIndex + 1);
       updateProgressText();
     }
 
-    buildBottomInfo() {
+    buildBottomInfo(Memory memory) {
       return Padding(
         padding: const EdgeInsets.all(16.0),
         child: Row(
@@ -71,7 +135,7 @@ class MemoryPage extends HookConsumerWidget {
               crossAxisAlignment: CrossAxisAlignment.start,
               children: [
                 Text(
-                  currentMemory.value.title,
+                  memory.title,
                   style: TextStyle(
                     color: Colors.grey[400],
                     fontSize: 11.0,
@@ -80,7 +144,7 @@ class MemoryPage extends HookConsumerWidget {
                 ),
                 Text(
                   DateFormat.yMMMMd().format(
-                    currentMemory.value.assets[0].fileCreatedAt,
+                    memory.assets[0].fileCreatedAt,
                   ),
                   style: const TextStyle(
                     color: Colors.white,
@@ -95,44 +159,105 @@ class MemoryPage extends HookConsumerWidget {
       );
     }
 
-    return Scaffold(
-      backgroundColor: bgColor,
-      body: SafeArea(
-        child: PageView.builder(
-          scrollDirection: Axis.vertical,
-          controller: memoryPageController,
-          onPageChanged: onMemoryChanged,
-          itemCount: memories.length,
-          itemBuilder: (context, mIndex) {
-            // Build horizontal page
-            return Column(
-              children: [
-                Expanded(
-                  child: PageView.builder(
-                    controller: memoryAssetPageController,
-                    onPageChanged: onAssetChanged,
-                    scrollDirection: Axis.horizontal,
-                    itemCount: memories[mIndex].assets.length,
-                    itemBuilder: (context, index) {
-                      final asset = memories[mIndex].assets[index];
-                      return Container(
-                        color: Colors.black,
-                        child: MemoryCard(
-                          asset: asset,
-                          onTap: () => toNextAsset(index),
-                          onClose: () => AutoRouter.of(context).pop(),
-                          rightCornerText: assetProgress.value,
-                          title: memories[mIndex].title,
-                          showTitle: index == 0,
-                        ),
-                      );
-                    },
+    /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
+     * when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
+     * page during the end of scroll is different than the current page
+     */
+    return NotificationListener<ScrollNotification>(
+      onNotification: (ScrollNotification notification) {
+        // Calculate OverScroll manually using the number of pixels away from maxScrollExtent
+        // maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1
+        // or sum of vertical pixels of all memories for depth = 0
+        if (notification is ScrollUpdateNotification) {
+          final offset = notification.metrics.pixels;
+          final isLastMemory =
+              (memories.indexOf(currentMemory.value) + 1) >= memories.length;
+          if (isLastMemory) {
+            // Vertical scroll handling only at the last asset.
+            // Tapping on the last asset instead of swiping will trigger the scroll
+            // implicitly which will trigger the below handling and thereby closes the
+            // memory lane as well
+            if (notification.depth == 0) {
+              final isLastAsset = (currentAssetPage.value + 1) ==
+                  currentMemory.value.assets.length;
+              if (isLastAsset &&
+                  (offset > notification.metrics.maxScrollExtent + 150)) {
+                AutoRouter.of(context).pop();
+                return true;
+              }
+            }
+            // Horizontal scroll handling
+            if (notification.depth == 1 &&
+                (offset > notification.metrics.maxScrollExtent + 100)) {
+              AutoRouter.of(context).pop();
+              return true;
+            }
+          }
+        }
+
+        if (notification.depth == 0) {
+          if (notification is ScrollStartNotification) {
+            assetProgress.value = "";
+            return true;
+          }
+          var currentPageNumber = memoryPageController.page!.toInt();
+          currentMemory.value = memories[currentPageNumber];
+          if (notification is ScrollEndNotification) {
+            HapticFeedback.mediumImpact();
+            if (currentPageNumber != previousMemoryIndex.value) {
+              currentAssetPage.value = 0;
+              previousMemoryIndex.value = currentPageNumber;
+            }
+            updateProgressText();
+            return true;
+          }
+        }
+        return false;
+      },
+      child: Scaffold(
+        backgroundColor: bgColor,
+        body: SafeArea(
+          child: PageView.builder(
+            physics: const BouncingScrollPhysics(
+              parent: AlwaysScrollableScrollPhysics(),
+            ),
+            scrollDirection: Axis.vertical,
+            controller: memoryPageController,
+            itemCount: memories.length,
+            itemBuilder: (context, mIndex) {
+              // Build horizontal page
+              return Column(
+                children: [
+                  Expanded(
+                    child: PageView.builder(
+                      physics: const BouncingScrollPhysics(
+                        parent: AlwaysScrollableScrollPhysics(),
+                      ),
+                      controller: memoryAssetPageController,
+                      onPageChanged: onAssetChanged,
+                      scrollDirection: Axis.horizontal,
+                      itemCount: memories[mIndex].assets.length,
+                      itemBuilder: (context, index) {
+                        final asset = memories[mIndex].assets[index];
+                        return Container(
+                          color: Colors.black,
+                          child: MemoryCard(
+                            asset: asset,
+                            onTap: () => toNextAsset(index),
+                            onClose: () => AutoRouter.of(context).pop(),
+                            rightCornerText: assetProgress.value,
+                            title: memories[mIndex].title,
+                            showTitle: index == 0,
+                          ),
+                        );
+                      },
+                    ),
                   ),
-                ),
-                buildBottomInfo(),
-              ],
-            );
-          },
+                  buildBottomInfo(memories[mIndex]),
+                ],
+              );
+            },
+          ),
         ),
       ),
     );

+ 0 - 9
mobile/lib/modules/search/providers/search_page_state.provider.dart

@@ -63,12 +63,3 @@ final getCuratedLocationProvider =
   var curatedLocation = await searchService.getCuratedLocation();
   return curatedLocation ?? [];
 });
-
-final getCuratedObjectProvider =
-    FutureProvider.autoDispose<List<CuratedObjectsResponseDto>>((ref) async {
-  final SearchService searchService = ref.watch(searchServiceProvider);
-
-  var curatedObject = await searchService.getCuratedObjects();
-
-  return curatedObject ?? [];
-});

+ 2 - 1
mobile/lib/modules/search/services/person.service.dart

@@ -18,7 +18,8 @@ class PersonService {
 
   Future<List<PersonResponseDto>?> getCuratedPeople() async {
     try {
-      return await _apiService.personApi.getAllPeople();
+      final peopleResponseDto = await _apiService.personApi.getAllPeople();
+      return peopleResponseDto?.people;
     } catch (e) {
       debugPrint("Error [getCuratedPeople] ${e.toString()}");
       return null;

+ 0 - 55
mobile/lib/modules/search/views/curated_object_page.dart

@@ -1,55 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:easy_localization/easy_localization.dart';
-import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/search/models/curated_content.dart';
-import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
-import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
-import 'package:immich_mobile/utils/capitalize.dart';
-import 'package:openapi/api.dart';
-
-class CuratedObjectPage extends HookConsumerWidget {
-  const CuratedObjectPage({
-    super.key,
-  });
-
-  @override
-  Widget build(BuildContext context, WidgetRef ref) {
-    AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
-        ref.watch(getCuratedObjectProvider);
-
-    return Scaffold(
-      appBar: AppBar(
-        title: Text(
-          'curated_object_page_title',
-          style: TextStyle(
-            color: Theme.of(context).primaryColor,
-            fontWeight: FontWeight.bold,
-            fontSize: 16.0,
-          ),
-        ).tr(),
-        leading: IconButton(
-          onPressed: () => AutoRouter.of(context).pop(),
-          icon: const Icon(Icons.arrow_back_ios_rounded),
-        ),
-      ),
-      body: curatedObjects.when(
-        loading: () => const Center(child: ImmichLoadingIndicator()),
-        error: (err, stack) => Center(
-          child: Text('Error: $err'),
-        ),
-        data: (curatedLocations) => ExploreGrid(
-          curatedContent: curatedLocations
-              .map(
-                (l) => CuratedContent(
-                  label: l.object.capitalize(),
-                  id: l.id,
-                ),
-              )
-              .toList(),
-        ),
-      ),
-    );
-  }
-}

+ 0 - 42
mobile/lib/modules/search/views/search_page.dart

@@ -25,7 +25,6 @@ class SearchPage extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
     final curatedLocation = ref.watch(getCuratedLocationProvider);
-    final curatedObjects = ref.watch(getCuratedObjectProvider);
     final curatedPeople = ref.watch(getCuratedPeopleProvider);
     var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
     double imageSize = MediaQuery.of(context).size.width / 3;
@@ -128,40 +127,6 @@ class SearchPage extends HookConsumerWidget {
       );
     }
 
-    buildThings() {
-      return SizedBox(
-        height: imageSize,
-        child: curatedObjects.when(
-          loading: () => SizedBox(
-            height: imageSize,
-            child: const Center(child: ImmichLoadingIndicator()),
-          ),
-          error: (err, stack) => SizedBox(
-            height: imageSize,
-            child: Center(child: Text('Error: $err')),
-          ),
-          data: (objects) => CuratedRow(
-            content: objects
-                .map(
-                  (o) => CuratedContent(
-                    id: o.id,
-                    label: o.object,
-                  ),
-                )
-                .toList(),
-            imageSize: imageSize,
-            onTap: (content, index) {
-              AutoRouter.of(context).push(
-                SearchResultRoute(
-                  searchTerm: 'm:${content.label}',
-                ),
-              );
-            },
-          ),
-        ),
-      );
-    }
-
     return Scaffold(
       appBar: ImmichSearchBar(
         searchFocusNode: searchFocusNode,
@@ -191,13 +156,6 @@ class SearchPage extends HookConsumerWidget {
                   top: 0,
                 ),
                 buildPlaces(),
-                SearchRowTitle(
-                  title: "search_page_things".tr(),
-                  onViewAllPressed: () => AutoRouter.of(context).push(
-                    const CuratedObjectRoute(),
-                  ),
-                ),
-                buildThings(),
                 const SizedBox(height: 24.0),
                 Padding(
                   padding: const EdgeInsets.symmetric(horizontal: 16),

+ 15 - 4
mobile/lib/routing/auth_guard.dart

@@ -4,29 +4,40 @@ import 'package:auto_route/auto_route.dart';
 import 'package:flutter/foundation.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 
 class AuthGuard extends AutoRouteGuard {
   final ApiService _apiService;
+  final _log = Logger("AuthGuard");
   AuthGuard(this._apiService);
   @override
   void onNavigation(NavigationResolver resolver, StackRouter router) async {
+
+    resolver.next(true);
+
     try {
       var res = await _apiService.authenticationApi.validateAccessToken();
-      if (res != null && res.authStatus) {
-        resolver.next(true);
-      } else {
+      if (res == null || res.authStatus != true) {
+        // If the access token is invalid, take user back to login
+        _log.fine("User token is invalid. Redirecting to login");
         router.replaceAll([const LoginRoute()]);
       }
     } on ApiException catch (e) {
       if (e.code == HttpStatus.badRequest &&
           e.innerException is SocketException) {
         // offline?
-        resolver.next(true);
+        _log.fine(
+          "Unable to validate user token. User may be offline and offline browsing is allowed.",
+        );
       } else {
         debugPrint("Error [onNavigation] ${e.toString()}");
         router.replaceAll([const LoginRoute()]);
+        return;
       }
+    } catch (e) {
+      debugPrint("Error [onNavigation] ${e.toString()}");
+      router.replaceAll([const LoginRoute()]);
       return;
     }
   }

+ 0 - 2
mobile/lib/routing/router.dart

@@ -30,7 +30,6 @@ import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
 import 'package:immich_mobile/modules/search/views/all_people_page.dart';
 import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
 import 'package:immich_mobile/modules/search/views/curated_location_page.dart';
-import 'package:immich_mobile/modules/search/views/curated_object_page.dart';
 import 'package:immich_mobile/modules/search/views/person_result_page.dart';
 import 'package:immich_mobile/modules/search/views/recently_added_page.dart';
 import 'package:immich_mobile/modules/search/views/search_page.dart';
@@ -87,7 +86,6 @@ part 'router.gr.dart';
     AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: CuratedLocationPage, guards: [AuthGuard, DuplicateGuard]),
-    AutoRoute(page: CuratedObjectPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: CreateAlbumPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: FavoritesPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: AllVideosPage, guards: [AuthGuard, DuplicateGuard]),

+ 2 - 28
mobile/lib/routing/router.gr.dart

@@ -111,12 +111,6 @@ class _$AppRouter extends RootStackRouter {
         child: const CuratedLocationPage(),
       );
     },
-    CuratedObjectRoute.name: (routeData) {
-      return MaterialPageX<dynamic>(
-        routeData: routeData,
-        child: const CuratedObjectPage(),
-      );
-    },
     CreateAlbumRoute.name: (routeData) {
       final args = routeData.argsAs<CreateAlbumRouteArgs>();
       return MaterialPageX<dynamic>(
@@ -441,14 +435,6 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
-        RouteConfig(
-          CuratedObjectRoute.name,
-          path: '/curated-object-page',
-          guards: [
-            authGuard,
-            duplicateGuard,
-          ],
-        ),
         RouteConfig(
           CreateAlbumRoute.name,
           path: '/create-album-page',
@@ -507,7 +493,7 @@ class _$AppRouter extends RootStackRouter {
         ),
         RouteConfig(
           AlbumViewerRoute.name,
-          path: '/',
+          path: '/album-viewer-page',
           guards: [
             authGuard,
             duplicateGuard,
@@ -839,18 +825,6 @@ class CuratedLocationRoute extends PageRouteInfo<void> {
   static const String name = 'CuratedLocationRoute';
 }
 
-/// generated route for
-/// [CuratedObjectPage]
-class CuratedObjectRoute extends PageRouteInfo<void> {
-  const CuratedObjectRoute()
-      : super(
-          CuratedObjectRoute.name,
-          path: '/curated-object-page',
-        );
-
-  static const String name = 'CuratedObjectRoute';
-}
-
 /// generated route for
 /// [CreateAlbumPage]
 class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
@@ -1020,7 +994,7 @@ class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
     required int albumId,
   }) : super(
           AlbumViewerRoute.name,
-          path: '/',
+          path: '/album-viewer-page',
           args: AlbumViewerRouteArgs(
             key: key,
             albumId: albumId,

+ 0 - 1
mobile/lib/routing/tab_navigation_observer.dart

@@ -33,7 +33,6 @@ class TabNavigationObserver extends AutoRouterObserver {
     if (route.name == 'SearchRoute') {
       // Refresh Location State
       ref.invalidate(getCuratedLocationProvider);
-      ref.invalidate(getCuratedObjectProvider);
       ref.invalidate(getCuratedPeopleProvider);
     }
 

+ 1 - 1
mobile/lib/shared/models/album.dart

@@ -71,7 +71,7 @@ class Album {
   }
 
   Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
-    final query = assets.filter().sortByFileCreatedAt();
+    final query = assets.filter().sortByFileCreatedAtDesc();
     _renderList = await RenderList.fromQuery(query, groupAssetsBy);
     yield _renderList;
     await for (final _ in query.watchLazy()) {

+ 5 - 0
mobile/lib/shared/models/user.dart

@@ -16,6 +16,7 @@ class User {
     required this.isAdmin,
     this.isPartnerSharedBy = false,
     this.isPartnerSharedWith = false,
+    this.profileImagePath = '',
   });
 
   Id get isarId => fastHash(id);
@@ -28,6 +29,7 @@ class User {
         lastName = dto.lastName,
         isPartnerSharedBy = false,
         isPartnerSharedWith = false,
+        profileImagePath = dto.profileImagePath,
         isAdmin = dto.isAdmin;
 
   @Index(unique: true, replace: false, type: IndexType.hash)
@@ -39,6 +41,7 @@ class User {
   bool isPartnerSharedBy;
   bool isPartnerSharedWith;
   bool isAdmin;
+  String profileImagePath;
   @Backlink(to: 'owner')
   final IsarLinks<Album> albums = IsarLinks<Album>();
   @Backlink(to: 'sharedUsers')
@@ -54,6 +57,7 @@ class User {
         lastName == other.lastName &&
         isPartnerSharedBy == other.isPartnerSharedBy &&
         isPartnerSharedWith == other.isPartnerSharedWith &&
+        profileImagePath == other.profileImagePath &&
         isAdmin == other.isAdmin;
   }
 
@@ -67,5 +71,6 @@ class User {
       lastName.hashCode ^
       isPartnerSharedBy.hashCode ^
       isPartnerSharedWith.hashCode ^
+      profileImagePath.hashCode ^
       isAdmin.hashCode;
 }

+ 29 - 0
mobile/lib/shared/services/api.service.dart

@@ -1,4 +1,6 @@
+import 'dart:async';
 import 'dart:convert';
+import 'dart:io';
 
 import 'package:flutter/material.dart';
 import 'package:immich_mobile/shared/models/store.dart';
@@ -62,6 +64,10 @@ class ApiService {
   Future<String> _resolveEndpoint(String serverUrl) async {
     final url = sanitizeUrl(serverUrl);
 
+    if (!await _isEndpointAvailable(serverUrl)) {
+      throw ApiException(503, "Server is not reachable");
+    }
+
     // Check for /.well-known/immich
     final wellKnownEndpoint = await _getWellKnownEndpoint(url);
     if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint;
@@ -70,6 +76,29 @@ class ApiService {
     return url;
   }
 
+  Future<bool> _isEndpointAvailable(String serverUrl) async {
+    final Client client = Client();
+
+    if (!serverUrl.endsWith('/api')) {
+      serverUrl += '/api';
+    }
+
+    // Throw Socket or Timeout exceptions,
+    // we do not care if the endpoints hits an HTTP error
+    try {
+      await client
+          .get(
+            Uri.parse(serverUrl),
+          )
+          .timeout(const Duration(seconds: 5));
+    } on TimeoutException catch (_) {
+      return false;
+    } on SocketException catch (_) {
+      return false;
+    }
+    return true;
+  }
+
   Future<String> _getWellKnownEndpoint(String baseUrl) async {
     final Client client = Client();
 

+ 57 - 7
mobile/lib/shared/ui/immich_image.dart

@@ -18,11 +18,13 @@ class ImmichImage extends StatelessWidget {
     this.height,
     this.fit = BoxFit.cover,
     this.useGrayBoxPlaceholder = false,
+    this.useProgressIndicator = false,
     this.type = api.ThumbnailFormat.WEBP,
     super.key,
   });
   final Asset? asset;
   final bool useGrayBoxPlaceholder;
+  final bool useProgressIndicator;
   final double? width;
   final double? height;
   final BoxFit fit;
@@ -60,17 +62,23 @@ class ImmichImage extends StatelessWidget {
           if (wasSynchronouslyLoaded || frame != null) {
             return child;
           }
-          return (useGrayBoxPlaceholder
-              ? const SizedBox.square(
+
+          // Show loading if desired
+          return Stack(
+            children: [
+              if (useGrayBoxPlaceholder)
+                const SizedBox.square(
                   dimension: 250,
                   child: DecoratedBox(
                     decoration: BoxDecoration(color: Colors.grey),
                   ),
-                )
-              : Transform.scale(
-                  scale: 0.2,
-                  child: const CircularProgressIndicator(),
-                ));
+                ),
+              if (useProgressIndicator)
+                const Center(
+                  child: CircularProgressIndicator(),
+                ),
+            ],
+          );
         },
         errorBuilder: (context, error, stackTrace) {
           if (error is PlatformException &&
@@ -137,4 +145,46 @@ class ImmichImage extends StatelessWidget {
       },
     );
   }
+
+  /// Precaches this asset for instant load the next time it is shown
+  static Future<void> precacheAsset(
+    Asset asset,
+    BuildContext context, {
+    type = api.ThumbnailFormat.WEBP,
+  }) {
+    final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
+
+    if (type == api.ThumbnailFormat.WEBP) {
+      final thumbnailUrl = getThumbnailUrl(asset);
+      final thumbnailCacheKey = getThumbnailCacheKey(asset);
+      final thumbnailProvider = CachedNetworkImageProvider(
+        thumbnailUrl,
+        cacheKey: thumbnailCacheKey,
+        headers: {"Authorization": authToken},
+      );
+      return precacheImage(thumbnailProvider, context);
+    }
+    // Precache the local image
+    if (!asset.isRemote &&
+        (asset.isLocal || !Store.get(StoreKey.preferRemoteImage, false))) {
+      final provider = AssetEntityImageProvider(
+        asset.local!,
+        isOriginal: false,
+        thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
+      );
+      return precacheImage(provider, context);
+    } else {
+      // Precache the remote image since we are not using local images
+      final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG);
+      final cacheKey =
+          getThumbnailCacheKey(asset, type: api.ThumbnailFormat.JPEG);
+      final provider = CachedNetworkImageProvider(
+        url,
+        cacheKey: cacheKey,
+        headers: {"Authorization": authToken},
+      );
+
+      return precacheImage(provider, context);
+    }
+  }
 }

+ 22 - 2
mobile/lib/shared/views/splash_screen.dart

@@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
 
 class SplashScreenPage extends HookConsumerWidget {
   const SplashScreenPage({Key? key}) : super(key: key);
@@ -17,24 +19,41 @@ class SplashScreenPage extends HookConsumerWidget {
     final apiService = ref.watch(apiServiceProvider);
     final serverUrl = Store.tryGet(StoreKey.serverUrl);
     final accessToken = Store.tryGet(StoreKey.accessToken);
+    final log = Logger("SplashScreenPage");
 
     void performLoggingIn() async {
       bool isSuccess = false;
+      bool deviceIsOffline = false;
       if (accessToken != null && serverUrl != null) {
         try {
           // Resolve API server endpoint from user provided serverUrl
           await apiService.resolveAndSetEndpoint(serverUrl);
-        } catch (e) {
+        } on ApiException catch (e) {
           // okay, try to continue anyway if offline
+          if (e.code == 503) {
+            deviceIsOffline = true;
+            log.fine("Device seems to be offline upon launch");
+          } else {
+            log.severe(e);
+          }
+        } catch (e) {
+          log.severe(e);
         }
 
         isSuccess =
             await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
                   accessToken: accessToken,
                   serverUrl: serverUrl,
+                  offlineLogin: deviceIsOffline,
                 );
       }
-      if (isSuccess) {
+
+      // If the device is offline and there is a currentUser stored locallly
+      // Proceed into the app
+      if (deviceIsOffline && Store.tryGet(StoreKey.currentUser) != null) {
+        AutoRouter.of(context).replace(const TabControllerRoute());
+      } else if (isSuccess) {
+        // If device was able to login through the internet successfully
         final hasPermission =
             await ref.read(galleryPermissionNotifier.notifier).hasPermission;
         if (hasPermission) {
@@ -43,6 +62,7 @@ class SplashScreenPage extends HookConsumerWidget {
         }
         AutoRouter.of(context).replace(const TabControllerRoute());
       } else {
+        // User was unable to login through either offline or online methods
         AutoRouter.of(context).replace(const LoginRoute());
       }
     }

+ 21 - 24
mobile/openapi/.openapi-generator/FILES

@@ -8,8 +8,6 @@ doc/APIKeyCreateDto.md
 doc/APIKeyCreateResponseDto.md
 doc/APIKeyResponseDto.md
 doc/APIKeyUpdateDto.md
-doc/AddAssetsDto.md
-doc/AddAssetsResponseDto.md
 doc/AddUsersDto.md
 doc/AdminSignupResponseDto.md
 doc/AlbumApi.md
@@ -21,8 +19,6 @@ doc/AssetBulkUploadCheckDto.md
 doc/AssetBulkUploadCheckItem.md
 doc/AssetBulkUploadCheckResponseDto.md
 doc/AssetBulkUploadCheckResult.md
-doc/AssetCountByTimeBucket.md
-doc/AssetCountByTimeBucketResponseDto.md
 doc/AssetFileUploadResponseDto.md
 doc/AssetIdsDto.md
 doc/AssetIdsResponseDto.md
@@ -33,6 +29,7 @@ doc/AudioCodec.md
 doc/AuthDeviceResponseDto.md
 doc/AuthenticationApi.md
 doc/BulkIdResponseDto.md
+doc/BulkIdsDto.md
 doc/ChangePasswordDto.md
 doc/CheckDuplicateAssetDto.md
 doc/CheckDuplicateAssetResponseDto.md
@@ -50,8 +47,6 @@ doc/DeleteAssetStatus.md
 doc/DownloadArchiveInfo.md
 doc/DownloadResponseDto.md
 doc/ExifResponseDto.md
-doc/GetAssetByTimeBucketDto.md
-doc/GetAssetCountByTimeBucketDto.md
 doc/ImportAssetDto.md
 doc/JobApi.md
 doc/JobCommand.md
@@ -71,11 +66,13 @@ doc/OAuthCallbackDto.md
 doc/OAuthConfigDto.md
 doc/OAuthConfigResponseDto.md
 doc/PartnerApi.md
+doc/PeopleResponseDto.md
+doc/PeopleUpdateDto.md
+doc/PeopleUpdateItem.md
 doc/PersonApi.md
 doc/PersonResponseDto.md
 doc/PersonUpdateDto.md
 doc/QueueStatusDto.md
-doc/RemoveAssetsDto.md
 doc/SearchAlbumResponseDto.md
 doc/SearchApi.md
 doc/SearchAssetDto.md
@@ -111,7 +108,9 @@ doc/TagApi.md
 doc/TagResponseDto.md
 doc/TagTypeEnum.md
 doc/ThumbnailFormat.md
-doc/TimeGroupEnum.md
+doc/TimeBucketResponseDto.md
+doc/TimeBucketSize.md
+doc/TranscodeHWAccel.md
 doc/TranscodePolicy.md
 doc/UpdateAlbumDto.md
 doc/UpdateAssetDto.md
@@ -147,8 +146,6 @@ lib/auth/authentication.dart
 lib/auth/http_basic_auth.dart
 lib/auth/http_bearer_auth.dart
 lib/auth/oauth.dart
-lib/model/add_assets_dto.dart
-lib/model/add_assets_response_dto.dart
 lib/model/add_users_dto.dart
 lib/model/admin_signup_response_dto.dart
 lib/model/album_count_response_dto.dart
@@ -162,8 +159,6 @@ lib/model/asset_bulk_upload_check_dto.dart
 lib/model/asset_bulk_upload_check_item.dart
 lib/model/asset_bulk_upload_check_response_dto.dart
 lib/model/asset_bulk_upload_check_result.dart
-lib/model/asset_count_by_time_bucket.dart
-lib/model/asset_count_by_time_bucket_response_dto.dart
 lib/model/asset_file_upload_response_dto.dart
 lib/model/asset_ids_dto.dart
 lib/model/asset_ids_response_dto.dart
@@ -173,6 +168,7 @@ lib/model/asset_type_enum.dart
 lib/model/audio_codec.dart
 lib/model/auth_device_response_dto.dart
 lib/model/bulk_id_response_dto.dart
+lib/model/bulk_ids_dto.dart
 lib/model/change_password_dto.dart
 lib/model/check_duplicate_asset_dto.dart
 lib/model/check_duplicate_asset_response_dto.dart
@@ -190,8 +186,6 @@ lib/model/delete_asset_status.dart
 lib/model/download_archive_info.dart
 lib/model/download_response_dto.dart
 lib/model/exif_response_dto.dart
-lib/model/get_asset_by_time_bucket_dto.dart
-lib/model/get_asset_count_by_time_bucket_dto.dart
 lib/model/import_asset_dto.dart
 lib/model/job_command.dart
 lib/model/job_command_dto.dart
@@ -208,10 +202,12 @@ lib/model/merge_person_dto.dart
 lib/model/o_auth_callback_dto.dart
 lib/model/o_auth_config_dto.dart
 lib/model/o_auth_config_response_dto.dart
+lib/model/people_response_dto.dart
+lib/model/people_update_dto.dart
+lib/model/people_update_item.dart
 lib/model/person_response_dto.dart
 lib/model/person_update_dto.dart
 lib/model/queue_status_dto.dart
-lib/model/remove_assets_dto.dart
 lib/model/search_album_response_dto.dart
 lib/model/search_asset_dto.dart
 lib/model/search_asset_response_dto.dart
@@ -242,7 +238,9 @@ lib/model/system_config_template_storage_option_dto.dart
 lib/model/tag_response_dto.dart
 lib/model/tag_type_enum.dart
 lib/model/thumbnail_format.dart
-lib/model/time_group_enum.dart
+lib/model/time_bucket_response_dto.dart
+lib/model/time_bucket_size.dart
+lib/model/transcode_hw_accel.dart
 lib/model/transcode_policy.dart
 lib/model/update_album_dto.dart
 lib/model/update_asset_dto.dart
@@ -254,8 +252,6 @@ lib/model/user_response_dto.dart
 lib/model/validate_access_token_response_dto.dart
 lib/model/video_codec.dart
 pubspec.yaml
-test/add_assets_dto_test.dart
-test/add_assets_response_dto_test.dart
 test/add_users_dto_test.dart
 test/admin_signup_response_dto_test.dart
 test/album_api_test.dart
@@ -272,8 +268,6 @@ test/asset_bulk_upload_check_dto_test.dart
 test/asset_bulk_upload_check_item_test.dart
 test/asset_bulk_upload_check_response_dto_test.dart
 test/asset_bulk_upload_check_result_test.dart
-test/asset_count_by_time_bucket_response_dto_test.dart
-test/asset_count_by_time_bucket_test.dart
 test/asset_file_upload_response_dto_test.dart
 test/asset_ids_dto_test.dart
 test/asset_ids_response_dto_test.dart
@@ -284,6 +278,7 @@ test/audio_codec_test.dart
 test/auth_device_response_dto_test.dart
 test/authentication_api_test.dart
 test/bulk_id_response_dto_test.dart
+test/bulk_ids_dto_test.dart
 test/change_password_dto_test.dart
 test/check_duplicate_asset_dto_test.dart
 test/check_duplicate_asset_response_dto_test.dart
@@ -301,8 +296,6 @@ test/delete_asset_status_test.dart
 test/download_archive_info_test.dart
 test/download_response_dto_test.dart
 test/exif_response_dto_test.dart
-test/get_asset_by_time_bucket_dto_test.dart
-test/get_asset_count_by_time_bucket_dto_test.dart
 test/import_asset_dto_test.dart
 test/job_api_test.dart
 test/job_command_dto_test.dart
@@ -322,11 +315,13 @@ test/o_auth_callback_dto_test.dart
 test/o_auth_config_dto_test.dart
 test/o_auth_config_response_dto_test.dart
 test/partner_api_test.dart
+test/people_response_dto_test.dart
+test/people_update_dto_test.dart
+test/people_update_item_test.dart
 test/person_api_test.dart
 test/person_response_dto_test.dart
 test/person_update_dto_test.dart
 test/queue_status_dto_test.dart
-test/remove_assets_dto_test.dart
 test/search_album_response_dto_test.dart
 test/search_api_test.dart
 test/search_asset_dto_test.dart
@@ -362,7 +357,9 @@ test/tag_api_test.dart
 test/tag_response_dto_test.dart
 test/tag_type_enum_test.dart
 test/thumbnail_format_test.dart
-test/time_group_enum_test.dart
+test/time_bucket_response_dto_test.dart
+test/time_bucket_size_test.dart
+test/transcode_hw_accel_test.dart
 test/transcode_policy_test.dart
 test/update_album_dto_test.dart
 test/update_asset_dto_test.dart

+ 15 - 15
mobile/openapi/README.md

@@ -3,7 +3,7 @@ Immich API
 
 This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
 
-- API version: 1.67.2
+- API version: 1.71.0
 - Build package: org.openapitools.codegen.languages.DartClientCodegen
 
 ## Requirements
@@ -95,16 +95,16 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
 *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | 
 *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
-*AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket | 
-*AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket | 
 *AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
 *AssetApi* | [**getAssetStats**](doc//AssetApi.md#getassetstats) | **GET** /asset/statistics | 
 *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | 
+*AssetApi* | [**getByTimeBucket**](doc//AssetApi.md#getbytimebucket) | **GET** /asset/time-bucket | 
 *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
 *AssetApi* | [**getDownloadInfo**](doc//AssetApi.md#getdownloadinfo) | **GET** /asset/download | 
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
+*AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
@@ -134,6 +134,7 @@ Class | Method | HTTP request | Description
 *PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | 
 *PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | 
 *PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge | 
+*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | 
 *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | 
 *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 *SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | 
@@ -165,13 +166,13 @@ Class | Method | HTTP request | Description
 *TagApi* | [**updateTag**](doc//TagApi.md#updatetag) | **PATCH** /tag/{id} | 
 *UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 *UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user | 
-*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{userId} | 
+*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{id} | 
 *UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /user | 
 *UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /user/me | 
-*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /user/profile-image/{userId} | 
-*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /user/info/{userId} | 
+*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /user/profile-image/{id} | 
+*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /user/info/{id} | 
 *UserApi* | [**getUserCount**](doc//UserApi.md#getusercount) | **GET** /user/count | 
-*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /user/{userId}/restore | 
+*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /user/{id}/restore | 
 *UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /user | 
 
 
@@ -181,8 +182,6 @@ Class | Method | HTTP request | Description
  - [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md)
  - [APIKeyResponseDto](doc//APIKeyResponseDto.md)
  - [APIKeyUpdateDto](doc//APIKeyUpdateDto.md)
- - [AddAssetsDto](doc//AddAssetsDto.md)
- - [AddAssetsResponseDto](doc//AddAssetsResponseDto.md)
  - [AddUsersDto](doc//AddUsersDto.md)
  - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
@@ -192,8 +191,6 @@ Class | Method | HTTP request | Description
  - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
  - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)
  - [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
- - [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
- - [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
  - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
  - [AssetIdsDto](doc//AssetIdsDto.md)
  - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
@@ -203,6 +200,7 @@ Class | Method | HTTP request | Description
  - [AudioCodec](doc//AudioCodec.md)
  - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
  - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
+ - [BulkIdsDto](doc//BulkIdsDto.md)
  - [ChangePasswordDto](doc//ChangePasswordDto.md)
  - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
  - [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
@@ -220,8 +218,6 @@ Class | Method | HTTP request | Description
  - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
  - [DownloadResponseDto](doc//DownloadResponseDto.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
- - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
- - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
  - [ImportAssetDto](doc//ImportAssetDto.md)
  - [JobCommand](doc//JobCommand.md)
  - [JobCommandDto](doc//JobCommandDto.md)
@@ -238,10 +234,12 @@ Class | Method | HTTP request | Description
  - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
+ - [PeopleResponseDto](doc//PeopleResponseDto.md)
+ - [PeopleUpdateDto](doc//PeopleUpdateDto.md)
+ - [PeopleUpdateItem](doc//PeopleUpdateItem.md)
  - [PersonResponseDto](doc//PersonResponseDto.md)
  - [PersonUpdateDto](doc//PersonUpdateDto.md)
  - [QueueStatusDto](doc//QueueStatusDto.md)
- - [RemoveAssetsDto](doc//RemoveAssetsDto.md)
  - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
  - [SearchAssetDto](doc//SearchAssetDto.md)
  - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
@@ -272,7 +270,9 @@ Class | Method | HTTP request | Description
  - [TagResponseDto](doc//TagResponseDto.md)
  - [TagTypeEnum](doc//TagTypeEnum.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)
- - [TimeGroupEnum](doc//TimeGroupEnum.md)
+ - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
+ - [TimeBucketSize](doc//TimeBucketSize.md)
+ - [TranscodeHWAccel](doc//TranscodeHWAccel.md)
  - [TranscodePolicy](doc//TranscodePolicy.md)
  - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
  - [UpdateAssetDto](doc//UpdateAssetDto.md)

+ 1 - 1
mobile/openapi/doc/APIKeyCreateResponseDto.md

@@ -8,8 +8,8 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**secret** | **String** |  | 
 **apiKey** | [**APIKeyResponseDto**](APIKeyResponseDto.md) |  | 
+**secret** | **String** |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 1 - 1
mobile/openapi/doc/APIKeyResponseDto.md

@@ -8,9 +8,9 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**createdAt** | [**DateTime**](DateTime.md) |  | 
 **id** | **String** |  | 
 **name** | **String** |  | 
-**createdAt** | [**DateTime**](DateTime.md) |  | 
 **updatedAt** | [**DateTime**](DateTime.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/AdminSignupResponseDto.md

@@ -8,11 +8,11 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**id** | **String** |  | 
+**createdAt** | [**DateTime**](DateTime.md) |  | 
 **email** | **String** |  | 
 **firstName** | **String** |  | 
+**id** | **String** |  | 
 **lastName** | **String** |  | 
-**createdAt** | [**DateTime**](DateTime.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)
 

+ 10 - 10
mobile/openapi/doc/AlbumApi.md

@@ -22,7 +22,7 @@ Method | HTTP request | Description
 
 
 # **addAssetsToAlbum**
-> AddAssetsResponseDto addAssetsToAlbum(id, addAssetsDto, key)
+> List<BulkIdResponseDto> addAssetsToAlbum(id, bulkIdsDto, key)
 
 
 
@@ -46,11 +46,11 @@ import 'package:openapi/api.dart';
 
 final api_instance = AlbumApi();
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final addAssetsDto = AddAssetsDto(); // AddAssetsDto | 
+final bulkIdsDto = BulkIdsDto(); // BulkIdsDto | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.addAssetsToAlbum(id, addAssetsDto, key);
+    final result = api_instance.addAssetsToAlbum(id, bulkIdsDto, key);
     print(result);
 } catch (e) {
     print('Exception when calling AlbumApi->addAssetsToAlbum: $e\n');
@@ -62,12 +62,12 @@ try {
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
  **id** | **String**|  | 
- **addAssetsDto** | [**AddAssetsDto**](AddAssetsDto.md)|  | 
+ **bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)|  | 
  **key** | **String**|  | [optional] 
 
 ### Return type
 
-[**AddAssetsResponseDto**](AddAssetsResponseDto.md)
+[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
 
 ### Authorization
 
@@ -412,7 +412,7 @@ Name | Type | Description  | Notes
 [[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)
 
 # **removeAssetFromAlbum**
-> AlbumResponseDto removeAssetFromAlbum(id, removeAssetsDto)
+> List<BulkIdResponseDto> removeAssetFromAlbum(id, bulkIdsDto)
 
 
 
@@ -436,10 +436,10 @@ import 'package:openapi/api.dart';
 
 final api_instance = AlbumApi();
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final removeAssetsDto = RemoveAssetsDto(); // RemoveAssetsDto | 
+final bulkIdsDto = BulkIdsDto(); // BulkIdsDto | 
 
 try {
-    final result = api_instance.removeAssetFromAlbum(id, removeAssetsDto);
+    final result = api_instance.removeAssetFromAlbum(id, bulkIdsDto);
     print(result);
 } catch (e) {
     print('Exception when calling AlbumApi->removeAssetFromAlbum: $e\n');
@@ -451,11 +451,11 @@ try {
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
  **id** | **String**|  | 
- **removeAssetsDto** | [**RemoveAssetsDto**](RemoveAssetsDto.md)|  | 
+ **bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)|  | 
 
 ### Return type
 
-[**AlbumResponseDto**](AlbumResponseDto.md)
+[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
 
 ### Authorization
 

+ 1 - 1
mobile/openapi/doc/AlbumCountResponseDto.md

@@ -8,9 +8,9 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**notShared** | **int** |  | 
 **owned** | **int** |  | 
 **shared** | **int** |  | 
-**notShared** | **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)
 

+ 7 - 7
mobile/openapi/doc/AlbumResponseDto.md

@@ -8,18 +8,18 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**albumName** | **String** |  | 
+**albumThumbnailAssetId** | **String** |  | 
 **assetCount** | **int** |  | 
+**assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []]
+**createdAt** | [**DateTime**](DateTime.md) |  | 
 **id** | **String** |  | 
+**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) |  | [optional] 
+**owner** | [**UserResponseDto**](UserResponseDto.md) |  | 
 **ownerId** | **String** |  | 
-**albumName** | **String** |  | 
-**createdAt** | [**DateTime**](DateTime.md) |  | 
-**updatedAt** | [**DateTime**](DateTime.md) |  | 
-**albumThumbnailAssetId** | **String** |  | 
 **shared** | **bool** |  | 
 **sharedUsers** | [**List<UserResponseDto>**](UserResponseDto.md) |  | [default to const []]
-**assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []]
-**owner** | [**UserResponseDto**](UserResponseDto.md) |  | 
-**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) |  | [optional] 
+**updatedAt** | [**DateTime**](DateTime.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)
 

+ 6 - 6
mobile/openapi/doc/AllJobStatusResponseDto.md

@@ -8,16 +8,16 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**thumbnailGeneration** | [**JobStatusDto**](JobStatusDto.md) |  | 
+**backgroundTask** | [**JobStatusDto**](JobStatusDto.md) |  | 
+**clipEncoding** | [**JobStatusDto**](JobStatusDto.md) |  | 
 **metadataExtraction** | [**JobStatusDto**](JobStatusDto.md) |  | 
-**videoConversion** | [**JobStatusDto**](JobStatusDto.md) |  | 
 **objectTagging** | [**JobStatusDto**](JobStatusDto.md) |  | 
-**clipEncoding** | [**JobStatusDto**](JobStatusDto.md) |  | 
-**storageTemplateMigration** | [**JobStatusDto**](JobStatusDto.md) |  | 
-**backgroundTask** | [**JobStatusDto**](JobStatusDto.md) |  | 
-**search** | [**JobStatusDto**](JobStatusDto.md) |  | 
 **recognizeFaces** | [**JobStatusDto**](JobStatusDto.md) |  | 
+**search** | [**JobStatusDto**](JobStatusDto.md) |  | 
 **sidecar** | [**JobStatusDto**](JobStatusDto.md) |  | 
+**storageTemplateMigration** | [**JobStatusDto**](JobStatusDto.md) |  | 
+**thumbnailGeneration** | [**JobStatusDto**](JobStatusDto.md) |  | 
+**videoConversion** | [**JobStatusDto**](JobStatusDto.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)
 

+ 123 - 101
mobile/openapi/doc/AssetApi.md

@@ -17,16 +17,16 @@ Method | HTTP request | Description
 [**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
 [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | 
 [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
-[**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket | 
-[**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket | 
 [**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
 [**getAssetStats**](AssetApi.md#getassetstats) | **GET** /asset/statistics | 
 [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | 
+[**getByTimeBucket**](AssetApi.md#getbytimebucket) | **GET** /asset/time-bucket | 
 [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
 [**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **GET** /asset/download | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
+[**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
@@ -503,8 +503,8 @@ Name | Type | Description  | Notes
 
 [[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)
 
-# **getAssetByTimeBucket**
-> List<AssetResponseDto> getAssetByTimeBucket(getAssetByTimeBucketDto)
+# **getAssetSearchTerms**
+> List<String> getAssetSearchTerms()
 
 
 
@@ -527,25 +527,21 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = AssetApi();
-final getAssetByTimeBucketDto = GetAssetByTimeBucketDto(); // GetAssetByTimeBucketDto | 
 
 try {
-    final result = api_instance.getAssetByTimeBucket(getAssetByTimeBucketDto);
+    final result = api_instance.getAssetSearchTerms();
     print(result);
 } catch (e) {
-    print('Exception when calling AssetApi->getAssetByTimeBucket: $e\n');
+    print('Exception when calling AssetApi->getAssetSearchTerms: $e\n');
 }
 ```
 
 ### Parameters
-
-Name | Type | Description  | Notes
-------------- | ------------- | ------------- | -------------
- **getAssetByTimeBucketDto** | [**GetAssetByTimeBucketDto**](GetAssetByTimeBucketDto.md)|  | 
+This endpoint does not need any parameter.
 
 ### Return type
 
-[**List<AssetResponseDto>**](AssetResponseDto.md)
+**List<String>**
 
 ### Authorization
 
@@ -553,13 +549,13 @@ Name | Type | Description  | Notes
 
 ### HTTP request headers
 
- - **Content-Type**: application/json
+ - **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)
 
-# **getAssetCountByTimeBucket**
-> AssetCountByTimeBucketResponseDto getAssetCountByTimeBucket(getAssetCountByTimeBucketDto)
+# **getAssetStats**
+> AssetStatsResponseDto getAssetStats(isArchived, isFavorite)
 
 
 
@@ -582,13 +578,14 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = AssetApi();
-final getAssetCountByTimeBucketDto = GetAssetCountByTimeBucketDto(); // GetAssetCountByTimeBucketDto | 
+final isArchived = true; // bool | 
+final isFavorite = true; // bool | 
 
 try {
-    final result = api_instance.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto);
+    final result = api_instance.getAssetStats(isArchived, isFavorite);
     print(result);
 } catch (e) {
-    print('Exception when calling AssetApi->getAssetCountByTimeBucket: $e\n');
+    print('Exception when calling AssetApi->getAssetStats: $e\n');
 }
 ```
 
@@ -596,62 +593,12 @@ try {
 
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
- **getAssetCountByTimeBucketDto** | [**GetAssetCountByTimeBucketDto**](GetAssetCountByTimeBucketDto.md)|  | 
-
-### Return type
-
-[**AssetCountByTimeBucketResponseDto**](AssetCountByTimeBucketResponseDto.md)
-
-### Authorization
-
-[cookie](../README.md#cookie), [api_key](../README.md#api_key), [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)
-
-# **getAssetSearchTerms**
-> List<String> getAssetSearchTerms()
-
-
-
-### Example
-```dart
-import 'package:openapi/api.dart';
-// TODO Configure API key authorization: cookie
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
-// TODO Configure API key authorization: api_key
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
-// 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 = AssetApi();
-
-try {
-    final result = api_instance.getAssetSearchTerms();
-    print(result);
-} catch (e) {
-    print('Exception when calling AssetApi->getAssetSearchTerms: $e\n');
-}
-```
-
-### Parameters
-This endpoint does not need any parameter.
+ **isArchived** | **bool**|  | [optional] 
+ **isFavorite** | **bool**|  | [optional] 
 
 ### Return type
 
-**List<String>**
+[**AssetStatsResponseDto**](AssetStatsResponseDto.md)
 
 ### Authorization
 
@@ -664,8 +611,8 @@ This endpoint does not need any parameter.
 
 [[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)
 
-# **getAssetStats**
-> AssetStatsResponseDto getAssetStats(isArchived, isFavorite)
+# **getAssetThumbnail**
+> MultipartFile getAssetThumbnail(id, format, key)
 
 
 
@@ -688,14 +635,15 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = AssetApi();
-final isArchived = true; // bool | 
-final isFavorite = true; // bool | 
+final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final format = ; // ThumbnailFormat | 
+final key = key_example; // String | 
 
 try {
-    final result = api_instance.getAssetStats(isArchived, isFavorite);
+    final result = api_instance.getAssetThumbnail(id, format, key);
     print(result);
 } catch (e) {
-    print('Exception when calling AssetApi->getAssetStats: $e\n');
+    print('Exception when calling AssetApi->getAssetThumbnail: $e\n');
 }
 ```
 
@@ -703,12 +651,13 @@ try {
 
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
- **isArchived** | **bool**|  | [optional] 
- **isFavorite** | **bool**|  | [optional] 
+ **id** | **String**|  | 
+ **format** | [**ThumbnailFormat**](.md)|  | [optional] 
+ **key** | **String**|  | [optional] 
 
 ### Return type
 
-[**AssetStatsResponseDto**](AssetStatsResponseDto.md)
+[**MultipartFile**](MultipartFile.md)
 
 ### Authorization
 
@@ -717,12 +666,12 @@ Name | Type | Description  | Notes
 ### HTTP request headers
 
  - **Content-Type**: Not defined
- - **Accept**: application/json
+ - **Accept**: image/jpeg, image/webp
 
 [[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)
 
-# **getAssetThumbnail**
-> MultipartFile getAssetThumbnail(id, format, key)
+# **getByTimeBucket**
+> List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, isArchived, isFavorite, key)
 
 
 
@@ -745,15 +694,19 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = AssetApi();
-final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final format = ; // ThumbnailFormat | 
+final size = ; // TimeBucketSize | 
+final timeBucket = timeBucket_example; // String | 
+final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final isArchived = true; // bool | 
+final isFavorite = true; // bool | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.getAssetThumbnail(id, format, key);
+    final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, isArchived, isFavorite, key);
     print(result);
 } catch (e) {
-    print('Exception when calling AssetApi->getAssetThumbnail: $e\n');
+    print('Exception when calling AssetApi->getByTimeBucket: $e\n');
 }
 ```
 
@@ -761,13 +714,17 @@ try {
 
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
- **id** | **String**|  | 
- **format** | [**ThumbnailFormat**](.md)|  | [optional] 
+ **size** | [**TimeBucketSize**](.md)|  | 
+ **timeBucket** | **String**|  | 
+ **userId** | **String**|  | [optional] 
+ **albumId** | **String**|  | [optional] 
+ **isArchived** | **bool**|  | [optional] 
+ **isFavorite** | **bool**|  | [optional] 
  **key** | **String**|  | [optional] 
 
 ### Return type
 
-[**MultipartFile**](MultipartFile.md)
+[**List<AssetResponseDto>**](AssetResponseDto.md)
 
 ### Authorization
 
@@ -776,7 +733,7 @@ Name | Type | Description  | Notes
 ### HTTP request headers
 
  - **Content-Type**: Not defined
- - **Accept**: image/jpeg, image/webp
+ - **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)
 
@@ -1059,6 +1016,71 @@ Name | Type | Description  | Notes
 
 [[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)
 
+# **getTimeBuckets**
+> List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, isArchived, isFavorite, key)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// 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 = AssetApi();
+final size = ; // TimeBucketSize | 
+final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final isArchived = true; // bool | 
+final isFavorite = true; // bool | 
+final key = key_example; // String | 
+
+try {
+    final result = api_instance.getTimeBuckets(size, userId, albumId, isArchived, isFavorite, key);
+    print(result);
+} catch (e) {
+    print('Exception when calling AssetApi->getTimeBuckets: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **size** | [**TimeBucketSize**](.md)|  | 
+ **userId** | **String**|  | [optional] 
+ **albumId** | **String**|  | [optional] 
+ **isArchived** | **bool**|  | [optional] 
+ **isFavorite** | **bool**|  | [optional] 
+ **key** | **String**|  | [optional] 
+
+### Return type
+
+[**List<TimeBucketResponseDto>**](TimeBucketResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [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)
+
 # **getUserAssetsByDeviceId**
 > List<String> getUserAssetsByDeviceId(deviceId)
 
@@ -1347,7 +1369,7 @@ Name | Type | Description  | Notes
 [[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)
 
 # **uploadFile**
-> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration)
+> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData)
 
 
 
@@ -1377,15 +1399,15 @@ final fileCreatedAt = 2013-10-20T19:20:30+01:00; // DateTime |
 final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime | 
 final isFavorite = true; // bool | 
 final key = key_example; // String | 
-final livePhotoData = BINARY_DATA_HERE; // MultipartFile | 
-final sidecarData = BINARY_DATA_HERE; // MultipartFile | 
-final isReadOnly = true; // bool | 
+final duration = duration_example; // String | 
 final isArchived = true; // bool | 
+final isReadOnly = true; // bool | 
 final isVisible = true; // bool | 
-final duration = duration_example; // String | 
+final livePhotoData = BINARY_DATA_HERE; // MultipartFile | 
+final sidecarData = BINARY_DATA_HERE; // MultipartFile | 
 
 try {
-    final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration);
+    final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->uploadFile: $e\n');
@@ -1403,12 +1425,12 @@ Name | Type | Description  | Notes
  **fileModifiedAt** | **DateTime**|  | 
  **isFavorite** | **bool**|  | 
  **key** | **String**|  | [optional] 
- **livePhotoData** | **MultipartFile**|  | [optional] 
- **sidecarData** | **MultipartFile**|  | [optional] 
- **isReadOnly** | **bool**|  | [optional] [default to false]
+ **duration** | **String**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
+ **isReadOnly** | **bool**|  | [optional] [default to false]
  **isVisible** | **bool**|  | [optional] 
- **duration** | **String**|  | [optional] 
+ **livePhotoData** | **MultipartFile**|  | [optional] 
+ **sidecarData** | **MultipartFile**|  | [optional] 
 
 ### Return type
 

+ 1 - 1
mobile/openapi/doc/AssetBulkUploadCheckItem.md

@@ -8,8 +8,8 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**id** | **String** |  | 
 **checksum** | **String** | base64 or hex encoded sha1 hash | 
+**id** | **String** |  | 
 
 [[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/AssetBulkUploadCheckResult.md

@@ -8,10 +8,10 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**id** | **String** |  | 
 **action** | **String** |  | 
-**reason** | **String** |  | [optional] 
 **assetId** | **String** |  | [optional] 
+**id** | **String** |  | 
+**reason** | **String** |  | [optional] 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

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

@@ -1,16 +0,0 @@
-# openapi.model.AssetCountByTimeBucketResponseDto
-
-## Load the model package
-```dart
-import 'package:openapi/api.dart';
-```
-
-## Properties
-Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
-**totalCount** | **int** |  | 
-**buckets** | [**List<AssetCountByTimeBucket>**](AssetCountByTimeBucket.md) |  | [default to const []]
-
-[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
-
-

+ 1 - 1
mobile/openapi/doc/AssetFileUploadResponseDto.md

@@ -8,8 +8,8 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**id** | **String** |  | 
 **duplicate** | **bool** |  | 
+**id** | **String** |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 1 - 1
mobile/openapi/doc/AssetIdsResponseDto.md

@@ -9,8 +9,8 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **assetId** | **String** |  | 
-**success** | **bool** |  | 
 **error** | **String** |  | [optional] 
+**success** | **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)
 

部分文件因为文件数量过多而无法显示