Alex Tran 1 年間 前
コミット
7bc8918067
67 ファイル変更709 行追加469 行削除
  1. 40 43
      README_zh_CN.md
  2. 51 58
      cli/src/api/open-api/api.ts
  3. 1 1
      cli/src/api/open-api/base.ts
  4. 1 1
      cli/src/api/open-api/common.ts
  5. 1 1
      cli/src/api/open-api/configuration.ts
  6. 1 1
      cli/src/api/open-api/index.ts
  7. 1 1
      machine-learning/pyproject.toml
  8. 2 2
      mobile/android/fastlane/Fastfile
  9. 3 3
      mobile/android/fastlane/report.xml
  10. 1 1
      mobile/ios/Podfile.lock
  11. 3 3
      mobile/ios/Runner.xcodeproj/project.pbxproj
  12. 2 2
      mobile/ios/Runner/Info.plist
  13. 1 1
      mobile/ios/fastlane/Fastfile
  14. 6 6
      mobile/ios/fastlane/report.xml
  15. 7 0
      mobile/lib/shared/providers/admin_provider.dart
  16. 7 0
      mobile/lib/shared/views/version_announcement_overlay.dart
  17. 3 0
      mobile/openapi/.openapi-generator/FILES
  18. 4 3
      mobile/openapi/README.md
  19. 7 13
      mobile/openapi/doc/AssetApi.md
  20. 18 0
      mobile/openapi/doc/DownloadInfoDto.md
  21. 1 0
      mobile/openapi/lib/api.dart
  22. 12 36
      mobile/openapi/lib/api/asset_api.dart
  23. 2 0
      mobile/openapi/lib/api_client.dart
  24. 150 0
      mobile/openapi/lib/model/download_info_dto.dart
  25. 1 1
      mobile/openapi/test/asset_api_test.dart
  26. 42 0
      mobile/openapi/test/download_info_dto_test.dart
  27. 1 1
      mobile/pubspec.yaml
  28. 48 51
      server/immich-openapi-specs.json
  29. 2 2
      server/package-lock.json
  30. 1 1
      server/package.json
  31. 5 0
      server/src/domain/access/access.core.ts
  32. 7 1
      server/src/domain/album/album.repository.ts
  33. 4 3
      server/src/domain/album/album.service.spec.ts
  34. 12 12
      server/src/domain/album/album.service.ts
  35. 6 3
      server/src/domain/asset/asset.service.ts
  36. 2 1
      server/src/domain/asset/dto/download.dto.ts
  37. 2 0
      server/src/domain/domain.constant.ts
  38. 2 0
      server/src/domain/job/job.constants.ts
  39. 1 0
      server/src/domain/job/job.repository.ts
  40. 4 0
      server/src/domain/job/job.service.spec.ts
  41. 5 1
      server/src/domain/job/job.service.ts
  42. 1 0
      server/src/domain/person/person.repository.ts
  43. 3 3
      server/src/domain/person/person.service.spec.ts
  44. 4 2
      server/src/domain/person/person.service.ts
  45. 3 3
      server/src/domain/server-info/server-info.service.ts
  46. 4 4
      server/src/immich/controllers/asset.controller.ts
  47. 13 0
      server/src/infra/migrations/1692057328660-fixGPSNullIsland.ts
  48. 37 24
      server/src/infra/repositories/album.repository.ts
  49. 10 4
      server/src/infra/repositories/person.repository.ts
  50. 1 0
      server/src/microservices/app.service.ts
  51. 44 28
      server/src/microservices/processors/metadata-extraction.processor.ts
  52. 12 0
      server/src/microservices/utils/exif/coordinates.spec.ts
  53. 9 2
      server/src/microservices/utils/exif/coordinates.ts
  54. 1 0
      server/test/repositories/album.repository.mock.ts
  55. 51 58
      web/src/api/open-api/api.ts
  56. 1 1
      web/src/api/open-api/base.ts
  57. 1 1
      web/src/api/open-api/common.ts
  58. 1 1
      web/src/api/open-api/configuration.ts
  59. 1 1
      web/src/api/open-api/index.ts
  60. 3 1
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  61. 1 1
      web/src/lib/components/forms/edit-user-form.svelte
  62. 3 7
      web/src/lib/utils/asset-utils.ts
  63. 13 49
      web/src/lib/utils/file-uploader.ts
  64. 3 3
      web/src/routes/(user)/albums/[albumId]/+page.svelte
  65. 1 1
      web/src/routes/(user)/partners/[userId]/+page.svelte
  66. 2 2
      web/src/routes/admin/+layout.svelte
  67. 16 20
      web/src/routes/admin/user-management/+page.svelte

+ 40 - 43
README_zh_CN.md

@@ -13,7 +13,7 @@
 </p>
 <h3 align="center">Immich - 高性能的自托管照片和视频备份方案</h3>
 <p align="center">  
-请注意: 此README不是由Immich团队维护, 这意味着它在某一时间点不会被更新,因为我们是依靠贡献者来更新的。感谢理解。
+请注意: 此 README 不是由 Immich 团队维护, 而是依靠贡献者来更新的,这意味着它可能并不会被及时更新。感谢理解。
 </p>
 <br/>
 <a href="https://immich.app">
@@ -31,29 +31,31 @@
 
 ## 免责声明
 
-- ⚠️ 本项目正在 **非常活跃** 的开发中。
-- ⚠️ 可能存在bug或者重大变更。
-- ⚠️ **不要把本软件作为你存储照片或视频的唯一方式!**
+- ⚠️ 本项目正在 **非常活跃** 地开发中。
+- ⚠️ 可能存在 bug 或者随时有重大变更。
+- ⚠️ **不要把本软件作为您存储照片或视频的唯一方式。**
+- ⚠️ 为了您宝贵的照片与视频,始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
 
 ## 目录
 
