diff --git a/README_zh_CN.md b/README_zh_CN.md index 10a946eee3..d018a4a285 100644 --- a/README_zh_CN.md +++ b/README_zh_CN.md @@ -13,7 +13,7 @@

Immich - 高性能的自托管照片和视频备份方案

-请注意: 此README不是由Immich团队维护, 这意味着它在某一时间点不会被更新,因为我们是依靠贡献者来更新的。感谢理解。 +请注意: 此 README 不是由 Immich 团队维护, 而是依靠贡献者来更新的,这意味着它可能并不会被及时更新。感谢理解。


@@ -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 diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index e9d53e50be..180f041267 100644 --- a/cli/src/api/open-api/api.ts +++ b/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} + * @memberof DownloadInfoDto + */ + 'assetIds'?: Array; + /** + * + * @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 => { // 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} [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, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/download`; + getDownloadInfo: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + // 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} [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, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - 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> { + 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 { - 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 { + 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} + * @type {DownloadInfoDto} * @memberof AssetApiGetDownloadInfo */ - readonly assetIds?: Array - - /** - * - * @type {string} - * @memberof AssetApiGetDownloadInfo - */ - readonly albumId?: string - - /** - * - * @type {string} - * @memberof AssetApiGetDownloadInfo - */ - readonly userId?: string - - /** - * - * @type {number} - * @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)); } /** diff --git a/cli/src/api/open-api/base.ts b/cli/src/api/open-api/base.ts index d636bfdd9a..7109cba71e 100644 --- a/cli/src/api/open-api/base.ts +++ b/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). diff --git a/cli/src/api/open-api/common.ts b/cli/src/api/open-api/common.ts index 6b75041a70..66890914ad 100644 --- a/cli/src/api/open-api/common.ts +++ b/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). diff --git a/cli/src/api/open-api/configuration.ts b/cli/src/api/open-api/configuration.ts index 275f99f72a..21fd71538b 100644 --- a/cli/src/api/open-api/configuration.ts +++ b/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). diff --git a/cli/src/api/open-api/index.ts b/cli/src/api/open-api/index.ts index 7047458436..937e9a24ad 100644 --- a/cli/src/api/open-api/index.ts +++ b/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). diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 4547dcb052..b2fa1b28e3 100644 --- a/machine-learning/pyproject.toml +++ b/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 "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 0de6cb8aa7..e9be5f6548 100644 --- a/mobile/android/fastlane/Fastfile +++ b/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') diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 45b5cef2d8..d268a40aee 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 7b7cbd0d57..4902388c6d 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -163,4 +163,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382 -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 9f463abbe7..8bb1e31927 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/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; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 041dcd2ebd..ae8c18518e 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -59,11 +59,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.70.0 + 1.73.0 CFBundleSignature ???? CFBundleVersion - 110 + 113 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 56bf4ba34e..7707eadb9d 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.72.2" + version_number: "1.73.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index f1ee5f8278..3f00787c15 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + diff --git a/mobile/lib/shared/providers/admin_provider.dart b/mobile/lib/shared/providers/admin_provider.dart new file mode 100644 index 0000000000..fc1126a92c --- /dev/null +++ b/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((ref) { + final currentUser = ref.watch(currentUserProvider); + return currentUser?.isAdmin ?? false; // Default to non-admin if no user +}); diff --git a/mobile/lib/shared/views/version_announcement_overlay.dart b/mobile/lib/shared/views/version_announcement_overlay.dart index 8ec0d0b1bc..7cb8213fc8 100644 --- a/mobile/lib/shared/views/version_announcement_overlay.dart +++ b/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'); diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index feafa6ad05..edaabb4fe2 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c62cee56e9..c26ad4521b 100644 --- a/mobile/openapi/README.md +++ b/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) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 2999f2de2a..5c1c7bb4bf 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/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('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = AssetApi(); -final assetIds = []; // List | -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.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) diff --git a/mobile/openapi/doc/DownloadInfoDto.md b/mobile/openapi/doc/DownloadInfoDto.md new file mode 100644 index 0000000000..14d5d1e712 --- /dev/null +++ b/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** | | [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) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index a9db957e5f..8f7b40eaa2 100644 --- a/mobile/openapi/lib/api.dart +++ b/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'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 6f4c391bf8..ba9f1d5a1f 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/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 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] assetIds: - /// - /// * [String] albumId: - /// - /// * [String] userId: - /// - /// * [num] archiveSize: + /// * [DownloadInfoDto] downloadInfoDto (required): /// /// * [String] key: - Future getDownloadInfoWithHttpInfo({ List? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async { + Future 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 = []; final headerParams = {}; final formParams = {}; - 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 = []; + const contentTypes = ['application/json']; return apiClient.invokeAPI( path, - 'GET', + 'POST', queryParams, postBody, headerParams, @@ -908,17 +890,11 @@ class AssetApi { /// Parameters: /// - /// * [List] assetIds: - /// - /// * [String] albumId: - /// - /// * [String] userId: - /// - /// * [num] archiveSize: + /// * [DownloadInfoDto] downloadInfoDto (required): /// /// * [String] key: - Future getDownloadInfo({ List? 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 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)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 699fd00fba..e5e908cf07 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/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': diff --git a/mobile/openapi/lib/model/download_info_dto.dart b/mobile/openapi/lib/model/download_info_dto.dart new file mode 100644 index 0000000000..fa3a06eeac --- /dev/null +++ b/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 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 toJson() { + final json = {}; + 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(); + + return DownloadInfoDto( + albumId: mapValueOfType(json, r'albumId'), + archiveSize: mapValueOfType(json, r'archiveSize'), + assetIds: json[r'assetIds'] is List + ? (json[r'assetIds'] as List).cast() + : const [], + userId: mapValueOfType(json, r'userId'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + }; +} + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 588902a14c..ebb472c4a0 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -97,7 +97,7 @@ void main() { // TODO }); - //Future getDownloadInfo({ List assetIds, String albumId, String userId, num archiveSize, String key }) async + //Future getDownloadInfo(DownloadInfoDto downloadInfoDto, { String key }) async test('test getDownloadInfo', () async { // TODO }); diff --git a/mobile/openapi/test/download_info_dto_test.dart b/mobile/openapi/test/download_info_dto_test.dart new file mode 100644 index 0000000000..5efd4e11eb --- /dev/null +++ b/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 assetIds (default value: const []) + test('to test the property `assetIds`', () async { + // TODO + }); + + // String userId + test('to test the property `userId`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5c75bfb7e9..dc90a5373c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.72.2+95 +version: 1.73.0+96 isar_version: &isar_version 3.1.0+1 environment: diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 4f939f5c34..2969647420 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1026,84 +1026,7 @@ ] } }, - "/asset/download": { - "get": { - "operationId": "getDownloadInfo", - "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, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DownloadResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - }, + "/asset/download/archive": { "post": { "operationId": "downloadArchive", "parameters": [ @@ -1155,6 +1078,57 @@ ] } }, + "/asset/download/info": { + "post": { + "operationId": "getDownloadInfo", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadInfoDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/download/{id}": { "post": { "operationId": "downloadFile", @@ -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": { diff --git a/server/package-lock.json b/server/package-lock.json index fa3fbf12a6..e6e2de6aba 100644 --- a/server/package-lock.json +++ b/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", diff --git a/server/package.json b/server/package.json index 2baf087bf6..cdfb87d59a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.72.2", + "version": "1.73.0", "description": "", "author": "", "private": true, diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 9b9ece8332..f9374f272b 100644 --- a/server/src/domain/access/access.core.ts +++ b/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)); diff --git a/server/src/domain/album/album.repository.ts b/server/src/domain/album/album.repository.ts index 811b85ec9a..d501964ef5 100644 --- a/server/src/domain/album/album.repository.ts +++ b/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; + getById(id: string, options: AlbumInfoOptions): Promise; getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; hasAsset(id: string, assetId: string): Promise; + /** Remove an asset from _all_ albums */ + removeAsset(id: string): Promise; getAssetCountForIds(ids: string[]): Promise; getInvalidThumbnail(): Promise; getOwned(ownerId: string): Promise; diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 8a8f6289a1..e40f9a9f40 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/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'); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 925b87ac9a..71c11d9357 100644 --- a/server/src/domain/album/album.service.ts +++ b/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 { @@ -111,7 +111,7 @@ export class AlbumService { async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise { 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 { 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 { - 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 { - 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 { 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 { @@ -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'); } diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index fdd6df6efd..2d4051b0fa 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/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 { + async getDownloadInfo(authUser: AuthUserDto, dto: DownloadInfoDto): Promise { 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> { + private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadInfoDto): Promise> { const PAGINATION_SIZE = 2500; if (dto.assetIds) { diff --git a/server/src/domain/asset/dto/download.dto.ts b/server/src/domain/asset/dto/download.dto.ts index c2cf85685a..604a8ea5fb 100644 --- a/server/src/domain/asset/dto/download.dto.ts +++ b/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; } diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index a1cb0a7dfe..b06720613c 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -48,6 +48,7 @@ const image: Record = { '.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 = { '.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'], diff --git a/server/src/domain/job/job.constants.ts b/server/src/domain/job/job.constants.ts index 6743b10a04..0e93d4efe2 100644 --- a/server/src/domain/job/job.constants.ts +++ b/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 = { // 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, diff --git a/server/src/domain/job/job.repository.ts b/server/src/domain/job/job.repository.ts index 1e9b193926..e657712310 100644 --- a/server/src/domain/job/job.repository.ts +++ b/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 } diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index 0b2c61adae..98ef53a7a9 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/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], }, { diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index df79491367..1c82908911 100644 --- a/server/src/domain/job/job.service.ts +++ b/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; } diff --git a/server/src/domain/person/person.repository.ts b/server/src/domain/person/person.repository.ts index 3c8432be1e..973d940cd6 100644 --- a/server/src/domain/person/person.repository.ts +++ b/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 { diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 7aaa875f27..e5bca7c830 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/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 }); }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 0f66447f13..187ef3358d 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -26,8 +26,10 @@ export class PersonService { ) {} async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise { - 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) diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index ceeb85e295..4a7e7f22bb 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/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), }; } } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index ba3de02cc5..b55cbb870d 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/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 { + @Post('download/info') + getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise { 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 { diff --git a/server/src/infra/migrations/1692057328660-fixGPSNullIsland.ts b/server/src/infra/migrations/1692057328660-fixGPSNullIsland.ts new file mode 100644 index 0000000000..74dc40a474 --- /dev/null +++ b/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 { + await queryRunner.query(`UPDATE "exif" SET latitude = NULL, longitude = NULL WHERE latitude = 0 AND longitude = 0;`); + } + + public async down(): Promise { + // Setting lat,lon to 0 not necessary + } + +} diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index 0446dd3884..6c3d0a1864 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/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, @InjectRepository(AlbumEntity) private repository: Repository, + @InjectDataSource() private dataSource: DataSource, ) {} - getById(id: string): Promise { - 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 { + const relations: FindOptionsRelations = { + owner: true, + sharedUsers: true, + assets: false, + sharedLinks: true, + rules: true, + }; + + const order: FindOptionsOrder = {}; + + if (options.withAssets) { + relations.assets = { + exifInfo: true, + }; + + order.assets = { + fileCreatedAt: 'DESC', + }; + } + + return this.repository.findOne({ where: { id }, relations, order }); } getByIds(ids: string[]): Promise { @@ -84,7 +87,7 @@ export class AlbumRepository implements IAlbumRepository { */ async getInvalidThumbnail(): Promise { // 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 { + // 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 { return this.repository.exist({ where: { diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index 8595484365..1203bba381 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -51,16 +51,22 @@ export class PersonRepository implements IPersonRepository { } getAll(userId: string, options?: PersonSearchOptions): Promise { - 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 { diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 972d35a16a..38c020ce0d 100644 --- a/server/src/microservices/app.service.ts +++ b/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), diff --git a/server/src/microservices/processors/metadata-extraction.processor.ts b/server/src/microservices/processors/metadata-extraction.processor.ts index a37ee6a23a..7c58f7102e 100644 --- a/server/src/microservices/processors/metadata-extraction.processor.ts +++ b/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]+)\/$/; diff --git a/server/src/microservices/utils/exif/coordinates.spec.ts b/server/src/microservices/utils/exif/coordinates.spec.ts index 975d1e9969..223a7671ec 100644 --- a/server/src/microservices/utils/exif/coordinates.spec.ts +++ b/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(); + }); +}); diff --git a/server/src/microservices/utils/exif/coordinates.ts b/server/src/microservices/utils/exif/coordinates.ts index 3a07b0c173..03aeb17f09 100644 --- a/server/src/microservices/utils/exif/coordinates.ts +++ b/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)) { diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 8656fa64a1..3d42630dfb 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -12,6 +12,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { getNotShared: jest.fn(), deleteAll: jest.fn(), getAll: jest.fn(), + removeAsset: jest.fn(), hasAsset: jest.fn(), create: jest.fn(), update: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e9d53e50be..180f041267 100644 --- a/web/src/api/open-api/api.ts +++ b/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} + * @memberof DownloadInfoDto + */ + 'assetIds'?: Array; + /** + * + * @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 => { // 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} [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, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/download`; + getDownloadInfo: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + // 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} [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, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - 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> { + 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 { - 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 { + 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} + * @type {DownloadInfoDto} * @memberof AssetApiGetDownloadInfo */ - readonly assetIds?: Array - - /** - * - * @type {string} - * @memberof AssetApiGetDownloadInfo - */ - readonly albumId?: string - - /** - * - * @type {string} - * @memberof AssetApiGetDownloadInfo - */ - readonly userId?: string - - /** - * - * @type {number} - * @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)); } /** diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index d636bfdd9a..7109cba71e 100644 --- a/web/src/api/open-api/base.ts +++ b/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). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 6b75041a70..66890914ad 100644 --- a/web/src/api/open-api/common.ts +++ b/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). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index 275f99f72a..21fd71538b 100644 --- a/web/src/api/open-api/configuration.ts +++ b/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). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index 7047458436..937e9a24ad 100644 --- a/web/src/api/open-api/index.ts +++ b/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). diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index e65b45e808..5df79b1590 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/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')} {:else} diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index 760c5f48ef..5881488508 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -67,7 +67,7 @@
{ URL.revokeObjectURL(url); }; -export const downloadArchive = async ( - fileName: string, - options: Omit, - 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'); diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index fad841c221..533ab372ef 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/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](), diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 41341ba7cd..5be8d7675c 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/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, diff --git a/web/src/routes/(user)/partners/[userId]/+page.svelte b/web/src/routes/(user)/partners/[userId]/+page.svelte index c7fd51f2a8..adc718e918 100644 --- a/web/src/routes/(user)/partners/[userId]/+page.svelte +++ b/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; diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index f484c4773c..b9586c9088 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -69,8 +69,8 @@
-
-
+
+
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 5dbe71aca3..586d5a3042 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/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" > - Email - First name - Last name - Can import - Action + Email + First name + Last name + Can import + Action @@ -187,10 +187,10 @@ : 'bg-immich-bg dark:bg-immich-dark-gray/50' }`} > - {user.email} - {user.firstName} - {user.lastName} - + {user.email} + {user.firstName} + {user.lastName} +
{#if user.externalPath} @@ -199,7 +199,7 @@ {/if}
- + {#if !isDeleted(user)}