Compare commits

..

14 Commits

Author SHA1 Message Date
Immich Release Bot
67453d18ff Version v1.51.2 2023-03-22 21:12:45 +00:00
Michel Heusschen
792a87e407 fix(nginx): x-forwarded-* headers (#2019)
* fix(nginx): x-forwarded-* headers

* change category / add link to nginx config
2023-03-22 15:46:30 -05:00
Skyler Mäntysaari
6da50626e1 fix(server): Return the original path for gif playback (#2022)
* fix(server): Return the original path for gifs.

Usually browser is able to play them directly.

* fix(server): Better place for the condition.

* fix(server): gif viewing works properly.
2023-03-22 14:56:00 -05:00
Jason Rasmussen
6239b3b309 fix: import assets on new install (#2044) 2023-03-22 00:36:32 -05:00
Jason Rasmussen
b9bc621e2a refactor: server-info (#2038) 2023-03-21 21:49:19 -05:00
Jason Rasmussen
e10bbfa933 chore: always restart typesense (#2042) 2023-03-21 21:41:19 -05:00
Jason Rasmussen
2dd301e292 feat: show current/saved template in preset dropdown (#2040) 2023-03-21 15:19:47 -05:00
Immich Release Bot
75edc6de0f Version v1.51.1 2023-03-21 03:10:10 +00:00
Alex Tran
780c5183e3 Revert "Version v1.51.1"
This reverts commit 6e1d09fc32.
2023-03-20 22:08:47 -05:00
Jason Rasmussen
25a10784eb fix(server): search and explore part 2 (#2031)
* explore logging

* chore: regenerate open api

* fix: explore page
2023-03-20 22:07:22 -05:00
Immich Release Bot
6e1d09fc32 Version v1.51.1 2023-03-20 20:24:30 +00:00
Jason Rasmussen
73a2063d96 fix(server): search and explore issues (#2029)
* fix: send assets to typesense in batches

* fix: run classs transformer on search endpoint

* chore: log typesense filters
2023-03-20 15:16:32 -05:00
Michel Heusschen
deb1e7f41f chore: bump openapi version to v1.51.0 (#2026) 2023-03-20 11:39:00 -05:00
Alex
f45f719b9d chore: add release note for Android 2023-03-20 11:38:46 -05:00
68 changed files with 1202 additions and 507 deletions

View File

@@ -1,17 +0,0 @@
# Deployment checklist for iOS/Android/Server
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
All of the version should be the same.

View File

@@ -4,7 +4,7 @@ services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:release
entrypoint: [ "/bin/sh", "./start-server.sh" ]
entrypoint: ["/bin/sh", "./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -20,7 +20,7 @@ services:
immich-microservices:
container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:release
entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -48,7 +48,7 @@ services:
immich-web:
container_name: immich_web
image: ghcr.io/immich-app/immich-web:release
entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
restart: always
@@ -63,6 +63,7 @@ services:
driver: none
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis

View File

@@ -0,0 +1,22 @@
# Reverse Proxy
When deploying Immich it is important to understand that a reverse proxy is required in front of the server and web container. The reverse proxy acts as an intermediary between the user and container, forwarding requests to the correct container based on the URL path.
## Default Reverse Proxy
Immich provides a default nginx reverse proxy preconfigured to perform the correct routing and set the necessary headers for the server and web container to use. These headers are crucial to redirect to the correct URL and determine the client's IP address.
## Using a Different Reverse Proxy
While the reverse proxy provided by Immich works well for basic deployments, some users may want to use a different reverse proxy. Fortunately, Immich is flexible enough to accommodate different reverse proxies. Users can either:
1. Add another reverse proxy on top of Immich's reverse proxy
2. Completely replace the default reverse proxy
## Adding a Custom Reverse Proxy
Users can deploy a custom reverse proxy that forwards requests to Immich's reverse proxy. This way, the new reverse proxy can handle TLS termination, load balancing, or other advanced features, while still delegating routing decisions to Immich's reverse proxy. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
## Replacing the Default Reverse Proxy
Replacing Immich's default reverse proxy is an advanced deployment and support may be limited. When replacing Immich's default proxy it is important to ensure that requests to `/api/*` are routed to the server container and all other requests to the web container. Additionally, the previously mentioned headers should be configured accordingly. You may find our [nginx configuration file](https://github.com/immich-app/immich/blob/main/nginx/templates/default.conf.template) a helpful reference.

View File

@@ -36,7 +36,7 @@ platform :android do
build_type: 'Release',
properties: {
"android.injected.version.code" => 74,
"android.injected.version.name" => "1.51.0",
"android.injected.version.name" => "1.51.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1,12 @@
* Enter server first for login
* Sync assets, albums & users to local database on device
* Fixes hero animation on main timeline by
* Transparent bottom Android navigation bar
* Fix do not crash on malformed asset duration
* Gallery viewer fullscreen edge case
* Fix Sorted shared album and added share user doesn't reflect change in album view
* Allow app to be used offline
* No longer wait for background backup in settings
* Share album name and adaptive shared album display
* Persist album sort order

View File

@@ -5,17 +5,19 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000306">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000232">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="104.812034">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.685298">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="47.568445">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="33.624781">
<failure message="/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:42:in `block (2 levels) in parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:354:in `run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:43:in `start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `load&apos;&#10;/opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Google Api Error: Invalid request - The release created has notes in language en-US with length 508, which is too long (max: 500)." />
</testcase>

View File

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

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.50.1
- API version: 1.51.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -113,7 +113,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)
# **search**
> SearchResponseDto search()
> SearchResponseDto search(q, query, clip, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion)
@@ -134,9 +134,23 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
final api_instance = SearchApi();
final q = q_example; // String |
final query = query_example; // String |
final clip = true; // bool |
final type = type_example; // String |
final isFavorite = true; // bool |
final exifInfoPeriodCity = exifInfoPeriodCity_example; // String |
final exifInfoPeriodState = exifInfoPeriodState_example; // String |
final exifInfoPeriodCountry = exifInfoPeriodCountry_example; // String |
final exifInfoPeriodMake = exifInfoPeriodMake_example; // String |
final exifInfoPeriodModel = exifInfoPeriodModel_example; // String |
final smartInfoPeriodObjects = []; // List<String> |
final smartInfoPeriodTags = []; // List<String> |
final recent = true; // bool |
final motion = true; // bool |
try {
final result = api_instance.search();
final result = api_instance.search(q, query, clip, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion);
print(result);
} catch (e) {
print('Exception when calling SearchApi->search: $e\n');
@@ -144,7 +158,23 @@ try {
```
### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**q** | **String**| | [optional]
**query** | **String**| | [optional]
**clip** | **bool**| | [optional]
**type** | **String**| | [optional]
**isFavorite** | **bool**| | [optional]
**exifInfoPeriodCity** | **String**| | [optional]
**exifInfoPeriodState** | **String**| | [optional]
**exifInfoPeriodCountry** | **String**| | [optional]
**exifInfoPeriodMake** | **String**| | [optional]
**exifInfoPeriodModel** | **String**| | [optional]
**smartInfoPeriodObjects** | [**List<String>**](String.md)| | [optional] [default to const []]
**smartInfoPeriodTags** | [**List<String>**](String.md)| | [optional] [default to const []]
**recent** | **bool**| | [optional]
**motion** | **bool**| | [optional]
### Return type

View File

@@ -110,7 +110,37 @@ class SearchApi {
///
///
/// Note: This method returns the HTTP [Response].
Future<Response> searchWithHttpInfo() async {
///
/// Parameters:
///
/// * [String] q:
///
/// * [String] query:
///
/// * [bool] clip:
///
/// * [String] type:
///
/// * [bool] isFavorite:
///
/// * [String] exifInfoPeriodCity:
///
/// * [String] exifInfoPeriodState:
///
/// * [String] exifInfoPeriodCountry:
///
/// * [String] exifInfoPeriodMake:
///
/// * [String] exifInfoPeriodModel:
///
/// * [List<String>] smartInfoPeriodObjects:
///
/// * [List<String>] smartInfoPeriodTags:
///
/// * [bool] recent:
///
/// * [bool] motion:
Future<Response> searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async {
// ignore: prefer_const_declarations
final path = r'/search';
@@ -121,6 +151,49 @@ class SearchApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (q != null) {
queryParams.addAll(_queryParams('', 'q', q));
}
if (query != null) {
queryParams.addAll(_queryParams('', 'query', query));
}
if (clip != null) {
queryParams.addAll(_queryParams('', 'clip', clip));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (exifInfoPeriodCity != null) {
queryParams.addAll(_queryParams('', 'exifInfo.city', exifInfoPeriodCity));
}
if (exifInfoPeriodState != null) {
queryParams.addAll(_queryParams('', 'exifInfo.state', exifInfoPeriodState));
}
if (exifInfoPeriodCountry != null) {
queryParams.addAll(_queryParams('', 'exifInfo.country', exifInfoPeriodCountry));
}
if (exifInfoPeriodMake != null) {
queryParams.addAll(_queryParams('', 'exifInfo.make', exifInfoPeriodMake));
}
if (exifInfoPeriodModel != null) {
queryParams.addAll(_queryParams('', 'exifInfo.model', exifInfoPeriodModel));
}
if (smartInfoPeriodObjects != null) {
queryParams.addAll(_queryParams('multi', 'smartInfo.objects', smartInfoPeriodObjects));
}
if (smartInfoPeriodTags != null) {
queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags));
}
if (recent != null) {
queryParams.addAll(_queryParams('', 'recent', recent));
}
if (motion != null) {
queryParams.addAll(_queryParams('', 'motion', motion));
}
const contentTypes = <String>[];
@@ -136,8 +209,38 @@ class SearchApi {
}
///
Future<SearchResponseDto?> search() async {
final response = await searchWithHttpInfo();
///
/// Parameters:
///
/// * [String] q:
///
/// * [String] query:
///
/// * [bool] clip:
///
/// * [String] type:
///
/// * [bool] isFavorite:
///
/// * [String] exifInfoPeriodCity:
///
/// * [String] exifInfoPeriodState:
///
/// * [String] exifInfoPeriodCountry:
///
/// * [String] exifInfoPeriodMake:
///
/// * [String] exifInfoPeriodModel:
///
/// * [List<String>] smartInfoPeriodObjects:
///
/// * [List<String>] smartInfoPeriodTags:
///
/// * [bool] recent:
///
/// * [bool] motion:
Future<SearchResponseDto?> search({ String? q, String? query, bool? clip, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async {
final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, isFavorite: isFavorite, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, recent: recent, motion: motion, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -33,7 +33,7 @@ void main() {
//
//
//Future<SearchResponseDto> search() async
//Future<SearchResponseDto> search({ String q, String query, bool clip, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List<String> smartInfoPeriodObjects, List<String> smartInfoPeriodTags, bool recent, bool motion }) async
test('test search', () async {
// TODO
});

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.51.0+74
version: 1.51.2+74
isar_version: &isar_version 3.0.5
environment:

View File

@@ -3,6 +3,14 @@ map $http_upgrade $connection_upgrade {
'' close;
}
map $http_x_forwarded_proto $forwarded_protocol {
default $scheme;
# Only allow the values 'http' and 'https' for the X-Forwarded-Proto header.
http http;
https https;
}
upstream server {
server ${IMMICH_SERVER_HOST};
keepalive 2;
@@ -43,13 +51,12 @@ server {
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
@@ -64,13 +71,12 @@ server {
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_pass ${IMMICH_WEB_SCHEME}web;
}

View File

@@ -279,17 +279,20 @@ export class AssetService {
/**
* Serve file viewer on the web
*/
if (query.isWeb) {
if (query.isWeb && asset.mimeType != 'image/gif') {
res.set({
'Content-Type': 'image/jpeg',
});
if (!asset.resizePath) {
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
}
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
@@ -299,7 +302,7 @@ export class AssetService {
/**
* Serve thumbnail image for both web and mobile app
*/
if (!query.isThumb && allowOriginalFile) {
if ((!query.isThumb && allowOriginalFile) || (query.isWeb && asset.mimeType === 'image/gif')) {
res.set({
'Content-Type': asset.mimeType,
});

View File

@@ -1,36 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { serverVersion } from '../../constants/server_version.constant';
import { ApiTags } from '@nestjs/swagger';
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
import { Authenticated } from '../../decorators/authenticated.decorator';
@ApiTags('Server Info')
@Controller('server-info')
export class ServerInfoController {
constructor(private readonly serverInfoService: ServerInfoService) {}
@Get()
async getServerInfo(): Promise<ServerInfoResponseDto> {
return await this.serverInfoService.getServerInfo();
}
@Get('/ping')
async pingServer(): Promise<ServerPingResponse> {
return new ServerPingResponse('pong');
}
@Get('/version')
async getServerVersion(): Promise<ServerVersionReponseDto> {
return serverVersion;
}
@Authenticated({ admin: true })
@Get('/stats')
async getStats(): Promise<ServerStatsResponseDto> {
return await this.serverInfoService.getStats();
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { ServerInfoController } from './server-info.controller';
import { UserEntity } from '@app/infra';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [ServerInfoController],
providers: [ServerInfoService],
})
export class ServerInfoModule {}

View File

@@ -1,81 +0,0 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { Injectable } from '@nestjs/common';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import diskusage from 'diskusage';
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
import { UsageByUserDto } from './response-dto/usage-by-user-response.dto';
import { UserEntity } from '@app/infra';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { asHumanReadable } from '../../utils/human-readable.util';
@Injectable()
export class ServerInfoService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
async getServerInfo(): Promise<ServerInfoResponseDto> {
const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION);
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
const serverInfo = new ServerInfoResponseDto();
serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
serverInfo.diskSize = asHumanReadable(diskInfo.total);
serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo;
}
async getStats(): Promise<ServerStatsResponseDto> {
type UserStatsQueryResponse = {
userId: string;
userFirstName: string;
userLastName: string;
photos: string;
videos: string;
usage: string;
};
const userStatsQueryResponse: UserStatsQueryResponse[] = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.firstName', 'userFirstName')
.addSelect('users.lastName', 'userLastName')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id')
.orderBy('users.createdAt', 'ASC')
.getRawMany();
const usageByUser = userStatsQueryResponse.map((userStats) => {
const usage = new UsageByUserDto();
usage.userId = userStats.userId;
usage.userFirstName = userStats.userFirstName;
usage.userLastName = userStats.userLastName;
usage.photos = Number(userStats.photos);
usage.videos = Number(userStats.videos);
usage.usage = Number(userStats.usage);
return usage;
});
const serverStats = new ServerStatsResponseDto();
usageByUser.forEach((user) => {
serverStats.photos += user.photos;
serverStats.videos += user.videos;
serverStats.usage += user.usage;
});
serverStats.usageByUser = usageByUser;
return serverStats;
}
}

View File

@@ -2,7 +2,6 @@ import { immichAppConfig } from '@app/common/config';
import { Module, OnModuleInit } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { AlbumModule } from './api-v1/album/album.module';
import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
@@ -17,6 +16,7 @@ import {
JobController,
OAuthController,
SearchController,
ServerInfoController,
ShareController,
SystemConfigController,
UserController,
@@ -34,8 +34,6 @@ import { AuthGuard } from './middlewares/auth.guard';
AssetModule,
ServerInfoModule,
AlbumModule,
ScheduleModule.forRoot(),
@@ -52,6 +50,7 @@ import { AuthGuard } from './middlewares/auth.guard';
JobController,
OAuthController,
SearchController,
ServerInfoController,
ShareController,
SystemConfigController,
UserController,

View File

@@ -1,4 +1,4 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { createHash, randomUUID } from 'crypto';

View File

@@ -1,4 +1,4 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { Request } from 'express';

View File

@@ -4,6 +4,7 @@ export * from './device-info.controller';
export * from './job.controller';
export * from './oauth.controller';
export * from './search.controller';
export * from './server-info.controller';
export * from './share.controller';
export * from './system-config.controller';
export * from './user.controller';

View File

@@ -20,7 +20,7 @@ export class SearchController {
@Get()
async search(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: SearchDto | any,
@Query(new ValidationPipe({ transform: true })) dto: SearchDto,
): Promise<SearchResponseDto> {
return this.searchService.search(authUser, dto);
}

View File

@@ -0,0 +1,37 @@
import {
ServerInfoResponseDto,
ServerInfoService,
ServerPingResponse,
ServerStatsResponseDto,
ServerVersionReponseDto,
} from '@app/domain';
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator';
@ApiTags('Server Info')
@Controller('server-info')
export class ServerInfoController {
constructor(private readonly service: ServerInfoService) {}
@Get()
getServerInfo(): Promise<ServerInfoResponseDto> {
return this.service.getInfo();
}
@Get('/ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('/version')
getServerVersion(): ServerVersionReponseDto {
return this.service.getVersion();
}
@Authenticated({ admin: true })
@Get('/stats')
getStats(): Promise<ServerStatsResponseDto> {
return this.service.getStats();
}
}

View File

@@ -6,12 +6,11 @@ import cookieParser from 'cookie-parser';
import { writeFileSync } from 'fs';
import path from 'path';
import { AppModule } from './app.module';
import { SERVER_VERSION } from './constants/server_version.constant';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
import { json } from 'body-parser';
import { patchOpenAPI } from './utils/patch-open-api.util';
import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
import { SERVER_VERSION, IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
const logger = new Logger('ImmichServer');
@@ -20,7 +19,7 @@ async function bootstrap() {
logger: getLogLevels(),
});
app.set('trust proxy');
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));

View File

@@ -2,7 +2,7 @@ import { AssetEntity } from '@app/infra';
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import archiver from 'archiver';
import { extname } from 'path';
import { asHumanReadable, HumanReadableSize } from '../../utils/human-readable.util';
import { asHumanReadable, HumanReadableSize } from '@app/domain';
export interface DownloadArchive {
stream: StreamableFile;

View File

@@ -1,6 +1,6 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { SERVER_VERSION } from 'apps/immich/src/constants/server_version.constant';
import { SERVER_VERSION } from '@app/domain';
import { getLogLevels } from '@app/common';
import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
import { MicroservicesModule } from './microservices.module';

View File

@@ -1,6 +1,5 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { AssetEntity, AssetType } from '@app/infra';
import {
APP_UPLOAD_LOCATION,
IAssetJob,
IAssetRepository,
IBaseJob,
@@ -10,6 +9,7 @@ import {
SystemConfigService,
WithoutProperty,
} from '@app/domain';
import { AssetEntity, AssetType } from '@app/infra';
import { Process, Processor } from '@nestjs/bull';
import { Inject, Logger } from '@nestjs/common';
import { Job } from 'bull';

View File

@@ -620,7 +620,132 @@
"get": {
"operationId": "search",
"description": "",
"parameters": [],
"parameters": [
{
"name": "q",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "query",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "clip",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"enum": [
"IMAGE",
"VIDEO",
"AUDIO",
"OTHER"
],
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "exifInfo.city",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "smartInfo.objects",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "smartInfo.tags",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "recent",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "motion",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
@@ -709,6 +834,102 @@
]
}
},
"/server-info": {
"get": {
"operationId": "getServerInfo",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerInfoResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/ping": {
"get": {
"operationId": "pingServer",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerPingResponse"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/version": {
"get": {
"operationId": "getServerVersion",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerVersionReponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/stats": {
"get": {
"operationId": "getStats",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatsResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/share": {
"get": {
"operationId": "getAllSharedLinks",
@@ -3145,108 +3366,12 @@
}
]
}
},
"/server-info": {
"get": {
"operationId": "getServerInfo",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerInfoResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/ping": {
"get": {
"operationId": "pingServer",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerPingResponse"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/version": {
"get": {
"operationId": "getServerVersion",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerVersionReponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/stats": {
"get": {
"operationId": "getStats",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatsResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
}
},
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.51.0",
"version": "1.51.2",
"contact": {}
},
"tags": [],
@@ -4205,6 +4330,148 @@
"items"
]
},
"ServerInfoResponseDto": {
"type": "object",
"properties": {
"diskSizeRaw": {
"type": "integer",
"format": "int64"
},
"diskUseRaw": {
"type": "integer",
"format": "int64"
},
"diskAvailableRaw": {
"type": "integer",
"format": "int64"
},
"diskUsagePercentage": {
"type": "number",
"format": "float"
},
"diskSize": {
"type": "string"
},
"diskUse": {
"type": "string"
},
"diskAvailable": {
"type": "string"
}
},
"required": [
"diskSizeRaw",
"diskUseRaw",
"diskAvailableRaw",
"diskUsagePercentage",
"diskSize",
"diskUse",
"diskAvailable"
]
},
"ServerPingResponse": {
"type": "object",
"properties": {
"res": {
"type": "string",
"readOnly": true,
"example": "pong"
}
},
"required": [
"res"
]
},
"ServerVersionReponseDto": {
"type": "object",
"properties": {
"major": {
"type": "integer"
},
"minor": {
"type": "integer"
},
"patch": {
"type": "integer"
}
},
"required": [
"major",
"minor",
"patch"
]
},
"UsageByUserDto": {
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"userFirstName": {
"type": "string"
},
"userLastName": {
"type": "string"
},
"photos": {
"type": "integer"
},
"videos": {
"type": "integer"
},
"usage": {
"type": "integer",
"format": "int64"
}
},
"required": [
"userId",
"userFirstName",
"userLastName",
"photos",
"videos",
"usage"
]
},
"ServerStatsResponseDto": {
"type": "object",
"properties": {
"photos": {
"type": "integer",
"default": 0
},
"videos": {
"type": "integer",
"default": 0
},
"usage": {
"type": "integer",
"default": 0,
"format": "int64"
},
"usageByUser": {
"default": [],
"title": "Array of usage for each user",
"example": [
{
"photos": 1,
"videos": 1,
"diskUsageRaw": 1
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/UsageByUserDto"
}
}
},
"required": [
"photos",
"videos",
"usage",
"usageByUser"
]
},
"SharedLinkType": {
"type": "string",
"enum": [
@@ -5146,148 +5413,6 @@
"required": [
"albumId"
]
},
"ServerInfoResponseDto": {
"type": "object",
"properties": {
"diskSizeRaw": {
"type": "integer",
"format": "int64"
},
"diskUseRaw": {
"type": "integer",
"format": "int64"
},
"diskAvailableRaw": {
"type": "integer",
"format": "int64"
},
"diskUsagePercentage": {
"type": "number",
"format": "float"
},
"diskSize": {
"type": "string"
},
"diskUse": {
"type": "string"
},
"diskAvailable": {
"type": "string"
}
},
"required": [
"diskSizeRaw",
"diskUseRaw",
"diskAvailableRaw",
"diskUsagePercentage",
"diskSize",
"diskUse",
"diskAvailable"
]
},
"ServerPingResponse": {
"type": "object",
"properties": {
"res": {
"type": "string",
"readOnly": true,
"example": "pong"
}
},
"required": [
"res"
]
},
"ServerVersionReponseDto": {
"type": "object",
"properties": {
"major": {
"type": "integer"
},
"minor": {
"type": "integer"
},
"patch": {
"type": "integer"
}
},
"required": [
"major",
"minor",
"patch"
]
},
"UsageByUserDto": {
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"userFirstName": {
"type": "string"
},
"userLastName": {
"type": "string"
},
"photos": {
"type": "integer"
},
"videos": {
"type": "integer"
},
"usage": {
"type": "integer",
"format": "int64"
}
},
"required": [
"userId",
"userFirstName",
"userLastName",
"photos",
"videos",
"usage"
]
},
"ServerStatsResponseDto": {
"type": "object",
"properties": {
"photos": {
"type": "integer",
"default": 0
},
"videos": {
"type": "integer",
"default": 0
},
"usage": {
"type": "integer",
"default": 0,
"format": "int64"
},
"usageByUser": {
"default": [],
"title": "Array of usage for each user",
"example": [
{
"photos": 1,
"videos": 1,
"diskUsageRaw": 1
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/UsageByUserDto"
}
}
},
"required": [
"photos",
"videos",
"usage",
"usageByUser"
]
}
}
}

View File

@@ -1,7 +1,5 @@
import { BadRequestException } from '@nestjs/common';
export * from './upload_location.constant';
export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';

View File

@@ -1 +0,0 @@
export const APP_UPLOAD_LOCATION = './upload';

View File

@@ -1,4 +1,4 @@
import pkg from 'package.json';
import pkg from '../../../package.json';
const [major, minor, patch] = pkg.version.split('.');
@@ -15,3 +15,5 @@ export const serverVersion: IServerVersion = {
};
export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`;
export const APP_UPLOAD_LOCATION = './upload';

View File

@@ -7,6 +7,7 @@ import { JobService } from './job';
import { MediaService } from './media';
import { OAuthService } from './oauth';
import { SearchService } from './search';
import { ServerInfoService } from './server-info';
import { ShareService } from './share';
import { SmartInfoService } from './smart-info';
import { StorageService } from './storage';
@@ -22,6 +23,7 @@ const providers: Provider[] = [
JobService,
MediaService,
OAuthService,
ServerInfoService,
SmartInfoService,
StorageService,
StorageTemplateService,

View File

@@ -1,3 +1,9 @@
import { basename, extname } from 'node:path';
export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path));
}
const KiB = Math.pow(1024, 1);
const MiB = Math.pow(1024, 2);
const GiB = Math.pow(1024, 3);

View File

@@ -5,11 +5,14 @@ export * from './auth';
export * from './communication';
export * from './crypto';
export * from './device-info';
export * from './domain.constant';
export * from './domain.module';
export * from './domain.util';
export * from './job';
export * from './media';
export * from './oauth';
export * from './search';
export * from './server-info';
export * from './share';
export * from './smart-info';
export * from './storage';
@@ -18,4 +21,3 @@ export * from './system-config';
export * from './tag';
export * from './user';
export * from './user-token';
export * from './util';

View File

@@ -1,10 +1,10 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { AssetType } from '@app/infra/db/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { join } from 'path';
import sanitize from 'sanitize-filename';
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';
import { IMediaRepository } from './media.repository';

View File

@@ -173,12 +173,23 @@ describe(SearchService.name, () => {
});
describe('handleIndexAssets', () => {
it('should call done, even when there are no assets', async () => {
assetMock.getAll.mockResolvedValue([]);
await sut.handleIndexAssets();
expect(searchMock.importAssets).toHaveBeenCalledWith([], true);
});
it('should index all the assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
await sut.handleIndexAssets();
expect(searchMock.importAssets).toHaveBeenCalledWith([assetEntityStub.image], true);
expect(searchMock.importAssets.mock.calls).toEqual([
[[assetEntityStub.image], false],
[[], true],
]);
});
it('should log an error', async () => {

View File

@@ -145,7 +145,14 @@ export class SearchService {
// TODO: do this in batches based on searchIndexVersion
const assets = this.patchAssets(await this.assetRepository.getAll({ isVisible: true }));
this.logger.log(`Indexing ${assets.length} assets`);
await this.searchRepository.importAssets(assets, true);
const chunkSize = 1000;
for (let i = 0; i < assets.length; i += chunkSize) {
await this.searchRepository.importAssets(assets.slice(i, i + chunkSize), false);
}
await this.searchRepository.importAssets([], true);
this.logger.debug('Finished re-indexing all assets');
} catch (error: any) {
this.logger.error(`Unable to index all assets`, error?.stack);

View File

@@ -0,0 +1,2 @@
export * from './response-dto';
export * from './server-info.service';

View File

@@ -0,0 +1,5 @@
export * from './server-info-response.dto';
export * from './server-ping-response.dto';
export * from './server-stats-response.dto';
export * from './server-version-response.dto';
export * from './usage-by-user-response.dto';

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IServerVersion } from 'apps/immich/src/constants/server_version.constant';
import { IServerVersion } from '@app/domain';
export class ServerVersionReponseDto implements IServerVersion {
@ApiProperty({ type: 'integer' })

View File

@@ -0,0 +1,209 @@
import { newStorageRepositoryMock, newUserRepositoryMock } from '../../test';
import { serverVersion } from '../domain.constant';
import { IStorageRepository } from '../storage';
import { IUserRepository } from '../user';
import { ServerInfoService } from './server-info.service';
describe(ServerInfoService.name, () => {
let sut: ServerInfoService;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => {
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new ServerInfoService(userMock, storageMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getInfo', () => {
it('should return the disk space as B', async () => {
storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 });
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '300 B',
diskAvailableRaw: 300,
diskSize: '500 B',
diskSizeRaw: 500,
diskUsagePercentage: 60,
diskUse: '300 B',
diskUseRaw: 300,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as KiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 });
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '293.0 KiB',
diskAvailableRaw: 300000,
diskSize: '488.3 KiB',
diskSizeRaw: 500000,
diskUsagePercentage: 60,
diskUse: '293.0 KiB',
diskUseRaw: 300000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as MiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 });
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '286.1 MiB',
diskAvailableRaw: 300000000,
diskSize: '476.8 MiB',
diskSizeRaw: 500000000,
diskUsagePercentage: 60,
diskUse: '286.1 MiB',
diskUseRaw: 300000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as GiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000,
available: 300_000_000_000,
total: 500_000_000_000,
});
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '279.4 GiB',
diskAvailableRaw: 300000000000,
diskSize: '465.7 GiB',
diskSizeRaw: 500000000000,
diskUsagePercentage: 60,
diskUse: '279.4 GiB',
diskUseRaw: 300000000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as TiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000_000,
available: 300_000_000_000_000,
total: 500_000_000_000_000,
});
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '272.8 TiB',
diskAvailableRaw: 300000000000000,
diskSize: '454.7 TiB',
diskSizeRaw: 500000000000000,
diskUsagePercentage: 60,
diskUse: '272.8 TiB',
diskUseRaw: 300000000000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as PiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000_000_000,
available: 300_000_000_000_000_000,
total: 500_000_000_000_000_000,
});
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '266.5 PiB',
diskAvailableRaw: 300000000000000000,
diskSize: '444.1 PiB',
diskSizeRaw: 500000000000000000,
diskUsagePercentage: 60,
diskUse: '266.5 PiB',
diskUseRaw: 300000000000000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
});
describe('ping', () => {
it('should respond with pong', () => {
expect(sut.ping()).toEqual({ res: 'pong' });
});
});
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual(serverVersion);
});
});
describe('getStats', () => {
it('should total up usage by user', async () => {
userMock.getUserStats.mockResolvedValue([
{
userId: 'user1',
userFirstName: '1',
userLastName: 'User',
photos: 10,
videos: 11,
usage: 12345,
},
{
userId: 'user2',
userFirstName: '2',
userLastName: 'User',
photos: 10,
videos: 20,
usage: 123456,
},
{
userId: 'user3',
userFirstName: '3',
userLastName: 'User',
photos: 100,
videos: 0,
usage: 987654,
},
]);
await expect(sut.getStats()).resolves.toEqual({
photos: 120,
videos: 31,
usage: 1123455,
usageByUser: [
{
photos: 10,
usage: 12345,
userFirstName: '1',
userId: 'user1',
userLastName: 'User',
videos: 11,
},
{
photos: 10,
usage: 123456,
userFirstName: '2',
userId: 'user2',
userLastName: 'User',
videos: 20,
},
{
photos: 100,
usage: 987654,
userFirstName: '3',
userId: 'user3',
userLastName: 'User',
videos: 0,
},
],
});
expect(userMock.getUserStats).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,60 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_UPLOAD_LOCATION, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
import { IStorageRepository } from '../storage';
import { IUserRepository, UserStatsQueryResponse } from '../user';
import { ServerInfoResponseDto, ServerPingResponse, ServerStatsResponseDto, UsageByUserDto } from './response-dto';
@Injectable()
export class ServerInfoService {
constructor(
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {}
async getInfo(): Promise<ServerInfoResponseDto> {
const diskInfo = await this.storageRepository.checkDiskUsage(APP_UPLOAD_LOCATION);
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
const serverInfo = new ServerInfoResponseDto();
serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
serverInfo.diskSize = asHumanReadable(diskInfo.total);
serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo;
}
ping(): ServerPingResponse {
return new ServerPingResponse('pong');
}
getVersion() {
return serverVersion;
}
async getStats(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();
for (const user of userStats) {
const usage = new UsageByUserDto();
usage.userId = user.userId;
usage.userFirstName = user.userFirstName;
usage.userLastName = user.userLastName;
usage.photos = user.photos;
usage.videos = user.videos;
usage.usage = user.usage;
serverStats.photos += usage.photos;
serverStats.videos += usage.videos;
serverStats.usage += usage.usage;
serverStats.usageByUser.push(usage);
}
return serverStats;
}
}

View File

@@ -1,4 +1,3 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import {
IStorageRepository,
ISystemConfigRepository,
@@ -15,6 +14,7 @@ import handlebar from 'handlebars';
import * as luxon from 'luxon';
import path from 'node:path';
import sanitize from 'sanitize-filename';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { SystemConfigCore } from '../system-config/system-config.core';
export class StorageTemplateCore {

View File

@@ -1,7 +1,7 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { AssetEntity, SystemConfig } from '@app/infra/db/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { IAssetRepository } from '../asset/asset.repository';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { IStorageRepository } from '../storage/storage.repository';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { StorageTemplateCore } from './storage-template.core';

View File

@@ -6,6 +6,12 @@ export interface ImmichReadStream {
length: number;
}
export interface DiskUsage {
available: number;
free: number;
total: number;
}
export const IStorageRepository = 'IStorageRepository';
export interface IStorageRepository {
@@ -16,4 +22,5 @@ export interface IStorageRepository {
moveFile(source: string, target: string): Promise<void>;
checkFileExists(filepath: string): Promise<boolean>;
mkdirSync(filepath: string): void;
checkDiskUsage(folder: string): Promise<DiskUsage>;
}

View File

@@ -4,6 +4,15 @@ export interface UserListFilter {
excludeId?: string;
}
export interface UserStatsQueryResponse {
userId: string;
userFirstName: string;
userLastName: string;
photos: number;
videos: number;
usage: number;
}
export const IUserRepository = 'IUserRepository';
export interface IUserRepository {
@@ -13,6 +22,7 @@ export interface IUserRepository {
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
getDeletedUsers(): Promise<UserEntity[]>;
getList(filter?: UserListFilter): Promise<UserEntity[]>;
getUserStats(): Promise<UserStatsQueryResponse[]>;
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;

View File

@@ -3,12 +3,12 @@ import { BadRequestException, Inject, Injectable, Logger, NotFoundException } fr
import { randomBytes } from 'crypto';
import { ReadStream } from 'fs';
import { join } from 'path';
import { APP_UPLOAD_LOCATION } from '@app/common';
import { IAlbumRepository } from '../album/album.repository';
import { IKeyRepository } from '../api-key/api-key.repository';
import { IAssetRepository } from '../asset/asset.repository';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { IJobRepository, IUserDeletionJob, JobName } from '../job';
import { IStorageRepository } from '../storage/storage.repository';
import { IUserTokenRepository } from '../user-token/user-token.repository';

View File

@@ -1,5 +0,0 @@
import { basename, extname } from 'node:path';
export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path));
}

View File

@@ -9,5 +9,6 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
moveFile: jest.fn(),
checkFileExists: jest.fn(),
mkdirSync: jest.fn(),
checkDiskUsage: jest.fn(),
};
};

View File

@@ -6,6 +6,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getByOAuthId: jest.fn(),
getUserStats: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),

View File

@@ -1,5 +1,5 @@
import { UserEntity } from '../entities';
import { IUserRepository, UserListFilter } from '@app/domain';
import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
@@ -76,4 +76,28 @@ export class UserRepository implements IUserRepository {
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.firstName', 'userFirstName')
.addSelect('users.lastName', 'userLastName')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id')
.orderBy('users.createdAt', 'ASC')
.getRawMany();
for (const stat of stats) {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
}
return stats;
}
}

View File

@@ -8,7 +8,7 @@ import {
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import _, { Dictionary } from 'lodash';
import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs';
import { catchError, filter, firstValueFrom, from, map, mergeMap, of, toArray } from 'rxjs';
import { Client } from 'typesense';
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
@@ -148,7 +148,7 @@ export class TypesenseRepository implements ISearchRepository {
const common = {
q: '*',
filter_by: `ownerId:${userId}`,
filter_by: this.buildFilterBy('ownerId', userId, true),
per_page: 100,
};
@@ -157,8 +157,8 @@ export class TypesenseRepository implements ISearchRepository {
const { facet_counts: facets } = await asset$.search({
...common,
query_by: 'exifInfo.imageName',
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
max_facet_values: 50,
facet_by: 'exifInfo.city,smartInfo.objects',
max_facet_values: 12,
});
return firstValueFrom(
@@ -166,23 +166,31 @@ export class TypesenseRepository implements ISearchRepository {
mergeMap(
(facet) =>
from(facet.counts).pipe(
mergeMap(
(count) =>
from(
asset$.search({
...common,
query_by: 'exifInfo.imageName',
filter_by: `${facet.field_name}:${count.value}`,
}),
).pipe(
map((result) => ({
value: count.value,
data: result.hits?.[0]?.document as AssetEntity,
})),
filter((item) => !!item.data),
),
5,
),
mergeMap((count) => {
const config = {
...common,
query_by: 'exifInfo.imageName',
filter_by: [
this.buildFilterBy('ownerId', userId, true),
this.buildFilterBy(facet.field_name, count.value, true),
].join(' && '),
per_page: 1,
};
this.logger.verbose(`Explore subquery: "filter_by:${config.filter_by}" (count:${count.count})`);
return from(asset$.search(config)).pipe(
catchError((error: any) => {
this.logger.warn(`Explore subquery error: ${error}`, error?.stack);
return of({ hits: [] });
}),
map((result) => ({
value: count.value,
data: result.hits?.[0]?.document as AssetEntity,
})),
filter((item) => !!item.data),
);
}, 5),
toArray(),
map((items) => ({
fieldName: facet.field_name as string,
@@ -208,7 +216,7 @@ export class TypesenseRepository implements ISearchRepository {
await this.client
.collections(schemaMap[collection].name)
.documents()
.delete({ filter_by: `id: [${ids.join(',')}]` });
.delete({ filter_by: this.buildFilterBy('id', ids, true) });
}
async searchAlbums(query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>> {
@@ -350,38 +358,61 @@ export class TypesenseRepository implements ISearchRepository {
private getAlbumFilters(filters: SearchFilter) {
const { userId } = filters;
const _filters = [`ownerId:${userId}`];
const _filters = [this.buildFilterBy('ownerId', userId, true)];
if (filters.id) {
_filters.push(`id:=${filters.id}`);
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of albumSchema.fields || []) {
let value = filters[item.name as keyof SearchFilter];
if (Array.isArray(value)) {
value = `[${value.join(',')}]`;
}
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(`${item.name}:${value}`);
_filters.push(this.buildFilterBy(item.name, value));
}
}
return _filters.join(' && ');
const result = _filters.join(' && ');
this.logger.debug(`Album filters are: ${result}`);
return result;
}
private getAssetFilters(filters: SearchFilter) {
const _filters = [`ownerId:${filters.userId}`];
const { userId } = filters;
const _filters = [this.buildFilterBy('ownerId', userId, true)];
if (filters.id) {
_filters.push(`id:=${filters.id}`);
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of assetSchema.fields || []) {
let value = filters[item.name as keyof SearchFilter];
if (Array.isArray(value)) {
value = `[${value.join(',')}]`;
}
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(`${item.name}:${value}`);
_filters.push(this.buildFilterBy(item.name, value));
}
}
return _filters.join(' && ');
const result = _filters.join(' && ');
this.logger.debug(`Asset filters are: ${result}`);
return result;
}
private buildFilterBy(key: string, values: boolean | string | string[], exact?: boolean) {
const token = exact ? ':=' : ':';
const _values = (Array.isArray(values) ? values : [values]).map((value) => {
if (typeof value === 'boolean' || value === 'true' || value === 'false') {
return value;
}
return '`' + value + '`';
});
const value = _values.length > 1 ? `[${_values.join(',')}]` : _values[0];
return `${key}${token}${value}`;
}
}

View File

@@ -1,8 +1,9 @@
import { ImmichReadStream, IStorageRepository } from '@app/domain';
import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs from 'fs/promises';
import mv from 'mv';
import { promisify } from 'node:util';
import diskUsage from 'diskusage';
import path from 'path';
const moveFile = promisify<string, string, mv.Options>(mv);
@@ -66,4 +67,8 @@ export class FilesystemProvider implements IStorageRepository {
mkdirSync(filepath, { recursive: true });
}
}
checkDiskUsage(folder: string): Promise<DiskUsage> {
return diskUsage.check(folder);
}
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.51.0",
"version": "1.51.2",
"description": "",
"author": "",
"private": true,
@@ -129,7 +129,7 @@
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.ts$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s",
@@ -137,10 +137,6 @@
],
"coverageDirectory": "./coverage",
"coverageThreshold": {
"global": {
"lines": 17,
"statements": 17
},
"./libs/domain/": {
"branches": 80,
"functions": 85,

View File

@@ -18,8 +18,6 @@
"paths": {
"@app/common": ["libs/common/src"],
"@app/common/*": ["libs/common/src/*"],
"@app/storage": ["libs/storage/src"],
"@app/storage/*": ["libs/storage/src/*"],
"@app/infra": ["libs/infra/src"],
"@app/infra/*": ["libs/infra/src/*"],
"@app/domain": ["libs/domain/src"],

View File

@@ -4,6 +4,8 @@
export PUBLIC_IMMICH_SERVER_URL=$IMMICH_SERVER_URL
export PUBLIC_IMMICH_API_URL_EXTERNAL=$IMMICH_API_URL_EXTERNAL
export PROTOCOL_HEADER=X-Forwarded-Proto
if [ "$(id -u)" -eq 0 ] && [ -n "$PUID" ] && [ -n "$PGID" ]; then
exec setpriv --reuid "$PUID" --regid "$PGID" --clear-groups node /usr/src/app/build/index.js
else

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.50.1
* The version of the OpenAPI document: 1.51.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -6761,10 +6761,24 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
},
/**
*
* @param {string} [q]
* @param {string} [query]
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/search`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -6783,6 +6797,62 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
// authentication cookie required
if (q !== undefined) {
localVarQueryParameter['q'] = q;
}
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
if (clip !== undefined) {
localVarQueryParameter['clip'] = clip;
}
if (type !== undefined) {
localVarQueryParameter['type'] = type;
}
if (isFavorite !== undefined) {
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (exifInfoCity !== undefined) {
localVarQueryParameter['exifInfo.city'] = exifInfoCity;
}
if (exifInfoState !== undefined) {
localVarQueryParameter['exifInfo.state'] = exifInfoState;
}
if (exifInfoCountry !== undefined) {
localVarQueryParameter['exifInfo.country'] = exifInfoCountry;
}
if (exifInfoMake !== undefined) {
localVarQueryParameter['exifInfo.make'] = exifInfoMake;
}
if (exifInfoModel !== undefined) {
localVarQueryParameter['exifInfo.model'] = exifInfoModel;
}
if (smartInfoObjects) {
localVarQueryParameter['smartInfo.objects'] = smartInfoObjects;
}
if (smartInfoTags) {
localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
}
if (recent !== undefined) {
localVarQueryParameter['recent'] = recent;
}
if (motion !== undefined) {
localVarQueryParameter['motion'] = motion;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -6824,11 +6894,25 @@ export const SearchApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [q]
* @param {string} [query]
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async search(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(options);
async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
@@ -6859,11 +6943,25 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
},
/**
*
* @param {string} [q]
* @param {string} [query]
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search(options?: any): AxiosPromise<SearchResponseDto> {
return localVarFp.search(options).then((request) => request(axios, basePath));
search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> {
return localVarFp.search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath));
},
};
};
@@ -6897,12 +6995,26 @@ export class SearchApi extends BaseAPI {
/**
*
* @param {string} [q]
* @param {string} [query]
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public search(options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(options).then((request) => request(this.axios, this.basePath));
public search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@
api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data)
]);
selectedPreset = templateOptions.presetOptions[0];
selectedPreset = savedConfig.template;
}
const getSupportDateTimeFormat = async () => {

View File

@@ -9,7 +9,23 @@ export const load = (async ({ locals, parent, url }) => {
const term = url.searchParams.get('q') || url.searchParams.get('query') || undefined;
const { data: results } = await locals.api.searchApi.search({ params: url.searchParams });
const { data: results } = await locals.api.searchApi.search(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ params: url.searchParams }
);
return {
user,