merge main

This commit is contained in:
Alex Tran 2023-08-16 13:30:20 -05:00
commit 7bc8918067
67 changed files with 737 additions and 497 deletions

View file

@ -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

View file

@ -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>}
* @type {DownloadInfoDto}
* @memberof AssetApiGetDownloadInfo
*/
readonly assetIds?: Array<string>
/**
*
* @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));
}
/**

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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"

View file

@ -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')

View file

@ -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>

View file

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

View file

@ -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;

View file

@ -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>

View file

@ -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,

View file

@ -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>

View file

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

View file

@ -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');

View file

@ -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

View file

@ -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)

View file

@ -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
mobile/openapi/doc/DownloadInfoDto.md generated Normal file
View file

@ -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)

View file

@ -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';

View file

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

View file

@ -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':

View file

@ -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>{
};
}

View file

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

View file

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

View file

@ -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:

View file

@ -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": {

View file

@ -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",

View file

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

View file

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

View file

@ -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[]>;

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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'],

View file

@ -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,

View file

@ -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 }

View file

@ -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],
},
{

View file

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

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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> {

View file

@ -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
}
}

View file

@ -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: {

View file

@ -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[]> {

View file

@ -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),

View file

@ -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]+)\/$/;

View file

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

View file

@ -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)) {

View file

@ -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(),

View file

@ -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>}
* @type {DownloadInfoDto}
* @memberof AssetApiGetDownloadInfo
*/
readonly assetIds?: Array<string>
/**
*
* @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));
}
/**

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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} />

View file

@ -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"

View file

@ -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');

View file

@ -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](),

View file

@ -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,

View file

@ -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;

View file

@ -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>

View file

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