-- [官方文档](https://immich.app/docs/overview/introduction)
+- [官方文档](https://immich.app/docs)
+- [路线图](https://github.com/orgs/immich-app/projects/1)
 - [示例](#示例)
 - [功能特性](#功能特性)
 - [介绍](https://immich.app/docs/overview/introduction)
 - [安装](https://immich.app/docs/install/requirements)
 - [贡献指南](https://immich.app/docs/overview/support-the-project)
-- [支持本项目](#support-the-project)
-- [已知问题](#known-issues)
+- [支持本项目](#支持本项目)
 
 ## 官方文档
 
-你可以在 https://immich.app/ 找到包含安装手册的官方文档.
+您可以在 https://immich.app/ 找到官方文档(包含安装手册)。
+
 ## 示例
 
-你可以在 https://demo.immich.app  访问示例.
+您可以在 https://demo.immich.app  访问示例。
 
-在移动端, 你可以使用 `https://demo.immich.app/api`获取`服务终端链接`
+在移动端, 您可以使用 `https://demo.immich.app/api` 获取 `服务终端链接`
 
 ```bash title="示例认证信息"
 认证信息
@@ -62,57 +64,52 @@
 ```
 
 ```
-规格: 甲骨文免费虚拟机套餐-阿姆斯特丹 4核 2.4Ghz ARM64 CPU, 24GB RAM。
+规格: 甲骨文免费虚拟机套餐——阿姆斯特丹 4核 2.4Ghz ARM64 CPU, 24GB RAM。
 ```
 
 # 功能特性
 
 | 功能特性                                     | 移动端  | 网页端 |
 | ------------------------------------------- | ------- | --- |
-| 上传并查看照片和视频                       | 是     | 是 |
-| 软件运行时自动备份          | 是     | N/A |
+| 上传并查看照片和视频                         | 是     | 是 |
+| 软件运行时自动备份                           | 是     | N/A |
 | 选择需要备份的相册          | 是     | N/A |
-| 下载照片和视频到本地  | 是     | 是 |
+| 下载照片和视频到本地        | 是     | 是 |
 | 多用户支持                          | 是     | 是 |
 | 相册                                       | 是     | 是 |
 | 共享相册                               | 是     | 是 |
 | 可拖动的快速导航栏   | 是     | 是 |
 | 支持RAW格式 (HEIC, HEIF, DNG, Apple ProRaw) | 是     | 是 |
-| 元数据视图 (EXIF, 地图)                   | 是     | 是 |
-| 通过元数据、对象和标签进行搜索  | 是     | No  |
-| 管理功能 (用户管理)  | N/A     | 是 |
-| 后台备份                         | Android | N/A |
+| 元数据视图(EXIF, 地图)                   | 是     | 是 |
+| 通过元数据、对象和标签进行搜索  | 是     |   |
+| 管理功能(用户管理)  | 否     | 是 |
+| 后台备份                         |  | N/A |
 | 虚拟滚动                             | 是     | 是 |
-| OAuth支持                               | 是     | 是 |
-| 实时照片备份和查看 (仅iOS)   | 是     | 是 |
+| OAuth 支持                               | 是     | 是 |
+| API Keys|N/A|是|
+| 实况照片备份和查看   | 仅 iOS     | 是 |
+|用户自定义存储结构|是|是|
+|公共分享|否|是|
+|归档与收藏功能|是|是|
+|全局地图|否|是|
+|好友分享|是|是|
+|人像识别与分组|是|是|
+|回忆(那年今日)|是|是|
+|离线支持|是|否|
+|只读相册|是|是|
 
 # 支持本项目
 
-我已经致力于本项目并且将我会持续更新文档、新增功能和修复问题。但是我不能一个人走下去,所以我需要你给予我走下去的动力。
+我已经致力于本项目并且将我会持续更新文档、新增功能和修复问题。但是独木不成林,我需要您给予我坚持下去的动力。
 
-就像我主页里面 [selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) 说的一样,这是我和团队的一项艰巨的任务。我希望某一天我能够全职开发本项目,在此我希望你们能够助我梦想成真。
+就像我 [selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) 节目里说的一样,这是我和团队的一项艰巨任务。并且我希望某一天我能够全职开发本项目,在此我请求您能够助我梦想成真。
 
-如果你使用了本项目一段时间,并且觉得上面的话有道理,那么请你按照如下方式帮助我吧。
+如果您使用了本项目一段时间,并且觉得上面的话有道理,那么请您考虑通过下列任一方式支持我吧。
 
 ## 捐赠
 
-- [按月捐赠](https://github.com/sponsors/alextran1502) via GitHub Sponsors
-- [一次捐赠](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via Github Sponsors
-
-# 已知问题
-
-## TensorFlow 构建问题
-
-_这是一个针对于Proxmox的已知问题_
-
-TensorFlow 不能运行在很旧的CPU架构上, 需要运行在AVX和AVX2指令集的CPU上。如果你在docker-compose的命令行中遇到了 `illegal instruction core dump`的错误, 通过如下命令检查你的CPU flag寄存器然后确保你能够看到`AVX`和`AVX2`的字样:
-
-```bash
-more /proc/cpuinfo | grep flags
-```
-
-如果你在Proxmox中运行虚拟机, 虚拟机中没有启用flag寄存器。
-
-你需要在虚拟机的硬件面板中把CPU类型从`kvm64`改为`host`。
-
-`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
+- 通过 GitHub Sponsors [按月捐赠](https://github.com/sponsors/alextran1502)
+- 通过 Github Sponsors [单次捐赠](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
+- [Librepay](https://liberapay.com/alex.tran1502/)
+- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
+- 比特币: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

+ 51 - 58
cli/src/api/open-api/api.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.72.2
+ * The version of the OpenAPI document: 1.73.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1171,6 +1171,37 @@ export interface DownloadArchiveInfo {
      */
     'size': number;
 }
+/**
+ * 
+ * @export
+ * @interface DownloadInfoDto
+ */
+export interface DownloadInfoDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof DownloadInfoDto
+     */
+    'albumId'?: string;
+    /**
+     * 
+     * @type {number}
+     * @memberof DownloadInfoDto
+     */
+    'archiveSize'?: number;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof DownloadInfoDto
+     */
+    'assetIds'?: Array<string>;
+    /**
+     * 
+     * @type {string}
+     * @memberof DownloadInfoDto
+     */
+    'userId'?: string;
+}
 /**
  * 
  * @export
@@ -5000,7 +5031,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'assetIdsDto' is not null or undefined
             assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto)
-            const localVarPath = `/asset/download`;
+            const localVarPath = `/asset/download/archive`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             let baseOptions;
@@ -5499,16 +5530,15 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         },
         /**
          * 
-         * @param {Array<string>} [assetIds] 
-         * @param {string} [albumId] 
-         * @param {string} [userId] 
-         * @param {number} [archiveSize] 
+         * @param {DownloadInfoDto} downloadInfoDto 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getDownloadInfo: async (assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
-            const localVarPath = `/asset/download`;
+        getDownloadInfo: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'downloadInfoDto' is not null or undefined
+            assertParamExists('getDownloadInfo', 'downloadInfoDto', downloadInfoDto)
+            const localVarPath = `/asset/download/info`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             let baseOptions;
@@ -5516,7 +5546,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 baseOptions = configuration.baseOptions;
             }
 
-            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
             const localVarHeaderParameter = {} as any;
             const localVarQueryParameter = {} as any;
 
@@ -5529,31 +5559,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             // http bearer authentication required
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
 
-            if (assetIds) {
-                localVarQueryParameter['assetIds'] = assetIds;
-            }
-
-            if (albumId !== undefined) {
-                localVarQueryParameter['albumId'] = albumId;
-            }
-
-            if (userId !== undefined) {
-                localVarQueryParameter['userId'] = userId;
-            }
-
-            if (archiveSize !== undefined) {
-                localVarQueryParameter['archiveSize'] = archiveSize;
-            }
-
             if (key !== undefined) {
                 localVarQueryParameter['key'] = key;
             }
 
 
     
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(downloadInfoDto, localVarRequestOptions, configuration)
 
             return {
                 url: toPathString(localVarUrlObj),
@@ -6261,16 +6278,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
         },
         /**
          * 
-         * @param {Array<string>} [assetIds] 
-         * @param {string} [albumId] 
-         * @param {string} [userId] 
-         * @param {number} [archiveSize] 
+         * @param {DownloadInfoDto} downloadInfoDto 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getDownloadInfo(assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DownloadResponseDto>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options);
+        async getDownloadInfo(downloadInfoDto: DownloadInfoDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DownloadResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(downloadInfoDto, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -6526,8 +6540,8 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig): AxiosPromise<DownloadResponseDto> {
-            return localVarFp.getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(axios, basePath));
+        getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest, options?: AxiosRequestConfig): AxiosPromise<DownloadResponseDto> {
+            return localVarFp.getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -6908,31 +6922,10 @@ export interface AssetApiGetByTimeBucketRequest {
 export interface AssetApiGetDownloadInfoRequest {
     /**
      * 
-     * @type {Array<string>}
-     * @memberof AssetApiGetDownloadInfo
-     */
-    readonly assetIds?: Array<string>
-
-    /**
-     * 
-     * @type {string}
-     * @memberof AssetApiGetDownloadInfo
-     */
-    readonly albumId?: string
-
-    /**
-     * 
-     * @type {string}
-     * @memberof AssetApiGetDownloadInfo
-     */
-    readonly userId?: string
-
-    /**
-     * 
-     * @type {number}
+     * @type {DownloadInfoDto}
      * @memberof AssetApiGetDownloadInfo
      */
-    readonly archiveSize?: number
+    readonly downloadInfoDto: DownloadInfoDto
 
     /**
      * 
@@ -7401,8 +7394,8 @@ export class AssetApi extends BaseAPI {
      * @throws {RequiredError}
      * @memberof AssetApi
      */
-    public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
+    public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

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

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.72.2
+ * The version of the OpenAPI document: 1.73.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.72.2
+ * The version of the OpenAPI document: 1.73.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.72.2
+ * The version of the OpenAPI document: 1.73.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.72.2
+ * The version of the OpenAPI document: 1.73.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
machine-learning/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.72.2"
+version = "1.73.0"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 95,
-        "android.injected.version.name" => "1.72.2",
+        "android.injected.version.code" => 96,
+        "android.injected.version.name" => "1.73.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.000239">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="68.788432">
+      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.877631">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.76592">
+      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="23.895222">
         
       </testcase>
     

+ 1 - 1
mobile/ios/Podfile.lock

@@ -163,4 +163,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 = 110;
+				CURRENT_PROJECT_VERSION = 113;
 				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 = 110;
+				CURRENT_PROJECT_VERSION = 113;
 				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 = 110;
+				CURRENT_PROJECT_VERSION = 113;
 				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.70.0</string>
+    <string>1.73.0</string>
     <key>CFBundleSignature</key>
     <string>????</string>
     <key>CFBundleVersion</key>
-    <string>110</string>
+    <string>113</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.72.2"
+      version_number: "1.73.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.000211">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000187">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.108738">
+      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.403882">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="28.952846">
+      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.068392">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.821481">
+      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.988079">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="4: build_app" time="99.212621">
+      <testcase classname="fastlane.lanes" name="4: build_app" time="96.47923">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.366701">
+      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="57.517755">
         
       </testcase>
     

+ 7 - 0
mobile/lib/shared/providers/admin_provider.dart

@@ -0,0 +1,7 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
+
+final isAdminProvider = Provider<bool>((ref) {
+  final currentUser = ref.watch(currentUserProvider);
+  return currentUser?.isAdmin ?? false;  // Default to non-admin if no user
+});

+ 7 - 0
mobile/lib/shared/views/version_announcement_overlay.dart

@@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/providers/release_info.provider.dart';
+import 'package:immich_mobile/shared/providers/admin_provider.dart';
 import 'package:url_launcher/url_launcher.dart';
 
 class VersionAnnouncementOverlay extends HookConsumerWidget {
@@ -12,6 +13,12 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final bool isAdmin = ref.watch(isAdminProvider);
+
+    if (!isAdmin) {
+      return const SizedBox.shrink();  // Don't show anything for non-admins
+    }
+
     void goToReleaseNote() async {
       final Uri url =
           Uri.parse('https://github.com/immich-app/immich/releases/latest');

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

@@ -46,6 +46,7 @@ doc/DeleteAssetDto.md
 doc/DeleteAssetResponseDto.md
 doc/DeleteAssetStatus.md
 doc/DownloadArchiveInfo.md
+doc/DownloadInfoDto.md
 doc/DownloadResponseDto.md
 doc/ExifResponseDto.md
 doc/ImportAssetDto.md
@@ -193,6 +194,7 @@ lib/model/delete_asset_dto.dart
 lib/model/delete_asset_response_dto.dart
 lib/model/delete_asset_status.dart
 lib/model/download_archive_info.dart
+lib/model/download_info_dto.dart
 lib/model/download_response_dto.dart
 lib/model/exif_response_dto.dart
 lib/model/import_asset_dto.dart
@@ -309,6 +311,7 @@ test/delete_asset_dto_test.dart
 test/delete_asset_response_dto_test.dart
 test/delete_asset_status_test.dart
 test/download_archive_info_test.dart
+test/download_info_dto_test.dart
 test/download_response_dto_test.dart
 test/exif_response_dto_test.dart
 test/import_asset_dto_test.dart

+ 4 - 3
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.72.2
+- API version: 1.73.0
 - Build package: org.openapitools.codegen.languages.DartClientCodegen
 
 ## Requirements
@@ -91,7 +91,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 *AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | 
-*AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download | 
+*AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download/archive | 
 *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} | 
@@ -101,7 +101,7 @@ Class | Method | HTTP request | Description
 *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* | [**getDownloadInfo**](doc//AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 *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 | 
@@ -221,6 +221,7 @@ Class | Method | HTTP request | Description
  - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md)
  - [DeleteAssetStatus](doc//DeleteAssetStatus.md)
  - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
+ - [DownloadInfoDto](doc//DownloadInfoDto.md)
  - [DownloadResponseDto](doc//DownloadResponseDto.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
  - [ImportAssetDto](doc//ImportAssetDto.md)

+ 7 - 13
mobile/openapi/doc/AssetApi.md

@@ -13,7 +13,7 @@ Method | HTTP request | Description
 [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 [**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | 
-[**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download | 
+[**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download/archive | 
 [**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
 [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | 
 [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
@@ -23,7 +23,7 @@ Method | HTTP request | Description
 [**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 | 
+[**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
@@ -842,7 +842,7 @@ 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)
 
 # **getDownloadInfo**
-> DownloadResponseDto getDownloadInfo(assetIds, albumId, userId, archiveSize, key)
+> DownloadResponseDto getDownloadInfo(downloadInfoDto, key)
 
 
 
@@ -865,14 +865,11 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = AssetApi();
-final assetIds = []; // List<String> | 
-final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final archiveSize = 8.14; // num | 
+final downloadInfoDto = DownloadInfoDto(); // DownloadInfoDto | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.getDownloadInfo(assetIds, albumId, userId, archiveSize, key);
+    final result = api_instance.getDownloadInfo(downloadInfoDto, key);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getDownloadInfo: $e\n');
@@ -883,10 +880,7 @@ try {
 
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
- **assetIds** | [**List<String>**](String.md)|  | [optional] [default to const []]
- **albumId** | **String**|  | [optional] 
- **userId** | **String**|  | [optional] 
- **archiveSize** | **num**|  | [optional] 
+ **downloadInfoDto** | [**DownloadInfoDto**](DownloadInfoDto.md)|  | 
  **key** | **String**|  | [optional] 
 
 ### Return type
@@ -899,7 +893,7 @@ Name | Type | Description  | Notes
 
 ### HTTP request headers
 
- - **Content-Type**: Not defined
+ - **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)

+ 18 - 0
mobile/openapi/doc/DownloadInfoDto.md

@@ -0,0 +1,18 @@
+# openapi.model.DownloadInfoDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**albumId** | **String** |  | [optional] 
+**archiveSize** | **int** |  | [optional] 
+**assetIds** | **List<String>** |  | [optional] [default to const []]
+**userId** | **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)
+
+

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

@@ -83,6 +83,7 @@ part 'model/delete_asset_dto.dart';
 part 'model/delete_asset_response_dto.dart';
 part 'model/delete_asset_status.dart';
 part 'model/download_archive_info.dart';
+part 'model/download_info_dto.dart';
 part 'model/download_response_dto.dart';
 part 'model/exif_response_dto.dart';
 part 'model/import_asset_dto.dart';

+ 12 - 36
mobile/openapi/lib/api/asset_api.dart

@@ -230,7 +230,7 @@ class AssetApi {
     return null;
   }
 
-  /// Performs an HTTP 'POST /asset/download' operation and returns the [Response].
+  /// Performs an HTTP 'POST /asset/download/archive' operation and returns the [Response].
   /// Parameters:
   ///
   /// * [AssetIdsDto] assetIdsDto (required):
@@ -238,7 +238,7 @@ class AssetApi {
   /// * [String] key:
   Future<Response> downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, }) async {
     // ignore: prefer_const_declarations
-    final path = r'/asset/download';
+    final path = r'/asset/download/archive';
 
     // ignore: prefer_final_locals
     Object? postBody = assetIdsDto;
@@ -853,51 +853,33 @@ class AssetApi {
     return null;
   }
 
-  /// Performs an HTTP 'GET /asset/download' operation and returns the [Response].
+  /// Performs an HTTP 'POST /asset/download/info' operation and returns the [Response].
   /// Parameters:
   ///
-  /// * [List<String>] assetIds:
-  ///
-  /// * [String] albumId:
-  ///
-  /// * [String] userId:
-  ///
-  /// * [num] archiveSize:
+  /// * [DownloadInfoDto] downloadInfoDto (required):
   ///
   /// * [String] key:
-  Future<Response> getDownloadInfoWithHttpInfo({ List<String>? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async {
+  Future<Response> getDownloadInfoWithHttpInfo(DownloadInfoDto downloadInfoDto, { String? key, }) async {
     // ignore: prefer_const_declarations
-    final path = r'/asset/download';
+    final path = r'/asset/download/info';
 
     // ignore: prefer_final_locals
-    Object? postBody;
+    Object? postBody = downloadInfoDto;
 
     final queryParams = <QueryParam>[];
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
 
-    if (assetIds != null) {
-      queryParams.addAll(_queryParams('multi', 'assetIds', assetIds));
-    }
-    if (albumId != null) {
-      queryParams.addAll(_queryParams('', 'albumId', albumId));
-    }
-    if (userId != null) {
-      queryParams.addAll(_queryParams('', 'userId', userId));
-    }
-    if (archiveSize != null) {
-      queryParams.addAll(_queryParams('', 'archiveSize', archiveSize));
-    }
     if (key != null) {
       queryParams.addAll(_queryParams('', 'key', key));
     }
 
-    const contentTypes = <String>[];
+    const contentTypes = <String>['application/json'];
 
 
     return apiClient.invokeAPI(
       path,
-      'GET',
+      'POST',
       queryParams,
       postBody,
       headerParams,
@@ -908,17 +890,11 @@ class AssetApi {
 
   /// Parameters:
   ///
-  /// * [List<String>] assetIds:
-  ///
-  /// * [String] albumId:
-  ///
-  /// * [String] userId:
-  ///
-  /// * [num] archiveSize:
+  /// * [DownloadInfoDto] downloadInfoDto (required):
   ///
   /// * [String] key:
-  Future<DownloadResponseDto?> getDownloadInfo({ List<String>? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async {
-    final response = await getDownloadInfoWithHttpInfo( assetIds: assetIds, albumId: albumId, userId: userId, archiveSize: archiveSize, key: key, );
+  Future<DownloadResponseDto?> getDownloadInfo(DownloadInfoDto downloadInfoDto, { String? key, }) async {
+    final response = await getDownloadInfoWithHttpInfo(downloadInfoDto,  key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

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

@@ -259,6 +259,8 @@ class ApiClient {
           return DeleteAssetStatusTypeTransformer().decode(value);
         case 'DownloadArchiveInfo':
           return DownloadArchiveInfo.fromJson(value);
+        case 'DownloadInfoDto':
+          return DownloadInfoDto.fromJson(value);
         case 'DownloadResponseDto':
           return DownloadResponseDto.fromJson(value);
         case 'ExifResponseDto':

+ 150 - 0
mobile/openapi/lib/model/download_info_dto.dart

@@ -0,0 +1,150 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class DownloadInfoDto {
+  /// Returns a new [DownloadInfoDto] instance.
+  DownloadInfoDto({
+    this.albumId,
+    this.archiveSize,
+    this.assetIds = const [],
+    this.userId,
+  });
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? albumId;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  int? archiveSize;
+
+  List<String> assetIds;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? userId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is DownloadInfoDto &&
+     other.albumId == albumId &&
+     other.archiveSize == archiveSize &&
+     other.assetIds == assetIds &&
+     other.userId == userId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (albumId == null ? 0 : albumId!.hashCode) +
+    (archiveSize == null ? 0 : archiveSize!.hashCode) +
+    (assetIds.hashCode) +
+    (userId == null ? 0 : userId!.hashCode);
+
+  @override
+  String toString() => 'DownloadInfoDto[albumId=$albumId, archiveSize=$archiveSize, assetIds=$assetIds, userId=$userId]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+    if (this.albumId != null) {
+      json[r'albumId'] = this.albumId;
+    } else {
+    //  json[r'albumId'] = null;
+    }
+    if (this.archiveSize != null) {
+      json[r'archiveSize'] = this.archiveSize;
+    } else {
+    //  json[r'archiveSize'] = null;
+    }
+      json[r'assetIds'] = this.assetIds;
+    if (this.userId != null) {
+      json[r'userId'] = this.userId;
+    } else {
+    //  json[r'userId'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [DownloadInfoDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static DownloadInfoDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return DownloadInfoDto(
+        albumId: mapValueOfType<String>(json, r'albumId'),
+        archiveSize: mapValueOfType<int>(json, r'archiveSize'),
+        assetIds: json[r'assetIds'] is List
+            ? (json[r'assetIds'] as List).cast<String>()
+            : const [],
+        userId: mapValueOfType<String>(json, r'userId'),
+      );
+    }
+    return null;
+  }
+
+  static List<DownloadInfoDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <DownloadInfoDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = DownloadInfoDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, DownloadInfoDto> mapFromJson(dynamic json) {
+    final map = <String, DownloadInfoDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = DownloadInfoDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of DownloadInfoDto-objects as value to a dart map
+  static Map<String, List<DownloadInfoDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<DownloadInfoDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = DownloadInfoDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+  };
+}
+

+ 1 - 1
mobile/openapi/test/asset_api_test.dart

@@ -97,7 +97,7 @@ void main() {
       // TODO
     });
 
-    //Future<DownloadResponseDto> getDownloadInfo({ List<String> assetIds, String albumId, String userId, num archiveSize, String key }) async
+    //Future<DownloadResponseDto> getDownloadInfo(DownloadInfoDto downloadInfoDto, { String key }) async
     test('test getDownloadInfo', () async {
       // TODO
     });

+ 42 - 0
mobile/openapi/test/download_info_dto_test.dart

@@ -0,0 +1,42 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for DownloadInfoDto
+void main() {
+  // final instance = DownloadInfoDto();
+
+  group('test DownloadInfoDto', () {
+    // String albumId
+    test('to test the property `albumId`', () async {
+      // TODO
+    });
+
+    // int archiveSize
+    test('to test the property `archiveSize`', () async {
+      // TODO
+    });
+
+    // List<String> assetIds (default value: const [])
+    test('to test the property `assetIds`', () async {
+      // TODO
+    });
+
+    // String userId
+    test('to test the property `userId`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 1 - 1
mobile/pubspec.yaml

@@ -2,7 +2,7 @@ name: immich_mobile
 description: Immich - selfhosted backup media file on mobile phone
 
 publish_to: "none"
-version: 1.72.2+95
+version: 1.73.0+96
 isar_version: &isar_version 3.1.0+1
 
 environment:

+ 48 - 51
server/immich-openapi-specs.json

@@ -1026,48 +1026,10 @@
         ]
       }
     },
-    "/asset/download": {
-      "get": {
-        "operationId": "getDownloadInfo",
+    "/asset/download/archive": {
+      "post": {
+        "operationId": "downloadArchive",
         "parameters": [
-          {
-            "name": "assetIds",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "format": "uuid",
-              "type": "array",
-              "items": {
-                "type": "string"
-              }
-            }
-          },
-          {
-            "name": "albumId",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "format": "uuid",
-              "type": "string"
-            }
-          },
-          {
-            "name": "userId",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "format": "uuid",
-              "type": "string"
-            }
-          },
-          {
-            "name": "archiveSize",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "type": "number"
-            }
-          },
           {
             "name": "key",
             "required": false,
@@ -1077,12 +1039,23 @@
             }
           }
         ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetIdsDto"
+              }
+            }
+          },
+          "required": true
+        },
         "responses": {
           "200": {
             "content": {
-              "application/json": {
+              "application/octet-stream": {
                 "schema": {
-                  "$ref": "#/components/schemas/DownloadResponseDto"
+                  "format": "binary",
+                  "type": "string"
                 }
               }
             },
@@ -1103,9 +1076,11 @@
         "tags": [
           "Asset"
         ]
-      },
+      }
+    },
+    "/asset/download/info": {
       "post": {
-        "operationId": "downloadArchive",
+        "operationId": "getDownloadInfo",
         "parameters": [
           {
             "name": "key",
@@ -1120,19 +1095,18 @@
           "content": {
             "application/json": {
               "schema": {
-                "$ref": "#/components/schemas/AssetIdsDto"
+                "$ref": "#/components/schemas/DownloadInfoDto"
               }
             }
           },
           "required": true
         },
         "responses": {
-          "200": {
+          "201": {
             "content": {
-              "application/octet-stream": {
+              "application/json": {
                 "schema": {
-                  "format": "binary",
-                  "type": "string"
+                  "$ref": "#/components/schemas/DownloadResponseDto"
                 }
               }
             },
@@ -4757,7 +4731,7 @@
   "info": {
     "title": "Immich",
     "description": "Immich API",
-    "version": "1.72.2",
+    "version": "1.73.0",
     "contact": {}
   },
   "tags": [],
@@ -5747,6 +5721,29 @@
         ],
         "type": "object"
       },
+      "DownloadInfoDto": {
+        "properties": {
+          "albumId": {
+            "format": "uuid",
+            "type": "string"
+          },
+          "archiveSize": {
+            "type": "integer"
+          },
+          "assetIds": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "userId": {
+            "format": "uuid",
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
       "DownloadResponseDto": {
         "properties": {
           "archives": {

+ 2 - 2
server/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "immich",
-  "version": "1.72.2",
+  "version": "1.73.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "immich",
-      "version": "1.72.2",
+      "version": "1.73.0",
       "license": "UNLICENSED",
       "dependencies": {
         "@babel/runtime": "^7.20.13",

+ 1 - 1
server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "immich",
-  "version": "1.72.2",
+  "version": "1.73.0",
   "description": "",
   "author": "",
   "private": true,

+ 5 - 0
server/src/domain/access/access.core.ts

@@ -24,6 +24,8 @@ export enum Permission {
   RULE_UPDATE = 'rule.update',
   RULE_DELETE = 'rule.delete',
 
+  ARCHIVE_READ = 'archive.read',
+
   LIBRARY_READ = 'library.read',
   LIBRARY_DOWNLOAD = 'library.download',
 }
@@ -173,6 +175,9 @@ export class AccessCore {
       case Permission.RULE_DELETE:
         return this.repository.rule.hasOwnerAccess(authUser.id, id);
 
+      case Permission.ARCHIVE_READ:
+        return authUser.id === id;
+
       case Permission.LIBRARY_READ:
         return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
 

+ 7 - 1
server/src/domain/album/album.repository.ts

@@ -7,11 +7,17 @@ export interface AlbumAssetCount {
   assetCount: number;
 }
 
+export interface AlbumInfoOptions {
+  withAssets: boolean;
+}
+
 export interface IAlbumRepository {
-  getById(id: string): Promise<AlbumEntity | null>;
+  getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>;
   getByIds(ids: string[]): Promise<AlbumEntity[]>;
   getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
   hasAsset(id: string, assetId: string): Promise<boolean>;
+  /** Remove an asset from _all_ albums */
+  removeAsset(id: string): Promise<void>;
   getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
   getInvalidThumbnail(): Promise<string[]>;
   getOwned(ownerId: string): Promise<AlbumEntity[]>;

+ 4 - 3
server/src/domain/album/album.service.spec.ts

@@ -365,6 +365,7 @@ describe(AlbumService.name, () => {
         updatedAt: expect.any(Date),
         sharedUsers: [],
       });
+      expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false });
     });
 
     it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
@@ -433,7 +434,7 @@ describe(AlbumService.name, () => {
 
       await sut.get(authStub.admin, albumStub.oneAsset.id, {});
 
-      expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id);
+      expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
       expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
     });
 
@@ -443,7 +444,7 @@ describe(AlbumService.name, () => {
 
       await sut.get(authStub.adminSharedLink, 'album-123', {});
 
-      expect(albumMock.getById).toHaveBeenCalledWith('album-123');
+      expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
       expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
         authStub.adminSharedLink.sharedLinkId,
         'album-123',
@@ -456,7 +457,7 @@ describe(AlbumService.name, () => {
 
       await sut.get(authStub.user1, 'album-123', {});
 
-      expect(albumMock.getById).toHaveBeenCalledWith('album-123');
+      expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
       expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123');
     });
 

+ 12 - 12
server/src/domain/album/album.service.ts

@@ -12,7 +12,7 @@ import {
   mapAlbumWithAssets,
   mapAlbumWithoutAssets,
 } from './album-response.dto';
-import { IAlbumRepository } from './album.repository';
+import { AlbumInfoOptions, IAlbumRepository } from './album.repository';
 import { AddUsersDto, AlbumInfoDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
 
 @Injectable()
@@ -84,7 +84,7 @@ export class AlbumService {
   async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto) {
     await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
     await this.albumRepository.updateThumbnails();
-    return mapAlbum(await this.findOrFail(id), !dto.withoutAssets);
+    return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);
   }
 
   async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
@@ -111,7 +111,7 @@ export class AlbumService {
   async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
     await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id);
 
-    const album = await this.findOrFail(id);
+    const album = await this.findOrFail(id, { withAssets: true });
 
     if (dto.albumThumbnailAssetId) {
       const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
@@ -129,13 +129,13 @@ export class AlbumService {
 
     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } });
 
-    return mapAlbumWithAssets(updatedAlbum);
+    return mapAlbumWithoutAssets(updatedAlbum);
   }
 
   async delete(authUser: AuthUserDto, id: string): Promise<void> {
     await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id);
 
-    const album = await this.albumRepository.getById(id);
+    const album = await this.findOrFail(id, { withAssets: false });
     if (!album) {
       throw new BadRequestException('Album not found');
     }
@@ -145,7 +145,7 @@ export class AlbumService {
   }
 
   async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
-    const album = await this.findOrFail(id);
+    const album = await this.findOrFail(id, { withAssets: true });
 
     await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
 
@@ -181,7 +181,7 @@ export class AlbumService {
   }
 
   async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
-    const album = await this.findOrFail(id);
+    const album = await this.findOrFail(id, { withAssets: true });
 
     await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
 
@@ -225,7 +225,7 @@ export class AlbumService {
   async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto): Promise<AlbumResponseDto> {
     await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
 
-    const album = await this.findOrFail(id);
+    const album = await this.findOrFail(id, { withAssets: false });
 
     for (const userId of dto.sharedUserIds) {
       const exists = album.sharedUsers.find((user) => user.id === userId);
@@ -247,7 +247,7 @@ export class AlbumService {
         updatedAt: new Date(),
         sharedUsers: album.sharedUsers,
       })
-      .then(mapAlbumWithAssets);
+      .then(mapAlbumWithoutAssets);
   }
 
   async removeUser(authUser: AuthUserDto, id: string, userId: string | 'me'): Promise<void> {
@@ -255,7 +255,7 @@ export class AlbumService {
       userId = authUser.id;
     }
 
-    const album = await this.findOrFail(id);
+    const album = await this.findOrFail(id, { withAssets: false });
 
     if (album.ownerId === userId) {
       throw new BadRequestException('Cannot remove album owner');
@@ -278,8 +278,8 @@ export class AlbumService {
     });
   }
 
-  private async findOrFail(id: string) {
-    const album = await this.albumRepository.getById(id);
+  private async findOrFail(id: string, options: AlbumInfoOptions) {
+    const album = await this.albumRepository.getById(id, options);
     if (!album) {
       throw new BadRequestException('Album not found');
     }

+ 6 - 3
server/src/domain/asset/asset.service.ts

@@ -13,7 +13,7 @@ import { IAssetRepository } from './asset.repository';
 import {
   AssetIdsDto,
   DownloadArchiveInfo,
-  DownloadDto,
+  DownloadInfoDto,
   DownloadResponseDto,
   MemoryLaneDto,
   TimeBucketAssetDto,
@@ -148,6 +148,9 @@ export class AssetService {
     if (dto.albumId) {
       await this.access.requirePermission(authUser, Permission.ALBUM_READ, [dto.albumId]);
     } else if (dto.userId) {
+      if (dto.isArchived !== false) {
+        await this.access.requirePermission(authUser, Permission.ARCHIVE_READ, [dto.userId]);
+      }
       await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [dto.userId]);
     } else {
       dto.userId = authUser.id;
@@ -176,7 +179,7 @@ export class AssetService {
     return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath));
   }
 
-  async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise<DownloadResponseDto> {
+  async getDownloadInfo(authUser: AuthUserDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
     const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
     const archives: DownloadArchiveInfo[] = [];
     let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
@@ -234,7 +237,7 @@ export class AssetService {
     return { stream: zip.stream };
   }
 
-  private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadDto): Promise<AsyncGenerator<AssetEntity[]>> {
+  private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadInfoDto): Promise<AsyncGenerator<AssetEntity[]>> {
     const PAGINATION_SIZE = 2500;
 
     if (dto.assetIds) {

+ 2 - 1
server/src/domain/asset/dto/download.dto.ts

@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
 import { IsInt, IsOptional, IsPositive } from 'class-validator';
 import { ValidateUUID } from '../../domain.util';
 
-export class DownloadDto {
+export class DownloadInfoDto {
   @ValidateUUID({ each: true, optional: true })
   assetIds?: string[];
 
@@ -15,6 +15,7 @@ export class DownloadDto {
   @IsInt()
   @IsPositive()
   @IsOptional()
+  @ApiProperty({ type: 'integer' })
   archiveSize?: number;
 }
 

+ 2 - 0
server/src/domain/domain.constant.ts

@@ -48,6 +48,7 @@ const image: Record<string, string[]> = {
   '.heic': ['image/heic'],
   '.heif': ['image/heif'],
   '.iiq': ['image/iiq', 'image/x-phaseone-iiq'],
+  '.insp': ['image/jpeg'],
   '.jpeg': ['image/jpeg'],
   '.jpg': ['image/jpeg'],
   '.jxl': ['image/jxl'],
@@ -79,6 +80,7 @@ const video: Record<string, string[]> = {
   '.3gp': ['video/3gpp'],
   '.avi': ['video/avi', 'video/msvideo', 'video/vnd.avi', 'video/x-msvideo'],
   '.flv': ['video/x-flv'],
+  '.insv': ['video/mp4'],
   '.m2ts': ['video/mp2t'],
   '.mkv': ['video/x-matroska'],
   '.mov': ['video/quicktime'],

+ 2 - 0
server/src/domain/job/job.constants.ts

@@ -33,6 +33,7 @@ export enum JobName {
   // metadata
   QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
   METADATA_EXTRACTION = 'metadata-extraction',
+  LINK_LIVE_PHOTOS = 'link-live-photos',
 
   // user deletion
   USER_DELETION = 'user-deletion',
@@ -102,6 +103,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
   // metadata
   [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
   [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
+  [JobName.LINK_LIVE_PHOTOS]: QueueName.METADATA_EXTRACTION,
 
   // storage template
   [JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION,

+ 1 - 0
server/src/domain/job/job.repository.ts

@@ -47,6 +47,7 @@ export type JobItem =
   // Metadata Extraction
   | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
   | { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
+  | { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob }
 
   // Sidecar Scanning
   | { name: JobName.QUEUE_SIDECAR; data: IBaseJob }

+ 4 - 0
server/src/domain/job/job.service.spec.ts

@@ -255,6 +255,10 @@ describe(JobService.name, () => {
       },
       {
         item: { name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } },
+        jobs: [JobName.LINK_LIVE_PHOTOS],
+      },
+      {
+        item: { name: JobName.LINK_LIVE_PHOTOS, data: { id: 'asset-1' } },
         jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, JobName.SEARCH_INDEX_ASSET],
       },
       {

+ 5 - 1
server/src/domain/job/job.service.ts

@@ -149,6 +149,10 @@ export class JobService {
         break;
 
       case JobName.METADATA_EXTRACTION:
+        await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data });
+        break;
+
+      case JobName.LINK_LIVE_PHOTOS:
         await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data });
         break;
 
@@ -186,7 +190,7 @@ export class JobService {
       case JobName.CLASSIFY_IMAGE:
       case JobName.ENCODE_CLIP:
       case JobName.RECOGNIZE_FACES:
-      case JobName.METADATA_EXTRACTION:
+      case JobName.LINK_LIVE_PHOTOS:
         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [item.data.id] } });
         break;
     }

+ 1 - 0
server/src/domain/person/person.repository.ts

@@ -4,6 +4,7 @@ export const IPersonRepository = 'IPersonRepository';
 
 export interface PersonSearchOptions {
   minimumFaceCount: number;
+  withHidden: boolean;
 }
 
 export interface UpdateFacesData {

+ 3 - 3
server/src/domain/person/person.service.spec.ts

@@ -47,7 +47,7 @@ describe(PersonService.name, () => {
         visible: 1,
         people: [responseDto],
       });
-      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
+      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1, withHidden: false });
     });
     it('should get all visible people with thumbnails', async () => {
       personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
@@ -56,7 +56,7 @@ describe(PersonService.name, () => {
         visible: 1,
         people: [responseDto],
       });
-      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
+      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1, withHidden: false });
     });
     it('should get all hidden and visible people with thumbnails', async () => {
       personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
@@ -73,7 +73,7 @@ describe(PersonService.name, () => {
           },
         ],
       });
-      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
+      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1, withHidden: true });
     });
   });
 

+ 4 - 2
server/src/domain/person/person.service.ts

@@ -26,8 +26,10 @@ export class PersonService {
   ) {}
 
   async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
-    const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
-
+    const people = await this.repository.getAll(authUser.id, {
+      minimumFaceCount: 1,
+      withHidden: dto.withHidden || false,
+    });
     const persons: PersonResponseDto[] = people
       // with thumbnails
       .filter((person) => !!person.thumbnailPath)

+ 3 - 3
server/src/domain/server-info/server-info.service.ts

@@ -69,9 +69,9 @@ export class ServerInfoService {
 
   getSupportedMediaTypes(): ServerMediaTypesResponseDto {
     return {
-      video: [...Object.keys(mimeTypes.video)],
-      image: [...Object.keys(mimeTypes.image)],
-      sidecar: [...Object.keys(mimeTypes.sidecar)],
+      video: Object.keys(mimeTypes.video),
+      image: Object.keys(mimeTypes.image),
+      sidecar: Object.keys(mimeTypes.sidecar),
     };
   }
 }

+ 4 - 4
server/src/immich/controllers/asset.controller.ts

@@ -5,7 +5,7 @@ import {
   AssetStatsDto,
   AssetStatsResponseDto,
   AuthUserDto,
-  DownloadDto,
+  DownloadInfoDto,
   DownloadResponseDto,
   MapMarkerResponseDto,
   MemoryLaneDto,
@@ -39,13 +39,13 @@ export class AssetController {
   }
 
   @SharedLinkRoute()
-  @Get('download')
-  getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Query() dto: DownloadDto): Promise<DownloadResponseDto> {
+  @Post('download/info')
+  getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {
     return this.service.getDownloadInfo(authUser, dto);
   }
 
   @SharedLinkRoute()
-  @Post('download')
+  @Post('download/archive')
   @HttpCode(HttpStatus.OK)
   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
   downloadArchive(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {

+ 13 - 0
server/src/infra/migrations/1692057328660-fixGPSNullIsland.ts

@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from "typeorm"
+
+export class FixGPSNullIsland1692057328660 implements MigrationInterface {
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`UPDATE "exif" SET latitude = NULL, longitude = NULL WHERE latitude = 0 AND longitude = 0;`);
+    }
+
+    public async down(): Promise<void> {
+        // Setting lat,lon to 0 not necessary
+    }
+
+}

+ 37 - 24
server/src/infra/repositories/album.repository.ts

@@ -1,7 +1,7 @@
-import { AlbumAssetCount, IAlbumRepository } from '@app/domain';
+import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from '@app/domain';
 import { Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { In, IsNull, Not, Repository } from 'typeorm';
+import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
+import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
 import { dataSource } from '../database.config';
 import { AlbumEntity, AssetEntity } from '../entities';
 
@@ -10,28 +10,31 @@ export class AlbumRepository implements IAlbumRepository {
   constructor(
     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
     @InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
+    @InjectDataSource() private dataSource: DataSource,
   ) {}
 
-  getById(id: string): Promise<AlbumEntity | null> {
-    return this.repository.findOne({
-      where: {
-        id,
-      },
-      relations: {
-        owner: true,
-        sharedUsers: true,
-        assets: {
-          exifInfo: true,
-        },
-        sharedLinks: true,
-        rules: true,
-      },
-      order: {
-        assets: {
-          fileCreatedAt: 'DESC',
-        },
-      },
-    });
+  getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> {
+    const relations: FindOptionsRelations<AlbumEntity> = {
+      owner: true,
+      sharedUsers: true,
+      assets: false,
+      sharedLinks: true,
+      rules: true,
+    };
+
+    const order: FindOptionsOrder<AlbumEntity> = {};
+
+    if (options.withAssets) {
+      relations.assets = {
+        exifInfo: true,
+      };
+
+      order.assets = {
+        fileCreatedAt: 'DESC',
+      };
+    }
+
+    return this.repository.findOne({ where: { id }, relations, order });
   }
 
   getByIds(ids: string[]): Promise<AlbumEntity[]> {
@@ -84,7 +87,7 @@ export class AlbumRepository implements IAlbumRepository {
    */
   async getInvalidThumbnail(): Promise<string[]> {
     // Using dataSource, because there is no direct access to albums_assets_assets.
-    const albumHasAssets = dataSource
+    const albumHasAssets = this.dataSource
       .createQueryBuilder()
       .select('1')
       .from('albums_assets_assets', 'albums_assets')
@@ -150,6 +153,16 @@ export class AlbumRepository implements IAlbumRepository {
     });
   }
 
+  async removeAsset(assetId: string): Promise<void> {
+    // Using dataSource, because there is no direct access to albums_assets_assets.
+    await this.dataSource
+      .createQueryBuilder()
+      .delete()
+      .from('albums_assets_assets')
+      .where('"albums_assets_assets"."assetsId" = :assetId', { assetId })
+      .execute();
+  }
+
   hasAsset(id: string, assetId: string): Promise<boolean> {
     return this.repository.exist({
       where: {

+ 10 - 4
server/src/infra/repositories/person.repository.ts

@@ -51,16 +51,22 @@ export class PersonRepository implements IPersonRepository {
   }
 
   getAll(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> {
-    return this.personRepository
+    const queryBuilder = this.personRepository
       .createQueryBuilder('person')
       .leftJoin('person.faces', 'face')
       .where('person.ownerId = :userId', { userId })
-      .orderBy('COUNT(face.assetId)', 'DESC')
+      .orderBy('person.isHidden', 'ASC')
+      .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
+      .addOrderBy('COUNT(face.assetId)', 'DESC')
       .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
       .having('COUNT(face.assetId) >= :faces', { faces: options?.minimumFaceCount || 1 })
       .groupBy('person.id')
-      .limit(500)
-      .getMany();
+      .limit(500);
+    if (!options?.withHidden) {
+      queryBuilder.andWhere('person.isHidden = false');
+    }
+
+    return queryBuilder.getMany();
   }
 
   getAllWithoutFaces(): Promise<PersonEntity[]> {

+ 1 - 0
server/src/microservices/app.service.ts

@@ -66,6 +66,7 @@ export class AppService {
       [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
       [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
       [JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
+      [JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataProcessor.handleLivePhotoLinking(data),
       [JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
       [JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
       [JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),

+ 44 - 28
server/src/microservices/processors/metadata-extraction.processor.ts

@@ -1,4 +1,5 @@
 import {
+  IAlbumRepository,
   IAssetRepository,
   IBaseJob,
   ICryptoRepository,
@@ -59,6 +60,7 @@ export class MetadataExtractionProcessor {
 
   constructor(
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
+    @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@@ -92,6 +94,38 @@ export class MetadataExtractionProcessor {
     }
   }
 
+  async handleLivePhotoLinking(job: IEntityJob) {
+    const { id } = job;
+    const [asset] = await this.assetRepository.getByIds([id]);
+    if (!asset?.exifInfo) {
+      return false;
+    }
+
+    if (!asset.exifInfo.livePhotoCID) {
+      return true;
+    }
+
+    const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO;
+    const match = await this.assetRepository.findLivePhotoMatch({
+      livePhotoCID: asset.exifInfo.livePhotoCID,
+      ownerId: asset.ownerId,
+      otherAssetId: asset.id,
+      type: otherType,
+    });
+
+    if (!match) {
+      return true;
+    }
+
+    const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
+
+    await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: motionAsset.id });
+    await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
+    await this.albumRepository.removeAsset(motionAsset.id);
+
+    return true;
+  }
+
   async handleQueueMetadataExtraction(job: IBaseJob) {
     const { force } = job;
     const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
@@ -308,8 +342,16 @@ export class MetadataExtractionProcessor {
 
     const latitude = getExifProperty('GPSLatitude');
     const longitude = getExifProperty('GPSLongitude');
-    newExif.latitude = latitude !== null ? parseLatitude(latitude) : null;
-    newExif.longitude = longitude !== null ? parseLongitude(longitude) : null;
+    const lat = parseLatitude(latitude);
+    const lon = parseLongitude(longitude);
+
+    if (lat === 0 && lon === 0) {
+      this.logger.warn(`Latitude & Longitude were on Null Island (${lat},${lon}), not assigning coordinates`);
+    } else {
+      newExif.latitude = lat;
+      newExif.longitude = lon;
+    }
+
     if (getExifProperty('MotionPhoto')) {
       // Seen on more recent Pixel phones: starting as early as Pixel 4a, possibly earlier.
       const rawDirectory = getExifProperty('Directory');
@@ -343,19 +385,6 @@ export class MetadataExtractionProcessor {
     }
 
     newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
-    if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
-      const motionAsset = await this.assetRepository.findLivePhotoMatch({
-        livePhotoCID: newExif.livePhotoCID,
-        otherAssetId: asset.id,
-        ownerId: asset.ownerId,
-        type: AssetType.VIDEO,
-      });
-      if (motionAsset) {
-        await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
-        await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
-      }
-    }
-
     await this.applyReverseGeocoding(asset, newExif);
 
     /**
@@ -420,19 +449,6 @@ export class MetadataExtractionProcessor {
     newExif.fps = null;
     newExif.livePhotoCID = exifData?.ContentIdentifier || null;
 
-    if (newExif.livePhotoCID) {
-      const photoAsset = await this.assetRepository.findLivePhotoMatch({
-        livePhotoCID: newExif.livePhotoCID,
-        ownerId: asset.ownerId,
-        otherAssetId: asset.id,
-        type: AssetType.IMAGE,
-      });
-      if (photoAsset) {
-        await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
-        await this.assetRepository.save({ id: asset.id, isVisible: false });
-      }
-    }
-
     if (videoTags && videoTags['location']) {
       const location = videoTags['location'] as string;
       const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;

+ 12 - 0
server/src/microservices/utils/exif/coordinates.spec.ts

@@ -23,6 +23,12 @@ describe('parsing latitude from string input', () => {
   });
 });
 
+describe('parsing latitude from null input', () => {
+  it('returns null for null input', () => {
+    expect(parseLatitude(null)).toBeNull();
+  });
+});
+
 describe('parsing longitude from string input', () => {
   it('returns null for invalid inputs', () => {
     expect(parseLongitude('')).toBeNull();
@@ -44,3 +50,9 @@ describe('parsing longitude from string input', () => {
     expect(parseLongitude('-0.0')).toBeCloseTo(-0.0);
   });
 });
+
+describe('parsing longitude from null input', () => {
+  it('returns null for null input', () => {
+    expect(parseLongitude(null)).toBeNull();
+  });
+});

+ 9 - 2
server/src/microservices/utils/exif/coordinates.ts

@@ -1,6 +1,9 @@
 import { isNumberInRange } from '../numbers';
 
-export function parseLatitude(input: string | number): number | null {
+export function parseLatitude(input: string | number | null): number | null {
+  if (input === null) {
+    return null;
+  }
   const latitude = typeof input === 'string' ? Number.parseFloat(input) : input;
 
   if (isNumberInRange(latitude, -90, 90)) {
@@ -9,7 +12,11 @@ export function parseLatitude(input: string | number): number | null {
   return null;
 }
 
-export function parseLongitude(input: string | number): number | null {
+export function parseLongitude(input: string | number | null): number | null {
+  if (input === null) {
+    return null;
+  }
+
   const longitude = typeof input === 'string' ? Number.parseFloat(input) : input;
 
   if (isNumberInRange(longitude, -180, 180)) {

+ 1 - 0
server/test/repositories/album.repository.mock.ts

@@ -12,6 +12,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
     getNotShared: jest.fn(),
     deleteAll: jest.fn(),
     getAll: jest.fn(),
+    removeAsset: jest.fn(),
     hasAsset: jest.fn(),
     create: jest.fn(),
     update: jest.fn(),

+ 51 - 58
web/src/api/open-api/api.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.72.2
+ * The version of the OpenAPI document: 1.73.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1171,6 +1171,37 @@ export interface DownloadArchiveInfo {
      */
     'size': number;
 }
+/**
+ * 
+ * @export
+ * @interface DownloadInfoDto
+ */
+export interface DownloadInfoDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof DownloadInfoDto
+     */
+    'albumId'?: string;
+    /**
+     * 
+     * @type {number}
+     * @memberof DownloadInfoDto
+     */
+    'archiveSize'?: number;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof DownloadInfoDto
+     */
+    'assetIds'?: Array<string>;
+    /**
+     * 
+     * @type {string}
+     * @memberof DownloadInfoDto
+     */
+    'userId'?: string;
+}
 /**
  * 
  * @export
@@ -5000,7 +5031,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'assetIdsDto' is not null or undefined
             assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto)
-            const localVarPath = `/asset/download`;
+            const localVarPath = `/asset/download/archive`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             let baseOptions;
@@ -5499,16 +5530,15 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         },
         /**
          * 
-         * @param {Array<string>} [assetIds] 
-         * @param {string} [albumId] 
-         * @param {string} [userId] 
-         * @param {number} [archiveSize] 
+         * @param {DownloadInfoDto} downloadInfoDto 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getDownloadInfo: async (assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
-            const localVarPath = `/asset/download`;
+        getDownloadInfo: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'downloadInfoDto' is not null or undefined
+            assertParamExists('getDownloadInfo', 'downloadInfoDto', downloadInfoDto)
+            const localVarPath = `/asset/download/info`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             let baseOptions;
@@ -5516,7 +5546,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 baseOptions = configuration.baseOptions;
             }
 
-            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
             const localVarHeaderParameter = {} as any;
             const localVarQueryParameter = {} as any;
 
@@ -5529,31 +5559,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             // http bearer authentication required
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
 
-            if (assetIds) {
-                localVarQueryParameter['assetIds'] = assetIds;
-            }
-
-            if (albumId !== undefined) {
-                localVarQueryParameter['albumId'] = albumId;
-            }
-
-            if (userId !== undefined) {
-                localVarQueryParameter['userId'] = userId;
-            }
-
-            if (archiveSize !== undefined) {
-                localVarQueryParameter['archiveSize'] = archiveSize;
-            }
-
             if (key !== undefined) {
                 localVarQueryParameter['key'] = key;
             }
 
 
     
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(downloadInfoDto, localVarRequestOptions, configuration)
 
             return {
                 url: toPathString(localVarUrlObj),
@@ -6261,16 +6278,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
         },
         /**
          * 
-         * @param {Array<string>} [assetIds] 
-         * @param {string} [albumId] 
-         * @param {string} [userId] 
-         * @param {number} [archiveSize] 
+         * @param {DownloadInfoDto} downloadInfoDto 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getDownloadInfo(assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DownloadResponseDto>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options);
+        async getDownloadInfo(downloadInfoDto: DownloadInfoDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DownloadResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(downloadInfoDto, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -6526,8 +6540,8 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig): AxiosPromise<DownloadResponseDto> {
-            return localVarFp.getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(axios, basePath));
+        getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest, options?: AxiosRequestConfig): AxiosPromise<DownloadResponseDto> {
+            return localVarFp.getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -6908,31 +6922,10 @@ export interface AssetApiGetByTimeBucketRequest {
 export interface AssetApiGetDownloadInfoRequest {
     /**
      * 
-     * @type {Array<string>}
-     * @memberof AssetApiGetDownloadInfo
-     */
-    readonly assetIds?: Array<string>
-
-    /**
-     * 
-     * @type {string}
-     * @memberof AssetApiGetDownloadInfo
-     */
-    readonly albumId?: string
-
-    /**
-     * 
-     * @type {string}
-     * @memberof AssetApiGetDownloadInfo
-     */
-    readonly userId?: string
-
-    /**
-     * 
-     * @type {number}
+     * @type {DownloadInfoDto}
      * @memberof AssetApiGetDownloadInfo
      */
-    readonly archiveSize?: number
+    readonly downloadInfoDto: DownloadInfoDto
 
     /**
      * 
@@ -7401,8 +7394,8 @@ export class AssetApi extends BaseAPI {
      * @throws {RequiredError}
      * @memberof AssetApi
      */
-    public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
+    public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

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

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

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

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

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

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

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

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

+ 3 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -297,7 +297,9 @@
             on:close={closeViewer}
             on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
           />
-        {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
+        {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.originalPath
+            .toLowerCase()
+            .endsWith('.insp')}
           <PanoramaViewer {publicSharedKey} {asset} />
         {:else}
           <PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />

+ 1 - 1
web/src/lib/components/forms/edit-user-form.svelte

@@ -67,7 +67,7 @@
 </script>
 
 <div
-  class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
+  class="max-h-screen w-[500px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
 >
   <div
     class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"

+ 3 - 7
web/src/lib/utils/asset-utils.ts

@@ -1,6 +1,6 @@
 import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
 import { downloadManager } from '$lib/stores/download';
-import { api, AssetApiGetDownloadInfoRequest, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto } from '@api';
+import { api, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto, DownloadInfoDto } from '@api';
 import { handleError } from './handle-error';
 
 export const addAssetsToAlbum = async (
@@ -32,15 +32,11 @@ const downloadBlob = (data: Blob, filename: string) => {
   URL.revokeObjectURL(url);
 };
 
-export const downloadArchive = async (
-  fileName: string,
-  options: Omit<AssetApiGetDownloadInfoRequest, 'key'>,
-  key?: string,
-) => {
+export const downloadArchive = async (fileName: string, options: DownloadInfoDto, key?: string) => {
   let downloadInfo: DownloadResponseDto | null = null;
 
   try {
-    const { data } = await api.assetApi.getDownloadInfo({ ...options, key });
+    const { data } = await api.assetApi.getDownloadInfo({ downloadInfoDto: options, key });
     downloadInfo = data;
   } catch (error) {
     handleError(error, 'Unable to download files');

+ 13 - 49
web/src/lib/utils/file-uploader.ts

@@ -1,62 +1,25 @@
 import { uploadAssetsStore } from '$lib/stores/upload';
 import { addAssetsToAlbum } from '$lib/utils/asset-utils';
-import type { AssetFileUploadResponseDto } from '@api';
+import { api, AssetFileUploadResponseDto } from '@api';
 import axios from 'axios';
 import { notificationController, NotificationType } from './../components/shared-components/notification/notification';
 
-const extensions = [
-  '.3fr',
-  '.3gp',
-  '.ari',
-  '.arw',
-  '.avi',
-  '.avif',
-  '.cap',
-  '.cin',
-  '.cr2',
-  '.cr3',
-  '.crw',
-  '.dcr',
-  '.dng',
-  '.erf',
-  '.fff',
-  '.flv',
-  '.gif',
-  '.heic',
-  '.heif',
-  '.iiq',
-  '.jpeg',
-  '.jpg',
-  '.k25',
-  '.kdc',
-  '.mkv',
-  '.mov',
-  '.mp2t',
-  '.mp4',
-  '.mpeg',
-  '.mrw',
-  '.nef',
-  '.orf',
-  '.ori',
-  '.pef',
-  '.png',
-  '.raf',
-  '.raw',
-  '.rwl',
-  '.sr2',
-  '.srf',
-  '.srw',
-  '.tiff',
-  '.webm',
-  '.webp',
-  '.wmv',
-  '.x3f',
-];
+let _extensions: string[];
+
+const getExtensions = async () => {
+  if (!_extensions) {
+    const { data } = await api.serverInfoApi.getSupportedMediaTypes();
+    _extensions = [...data.image, ...data.video];
+  }
+  return _extensions;
+};
 
 export const openFileUploadDialog = async (
   albumId: string | undefined = undefined,
   sharedKey: string | undefined = undefined,
 ) => {
+  const extensions = await getExtensions();
+
   return new Promise<(string | undefined)[]>((resolve, reject) => {
     try {
       const fileSelector = document.createElement('input');
@@ -87,6 +50,7 @@ export const fileUploadHandler = async (
   albumId: string | undefined = undefined,
   sharedKey: string | undefined = undefined,
 ) => {
+  const extensions = await getExtensions();
   const iterable = {
     files: files.filter((file) => extensions.some((ext) => file.name.toLowerCase().endsWith(ext)))[Symbol.iterator](),
 

+ 3 - 3
web/src/routes/(user)/albums/[albumId]/+page.svelte

@@ -103,7 +103,7 @@
   });
 
   const refreshAlbum = async () => {
-    const { data } = await api.albumApi.getAlbumInfo({ id: album.id, withoutAssets: false });
+    const { data } = await api.albumApi.getAlbumInfo({ id: album.id, withoutAssets: true });
     album = data;
   };
 
@@ -264,9 +264,9 @@
     }
   };
 
-  const handleUpdateDescription = (description: string) => {
+  const handleUpdateDescription = async (description: string) => {
     try {
-      api.albumApi.updateAlbumInfo({
+      await api.albumApi.updateAlbumInfo({
         id: album.id,
         updateAlbumDto: {
           description,

+ 1 - 1
web/src/routes/(user)/partners/[userId]/+page.svelte

@@ -18,7 +18,7 @@
 
   export let data: PageData;
 
-  const assetStore = new AssetStore({ size: TimeBucketSize.Month, userId: data.partner.id });
+  const assetStore = new AssetStore({ size: TimeBucketSize.Month, userId: data.partner.id, isArchived: false });
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 

+ 2 - 2
web/src/routes/admin/+layout.svelte

@@ -69,8 +69,8 @@
     </div>
   </SideBarSection>
 
-  <section id="setting-content" class="mx-4 flex place-content-center">
-    <section class="w-full pb-28 pt-5 sm:w-5/6 md:w-[800px]">
+  <section id="setting-content" class="flex place-content-center sm:mx-4">
+    <section class="w-full pb-28 pt-5 sm:w-5/6 md:w-[850px]">
       <slot />
     </section>
   </section>

+ 16 - 20
web/src/routes/admin/user-management/+page.svelte

@@ -168,11 +168,11 @@
       class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
     >
       <tr class="flex w-full place-items-center">
-        <th class="w-1/4 text-center text-sm font-medium">Email</th>
-        <th class="w-1/4 text-center text-sm font-medium">First name</th>
-        <th class="w-1/4 text-center text-sm font-medium">Last name</th>
-        <th class="w-1/4 text-center text-sm font-medium">Can import</th>
-        <th class="w-1/4 text-center text-sm font-medium">Action</th>
+        <th class="w-4/12 text-center text-sm font-medium">Email</th>
+        <th class="w-2/12 text-center text-sm font-medium">First name</th>
+        <th class="w-2/12 text-center text-sm font-medium">Last name</th>
+        <th class="w-2/12 text-center text-sm font-medium">Can import</th>
+        <th class="w-2/12 text-center text-sm font-medium">Action</th>
       </tr>
     </thead>
     <tbody class="block max-h-[320px] w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
@@ -187,10 +187,10 @@
                 : 'bg-immich-bg dark:bg-immich-dark-gray/50'
             }`}
           >
-            <td class="w-1/4 text-ellipsis break-all px-4 text-sm">{user.email}</td>
-            <td class="w-1/4 text-ellipsis break-all px-4 text-sm">{user.firstName}</td>
-            <td class="w-1/4 text-ellipsis break-all px-4 text-sm">{user.lastName}</td>
-            <td class="w-1/4 text-ellipsis break-all px-4 text-sm">
+            <td class="w-4/12 text-ellipsis break-all px-2 text-sm">{user.email}</td>
+            <td class="w-2/12 text-ellipsis break-all px-2 text-sm">{user.firstName}</td>
+            <td class="w-2/12 text-ellipsis break-all px-2 text-sm">{user.lastName}</td>
+            <td class="w-2/12 text-ellipsis break-all px-2 text-sm">
               <div class="container mx-auto flex flex-wrap justify-center">
                 {#if user.externalPath}
                   <Check size="16" />
@@ -199,7 +199,7 @@
                 {/if}
               </div>
             </td>
-            <td class="w-1/4 text-ellipsis break-all px-4 text-sm">
+            <td class="w-2/12 text-ellipsis break-all px-4 text-sm">
               {#if !isDeleted(user)}
                 <button
                   on:click={() => editUserHandler(user)}
@@ -237,11 +237,9 @@
       class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
     >
       <tr class="flex w-full place-items-center">
-        <th class="flex w-1/2 justify-around text-center text-sm font-medium">
-          <span>Name</span>
-          <span>Email</span>
-        </th>
-        <th class="w-1/2 text-center text-sm font-medium">Action</th>
+        <th class="w-1/4 text-center text-sm font-medium">Name</th>
+        <th class="w-1/2 text-center text-sm font-medium">Email</th>
+        <th class="w-1/4 text-center text-sm font-medium">Action</th>
       </tr>
     </thead>
     <tbody class="block max-h-[320px] w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
@@ -256,11 +254,9 @@
                 : 'bg-immich-bg dark:bg-immich-dark-gray/50'
             }`}
           >
-            <td class="w-2/3 text-ellipsis px-4 text-sm">
-              <span>{user.firstName} {user.lastName}</span>
-              <span>{user.email}</span>
-            </td>
-            <td class="w-1/3 text-ellipsis px-4 text-sm">
+            <td class="w-1/4 text-ellipsis break-words px-2 text-sm">{user.firstName} {user.lastName}</td>
+            <td class="w-1/2 text-ellipsis break-all px-2 text-sm">{user.email}</td>
+            <td class="w-1/4 text-ellipsis px-2 text-sm">
               {#if !isDeleted(user)}
                 <button
                   on:click={() => editUserHandler(user)}