merge main
This commit is contained in:
commit
7bc8918067
67 changed files with 737 additions and 497 deletions
|
@ -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
|
||||
|
|
109
cli/src/api/open-api/api.ts
generated
109
cli/src/api/open-api/api.ts
generated
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.72.2
|
||||
* The version of the OpenAPI document: 1.73.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
@ -1,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"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -163,4 +163,4 @@ SPEC CHECKSUMS:
|
|||
|
||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.1
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,32 +5,32 @@
|
|||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000187">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.108738">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.403882">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="28.952846">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.068392">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.821481">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.988079">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="99.212621">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="96.47923">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.366701">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="57.517755">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
|
7
mobile/lib/shared/providers/admin_provider.dart
Normal file
7
mobile/lib/shared/providers/admin_provider.dart
Normal 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
|
||||
});
|
|
@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/providers/release_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/admin_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class VersionAnnouncementOverlay extends HookConsumerWidget {
|
||||
|
@ -12,6 +13,12 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isAdmin = ref.watch(isAdminProvider);
|
||||
|
||||
if (!isAdmin) {
|
||||
return const SizedBox.shrink(); // Don't show anything for non-admins
|
||||
}
|
||||
|
||||
void goToReleaseNote() async {
|
||||
final Uri url =
|
||||
Uri.parse('https://github.com/immich-app/immich/releases/latest');
|
||||
|
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
|
@ -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
|
||||
|
|
7
mobile/openapi/README.md
generated
7
mobile/openapi/README.md
generated
|
@ -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)
|
||||
|
|
20
mobile/openapi/doc/AssetApi.md
generated
20
mobile/openapi/doc/AssetApi.md
generated
|
@ -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
18
mobile/openapi/doc/DownloadInfoDto.md
generated
Normal 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)
|
||||
|
||||
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
@ -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';
|
||||
|
|
48
mobile/openapi/lib/api/asset_api.dart
generated
48
mobile/openapi/lib/api/asset_api.dart
generated
|
@ -230,7 +230,7 @@ class AssetApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /asset/download' operation and returns the [Response].
|
||||
/// Performs an HTTP 'POST /asset/download/archive' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AssetIdsDto] assetIdsDto (required):
|
||||
|
@ -238,7 +238,7 @@ class AssetApi {
|
|||
/// * [String] key:
|
||||
Future<Response> downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/download';
|
||||
final path = r'/asset/download/archive';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetIdsDto;
|
||||
|
@ -853,51 +853,33 @@ class AssetApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/download' operation and returns the [Response].
|
||||
/// Performs an HTTP 'POST /asset/download/info' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [List<String>] assetIds:
|
||||
///
|
||||
/// * [String] albumId:
|
||||
///
|
||||
/// * [String] userId:
|
||||
///
|
||||
/// * [num] archiveSize:
|
||||
/// * [DownloadInfoDto] downloadInfoDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<Response> getDownloadInfoWithHttpInfo({ List<String>? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async {
|
||||
Future<Response> getDownloadInfoWithHttpInfo(DownloadInfoDto downloadInfoDto, { String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/download';
|
||||
final path = r'/asset/download/info';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
Object? postBody = downloadInfoDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (assetIds != null) {
|
||||
queryParams.addAll(_queryParams('multi', 'assetIds', assetIds));
|
||||
}
|
||||
if (albumId != null) {
|
||||
queryParams.addAll(_queryParams('', 'albumId', albumId));
|
||||
}
|
||||
if (userId != null) {
|
||||
queryParams.addAll(_queryParams('', 'userId', userId));
|
||||
}
|
||||
if (archiveSize != null) {
|
||||
queryParams.addAll(_queryParams('', 'archiveSize', archiveSize));
|
||||
}
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
|
@ -908,17 +890,11 @@ class AssetApi {
|
|||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [List<String>] assetIds:
|
||||
///
|
||||
/// * [String] albumId:
|
||||
///
|
||||
/// * [String] userId:
|
||||
///
|
||||
/// * [num] archiveSize:
|
||||
/// * [DownloadInfoDto] downloadInfoDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<DownloadResponseDto?> getDownloadInfo({ List<String>? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async {
|
||||
final response = await getDownloadInfoWithHttpInfo( assetIds: assetIds, albumId: albumId, userId: userId, archiveSize: archiveSize, key: key, );
|
||||
Future<DownloadResponseDto?> getDownloadInfo(DownloadInfoDto downloadInfoDto, { String? key, }) async {
|
||||
final response = await getDownloadInfoWithHttpInfo(downloadInfoDto, key: key, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
|
@ -259,6 +259,8 @@ class ApiClient {
|
|||
return DeleteAssetStatusTypeTransformer().decode(value);
|
||||
case 'DownloadArchiveInfo':
|
||||
return DownloadArchiveInfo.fromJson(value);
|
||||
case 'DownloadInfoDto':
|
||||
return DownloadInfoDto.fromJson(value);
|
||||
case 'DownloadResponseDto':
|
||||
return DownloadResponseDto.fromJson(value);
|
||||
case 'ExifResponseDto':
|
||||
|
|
150
mobile/openapi/lib/model/download_info_dto.dart
generated
Normal file
150
mobile/openapi/lib/model/download_info_dto.dart
generated
Normal 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>{
|
||||
};
|
||||
}
|
||||
|
2
mobile/openapi/test/asset_api_test.dart
generated
2
mobile/openapi/test/asset_api_test.dart
generated
|
@ -97,7 +97,7 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
//Future<DownloadResponseDto> getDownloadInfo({ List<String> assetIds, String albumId, String userId, num archiveSize, String key }) async
|
||||
//Future<DownloadResponseDto> getDownloadInfo(DownloadInfoDto downloadInfoDto, { String key }) async
|
||||
test('test getDownloadInfo', () async {
|
||||
// TODO
|
||||
});
|
||||
|
|
42
mobile/openapi/test/download_info_dto_test.dart
generated
Normal file
42
mobile/openapi/test/download_info_dto_test.dart
generated
Normal 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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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": {
|
||||
|
|
4
server/package-lock.json
generated
4
server/package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "immich",
|
||||
"version": "1.72.2",
|
||||
"version": "1.73.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.72.2",
|
||||
"version": "1.73.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "immich",
|
||||
"version": "1.72.2",
|
||||
"version": "1.73.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
|
|
@ -24,6 +24,8 @@ export enum Permission {
|
|||
RULE_UPDATE = 'rule.update',
|
||||
RULE_DELETE = 'rule.delete',
|
||||
|
||||
ARCHIVE_READ = 'archive.read',
|
||||
|
||||
LIBRARY_READ = 'library.read',
|
||||
LIBRARY_DOWNLOAD = 'library.download',
|
||||
}
|
||||
|
@ -173,6 +175,9 @@ export class AccessCore {
|
|||
case Permission.RULE_DELETE:
|
||||
return this.repository.rule.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ARCHIVE_READ:
|
||||
return authUser.id === id;
|
||||
|
||||
case Permission.LIBRARY_READ:
|
||||
return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
|
||||
|
||||
|
|
|
@ -7,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[]>;
|
||||
|
|
|
@ -365,6 +365,7 @@ describe(AlbumService.name, () => {
|
|||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [],
|
||||
});
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false });
|
||||
});
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
|
@ -433,7 +434,7 @@ describe(AlbumService.name, () => {
|
|||
|
||||
await sut.get(authStub.admin, albumStub.oneAsset.id, {});
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id);
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
|
||||
});
|
||||
|
||||
|
@ -443,7 +444,7 @@ describe(AlbumService.name, () => {
|
|||
|
||||
await sut.get(authStub.adminSharedLink, 'album-123', {});
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
'album-123',
|
||||
|
@ -456,7 +457,7 @@ describe(AlbumService.name, () => {
|
|||
|
||||
await sut.get(authStub.user1, 'album-123', {});
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123');
|
||||
});
|
||||
|
||||
|
|
|
@ -12,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');
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import { IAssetRepository } from './asset.repository';
|
|||
import {
|
||||
AssetIdsDto,
|
||||
DownloadArchiveInfo,
|
||||
DownloadDto,
|
||||
DownloadInfoDto,
|
||||
DownloadResponseDto,
|
||||
MemoryLaneDto,
|
||||
TimeBucketAssetDto,
|
||||
|
@ -148,6 +148,9 @@ export class AssetService {
|
|||
if (dto.albumId) {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, [dto.albumId]);
|
||||
} else if (dto.userId) {
|
||||
if (dto.isArchived !== false) {
|
||||
await this.access.requirePermission(authUser, Permission.ARCHIVE_READ, [dto.userId]);
|
||||
}
|
||||
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [dto.userId]);
|
||||
} else {
|
||||
dto.userId = authUser.id;
|
||||
|
@ -176,7 +179,7 @@ export class AssetService {
|
|||
return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath));
|
||||
}
|
||||
|
||||
async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise<DownloadResponseDto> {
|
||||
async getDownloadInfo(authUser: AuthUserDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
|
||||
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
|
||||
const archives: DownloadArchiveInfo[] = [];
|
||||
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
|
||||
|
@ -234,7 +237,7 @@ export class AssetService {
|
|||
return { stream: zip.stream };
|
||||
}
|
||||
|
||||
private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadDto): Promise<AsyncGenerator<AssetEntity[]>> {
|
||||
private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadInfoDto): Promise<AsyncGenerator<AssetEntity[]>> {
|
||||
const PAGINATION_SIZE = 2500;
|
||||
|
||||
if (dto.assetIds) {
|
||||
|
|
|
@ -2,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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ export const IPersonRepository = 'IPersonRepository';
|
|||
|
||||
export interface PersonSearchOptions {
|
||||
minimumFaceCount: number;
|
||||
withHidden: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateFacesData {
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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[]> {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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]+)\/$/;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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(),
|
||||
|
|
109
web/src/api/open-api/api.ts
generated
109
web/src/api/open-api/api.ts
generated
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
2
web/src/api/open-api/base.ts
generated
2
web/src/api/open-api/base.ts
generated
|
@ -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).
|
||||
|
|
2
web/src/api/open-api/common.ts
generated
2
web/src/api/open-api/common.ts
generated
|
@ -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).
|
||||
|
|
2
web/src/api/open-api/configuration.ts
generated
2
web/src/api/open-api/configuration.ts
generated
|
@ -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).
|
||||
|
|
2
web/src/api/open-api/index.ts
generated
2
web/src/api/open-api/index.ts
generated
|
@ -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).
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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](),
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
|
|
Loading…
Reference in a new issue