mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01:00
parent
15c1cd6449
commit
8a445cac07
24 changed files with 905 additions and 18 deletions
8
.github/workflows/docker.yml
vendored
8
.github/workflows/docker.yml
vendored
|
@ -124,7 +124,11 @@ jobs:
|
||||||
push: ${{ !github.event.pull_request.head.repo.fork }}
|
push: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
|
||||||
cache-to: ${{ steps.cache-target.outputs.cache-to }}
|
cache-to: ${{ steps.cache-target.outputs.cache-to }}
|
||||||
build-args: |
|
|
||||||
DEVICE=${{ matrix.device }}
|
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
DEVICE=${{ matrix.device }}
|
||||||
|
BUILD_ID=${{ github.run_id }}
|
||||||
|
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
|
||||||
|
BUILD_SOURCE_REF=${{ github.ref_name }}
|
||||||
|
BUILD_SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
|
|
@ -26,6 +26,16 @@ services:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
IMMICH_REPOSITORY: immich-app/immich
|
||||||
|
IMMICH_REPOSITORY_URL: https://github.com/immich-app/immich
|
||||||
|
IMMICH_SOURCE_REF: local
|
||||||
|
IMMICH_SOURCE_COMMIT: af2efbdbbddc27cd06142f22253ccbbbbeec1f55
|
||||||
|
IMMICH_SOURCE_URL: https://github.com/immich-app/immich/commit/af2efbdbbddc27cd06142f22253ccbbbbeec1f55
|
||||||
|
IMMICH_BUILD: '9654404849'
|
||||||
|
IMMICH_BUILD_URL: https://github.com/immich-app/immich/actions/runs/9654404849
|
||||||
|
IMMICH_BUILD_IMAGE: development
|
||||||
|
IMMICH_BUILD_IMAGE_URL: https://github.com/immich-app/immich/pkgs/container/immich-server
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 1048576
|
soft: 1048576
|
||||||
|
@ -107,7 +117,22 @@ services:
|
||||||
interval: 5m
|
interval: 5m
|
||||||
start_interval: 30s
|
start_interval: 30s
|
||||||
start_period: 5m
|
start_period: 5m
|
||||||
command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"]
|
command:
|
||||||
|
[
|
||||||
|
'postgres',
|
||||||
|
'-c',
|
||||||
|
'shared_preload_libraries=vectors.so',
|
||||||
|
'-c',
|
||||||
|
'search_path="$$user", public, vectors',
|
||||||
|
'-c',
|
||||||
|
'logging_collector=on',
|
||||||
|
'-c',
|
||||||
|
'max_wal_size=2GB',
|
||||||
|
'-c',
|
||||||
|
'shared_buffers=512MB',
|
||||||
|
'-c',
|
||||||
|
'wal_compression=on',
|
||||||
|
]
|
||||||
|
|
||||||
# set IMMICH_METRICS=true in .env to enable metrics
|
# set IMMICH_METRICS=true in .env to enable metrics
|
||||||
# immich-prometheus:
|
# immich-prometheus:
|
||||||
|
|
|
@ -10,6 +10,11 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
|
args:
|
||||||
|
- BUILD_ID=1234567890
|
||||||
|
- BUILD_IMAGE=e2e
|
||||||
|
- BUILD_SOURCE_REF=e2e
|
||||||
|
- BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee
|
||||||
environment:
|
environment:
|
||||||
- DB_HOSTNAME=database
|
- DB_HOSTNAME=database
|
||||||
- DB_USERNAME=postgres
|
- DB_USERNAME=postgres
|
||||||
|
|
|
@ -15,6 +15,39 @@ describe('/server-info', () => {
|
||||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /server-info/about', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).get('/server-info/about');
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return about information', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/server-info/about')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
version: expect.any(String),
|
||||||
|
versionUrl: expect.any(String),
|
||||||
|
repository: 'immich-app/immich',
|
||||||
|
repositoryUrl: 'https://github.com/immich-app/immich',
|
||||||
|
build: '1234567890',
|
||||||
|
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
|
||||||
|
buildImage: 'e2e',
|
||||||
|
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
|
||||||
|
sourceRef: 'e2e',
|
||||||
|
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
|
||||||
|
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
|
||||||
|
nodejs: expect.any(String),
|
||||||
|
ffmpeg: expect.any(String),
|
||||||
|
imagemagick: expect.any(String),
|
||||||
|
libvips: expect.any(String),
|
||||||
|
exiftool: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /server-info/storage', () => {
|
describe('GET /server-info/storage', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).get('/server-info/storage');
|
const { status, body } = await request(app).get('/server-info/storage');
|
||||||
|
|
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
|
@ -171,6 +171,7 @@ Class | Method | HTTP request | Description
|
||||||
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
|
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
|
||||||
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
|
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
|
||||||
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
|
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
|
||||||
|
*ServerInfoApi* | [**getAboutInfo**](doc//ServerInfoApi.md#getaboutinfo) | **GET** /server-info/about |
|
||||||
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
|
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
|
||||||
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
|
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
|
||||||
*ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics |
|
*ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics |
|
||||||
|
@ -360,6 +361,7 @@ Class | Method | HTTP request | Description
|
||||||
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
|
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
|
||||||
- [SearchResponseDto](doc//SearchResponseDto.md)
|
- [SearchResponseDto](doc//SearchResponseDto.md)
|
||||||
- [SearchSuggestionType](doc//SearchSuggestionType.md)
|
- [SearchSuggestionType](doc//SearchSuggestionType.md)
|
||||||
|
- [ServerAboutResponseDto](doc//ServerAboutResponseDto.md)
|
||||||
- [ServerConfigDto](doc//ServerConfigDto.md)
|
- [ServerConfigDto](doc//ServerConfigDto.md)
|
||||||
- [ServerFeaturesDto](doc//ServerFeaturesDto.md)
|
- [ServerFeaturesDto](doc//ServerFeaturesDto.md)
|
||||||
- [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md)
|
- [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md)
|
||||||
|
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
@ -187,6 +187,7 @@ part 'model/search_facet_count_response_dto.dart';
|
||||||
part 'model/search_facet_response_dto.dart';
|
part 'model/search_facet_response_dto.dart';
|
||||||
part 'model/search_response_dto.dart';
|
part 'model/search_response_dto.dart';
|
||||||
part 'model/search_suggestion_type.dart';
|
part 'model/search_suggestion_type.dart';
|
||||||
|
part 'model/server_about_response_dto.dart';
|
||||||
part 'model/server_config_dto.dart';
|
part 'model/server_config_dto.dart';
|
||||||
part 'model/server_features_dto.dart';
|
part 'model/server_features_dto.dart';
|
||||||
part 'model/server_media_types_response_dto.dart';
|
part 'model/server_media_types_response_dto.dart';
|
||||||
|
|
41
mobile/openapi/lib/api/server_info_api.dart
generated
41
mobile/openapi/lib/api/server_info_api.dart
generated
|
@ -16,6 +16,47 @@ class ServerInfoApi {
|
||||||
|
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /server-info/about' operation and returns the [Response].
|
||||||
|
Future<Response> getAboutInfoWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/server-info/about';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ServerAboutResponseDto?> getAboutInfo() async {
|
||||||
|
final response = await getAboutInfoWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerAboutResponseDto',) as ServerAboutResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /server-info/config' operation and returns the [Response].
|
/// Performs an HTTP 'GET /server-info/config' operation and returns the [Response].
|
||||||
Future<Response> getServerConfigWithHttpInfo() async {
|
Future<Response> getServerConfigWithHttpInfo() async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
|
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
|
@ -436,6 +436,8 @@ class ApiClient {
|
||||||
return SearchResponseDto.fromJson(value);
|
return SearchResponseDto.fromJson(value);
|
||||||
case 'SearchSuggestionType':
|
case 'SearchSuggestionType':
|
||||||
return SearchSuggestionTypeTypeTransformer().decode(value);
|
return SearchSuggestionTypeTypeTransformer().decode(value);
|
||||||
|
case 'ServerAboutResponseDto':
|
||||||
|
return ServerAboutResponseDto.fromJson(value);
|
||||||
case 'ServerConfigDto':
|
case 'ServerConfigDto':
|
||||||
return ServerConfigDto.fromJson(value);
|
return ServerConfigDto.fromJson(value);
|
||||||
case 'ServerFeaturesDto':
|
case 'ServerFeaturesDto':
|
||||||
|
|
344
mobile/openapi/lib/model/server_about_response_dto.dart
generated
Normal file
344
mobile/openapi/lib/model/server_about_response_dto.dart
generated
Normal file
|
@ -0,0 +1,344 @@
|
||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// 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 ServerAboutResponseDto {
|
||||||
|
/// Returns a new [ServerAboutResponseDto] instance.
|
||||||
|
ServerAboutResponseDto({
|
||||||
|
this.build,
|
||||||
|
this.buildImage,
|
||||||
|
this.buildImageUrl,
|
||||||
|
this.buildUrl,
|
||||||
|
this.exiftool,
|
||||||
|
this.ffmpeg,
|
||||||
|
this.imagemagick,
|
||||||
|
this.libvips,
|
||||||
|
this.nodejs,
|
||||||
|
this.repository,
|
||||||
|
this.repositoryUrl,
|
||||||
|
this.sourceCommit,
|
||||||
|
this.sourceRef,
|
||||||
|
this.sourceUrl,
|
||||||
|
required this.version,
|
||||||
|
required this.versionUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? build;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? buildImage;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? buildImageUrl;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? buildUrl;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? exiftool;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? ffmpeg;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? imagemagick;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? libvips;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? nodejs;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? repository;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? repositoryUrl;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? sourceCommit;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? sourceRef;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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? sourceUrl;
|
||||||
|
|
||||||
|
String version;
|
||||||
|
|
||||||
|
String versionUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is ServerAboutResponseDto &&
|
||||||
|
other.build == build &&
|
||||||
|
other.buildImage == buildImage &&
|
||||||
|
other.buildImageUrl == buildImageUrl &&
|
||||||
|
other.buildUrl == buildUrl &&
|
||||||
|
other.exiftool == exiftool &&
|
||||||
|
other.ffmpeg == ffmpeg &&
|
||||||
|
other.imagemagick == imagemagick &&
|
||||||
|
other.libvips == libvips &&
|
||||||
|
other.nodejs == nodejs &&
|
||||||
|
other.repository == repository &&
|
||||||
|
other.repositoryUrl == repositoryUrl &&
|
||||||
|
other.sourceCommit == sourceCommit &&
|
||||||
|
other.sourceRef == sourceRef &&
|
||||||
|
other.sourceUrl == sourceUrl &&
|
||||||
|
other.version == version &&
|
||||||
|
other.versionUrl == versionUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(build == null ? 0 : build!.hashCode) +
|
||||||
|
(buildImage == null ? 0 : buildImage!.hashCode) +
|
||||||
|
(buildImageUrl == null ? 0 : buildImageUrl!.hashCode) +
|
||||||
|
(buildUrl == null ? 0 : buildUrl!.hashCode) +
|
||||||
|
(exiftool == null ? 0 : exiftool!.hashCode) +
|
||||||
|
(ffmpeg == null ? 0 : ffmpeg!.hashCode) +
|
||||||
|
(imagemagick == null ? 0 : imagemagick!.hashCode) +
|
||||||
|
(libvips == null ? 0 : libvips!.hashCode) +
|
||||||
|
(nodejs == null ? 0 : nodejs!.hashCode) +
|
||||||
|
(repository == null ? 0 : repository!.hashCode) +
|
||||||
|
(repositoryUrl == null ? 0 : repositoryUrl!.hashCode) +
|
||||||
|
(sourceCommit == null ? 0 : sourceCommit!.hashCode) +
|
||||||
|
(sourceRef == null ? 0 : sourceRef!.hashCode) +
|
||||||
|
(sourceUrl == null ? 0 : sourceUrl!.hashCode) +
|
||||||
|
(version.hashCode) +
|
||||||
|
(versionUrl.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, version=$version, versionUrl=$versionUrl]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
if (this.build != null) {
|
||||||
|
json[r'build'] = this.build;
|
||||||
|
} else {
|
||||||
|
// json[r'build'] = null;
|
||||||
|
}
|
||||||
|
if (this.buildImage != null) {
|
||||||
|
json[r'buildImage'] = this.buildImage;
|
||||||
|
} else {
|
||||||
|
// json[r'buildImage'] = null;
|
||||||
|
}
|
||||||
|
if (this.buildImageUrl != null) {
|
||||||
|
json[r'buildImageUrl'] = this.buildImageUrl;
|
||||||
|
} else {
|
||||||
|
// json[r'buildImageUrl'] = null;
|
||||||
|
}
|
||||||
|
if (this.buildUrl != null) {
|
||||||
|
json[r'buildUrl'] = this.buildUrl;
|
||||||
|
} else {
|
||||||
|
// json[r'buildUrl'] = null;
|
||||||
|
}
|
||||||
|
if (this.exiftool != null) {
|
||||||
|
json[r'exiftool'] = this.exiftool;
|
||||||
|
} else {
|
||||||
|
// json[r'exiftool'] = null;
|
||||||
|
}
|
||||||
|
if (this.ffmpeg != null) {
|
||||||
|
json[r'ffmpeg'] = this.ffmpeg;
|
||||||
|
} else {
|
||||||
|
// json[r'ffmpeg'] = null;
|
||||||
|
}
|
||||||
|
if (this.imagemagick != null) {
|
||||||
|
json[r'imagemagick'] = this.imagemagick;
|
||||||
|
} else {
|
||||||
|
// json[r'imagemagick'] = null;
|
||||||
|
}
|
||||||
|
if (this.libvips != null) {
|
||||||
|
json[r'libvips'] = this.libvips;
|
||||||
|
} else {
|
||||||
|
// json[r'libvips'] = null;
|
||||||
|
}
|
||||||
|
if (this.nodejs != null) {
|
||||||
|
json[r'nodejs'] = this.nodejs;
|
||||||
|
} else {
|
||||||
|
// json[r'nodejs'] = null;
|
||||||
|
}
|
||||||
|
if (this.repository != null) {
|
||||||
|
json[r'repository'] = this.repository;
|
||||||
|
} else {
|
||||||
|
// json[r'repository'] = null;
|
||||||
|
}
|
||||||
|
if (this.repositoryUrl != null) {
|
||||||
|
json[r'repositoryUrl'] = this.repositoryUrl;
|
||||||
|
} else {
|
||||||
|
// json[r'repositoryUrl'] = null;
|
||||||
|
}
|
||||||
|
if (this.sourceCommit != null) {
|
||||||
|
json[r'sourceCommit'] = this.sourceCommit;
|
||||||
|
} else {
|
||||||
|
// json[r'sourceCommit'] = null;
|
||||||
|
}
|
||||||
|
if (this.sourceRef != null) {
|
||||||
|
json[r'sourceRef'] = this.sourceRef;
|
||||||
|
} else {
|
||||||
|
// json[r'sourceRef'] = null;
|
||||||
|
}
|
||||||
|
if (this.sourceUrl != null) {
|
||||||
|
json[r'sourceUrl'] = this.sourceUrl;
|
||||||
|
} else {
|
||||||
|
// json[r'sourceUrl'] = null;
|
||||||
|
}
|
||||||
|
json[r'version'] = this.version;
|
||||||
|
json[r'versionUrl'] = this.versionUrl;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [ServerAboutResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static ServerAboutResponseDto? fromJson(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return ServerAboutResponseDto(
|
||||||
|
build: mapValueOfType<String>(json, r'build'),
|
||||||
|
buildImage: mapValueOfType<String>(json, r'buildImage'),
|
||||||
|
buildImageUrl: mapValueOfType<String>(json, r'buildImageUrl'),
|
||||||
|
buildUrl: mapValueOfType<String>(json, r'buildUrl'),
|
||||||
|
exiftool: mapValueOfType<String>(json, r'exiftool'),
|
||||||
|
ffmpeg: mapValueOfType<String>(json, r'ffmpeg'),
|
||||||
|
imagemagick: mapValueOfType<String>(json, r'imagemagick'),
|
||||||
|
libvips: mapValueOfType<String>(json, r'libvips'),
|
||||||
|
nodejs: mapValueOfType<String>(json, r'nodejs'),
|
||||||
|
repository: mapValueOfType<String>(json, r'repository'),
|
||||||
|
repositoryUrl: mapValueOfType<String>(json, r'repositoryUrl'),
|
||||||
|
sourceCommit: mapValueOfType<String>(json, r'sourceCommit'),
|
||||||
|
sourceRef: mapValueOfType<String>(json, r'sourceRef'),
|
||||||
|
sourceUrl: mapValueOfType<String>(json, r'sourceUrl'),
|
||||||
|
version: mapValueOfType<String>(json, r'version')!,
|
||||||
|
versionUrl: mapValueOfType<String>(json, r'versionUrl')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ServerAboutResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <ServerAboutResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = ServerAboutResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, ServerAboutResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, ServerAboutResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = ServerAboutResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of ServerAboutResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<ServerAboutResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<ServerAboutResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = ServerAboutResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'version',
|
||||||
|
'versionUrl',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -4718,6 +4718,38 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/server-info/about": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAboutInfo",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ServerAboutResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Server Info"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/server-info/config": {
|
"/server-info/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getServerConfig",
|
"operationId": "getServerConfig",
|
||||||
|
@ -9630,6 +9662,63 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"ServerAboutResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"build": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"buildImage": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"buildImageUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"buildUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"exiftool": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ffmpeg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"imagemagick": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"libvips": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nodejs": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"repositoryUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sourceCommit": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sourceRef": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sourceUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"versionUrl": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"versionUrl"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ServerConfigDto": {
|
"ServerConfigDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"externalDomain": {
|
"externalDomain": {
|
||||||
|
|
|
@ -787,6 +787,24 @@ export type SmartSearchDto = {
|
||||||
withDeleted?: boolean;
|
withDeleted?: boolean;
|
||||||
withExif?: boolean;
|
withExif?: boolean;
|
||||||
};
|
};
|
||||||
|
export type ServerAboutResponseDto = {
|
||||||
|
build?: string;
|
||||||
|
buildImage?: string;
|
||||||
|
buildImageUrl?: string;
|
||||||
|
buildUrl?: string;
|
||||||
|
exiftool?: string;
|
||||||
|
ffmpeg?: string;
|
||||||
|
imagemagick?: string;
|
||||||
|
libvips?: string;
|
||||||
|
nodejs?: string;
|
||||||
|
repository?: string;
|
||||||
|
repositoryUrl?: string;
|
||||||
|
sourceCommit?: string;
|
||||||
|
sourceRef?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
version: string;
|
||||||
|
versionUrl: string;
|
||||||
|
};
|
||||||
export type ServerConfigDto = {
|
export type ServerConfigDto = {
|
||||||
externalDomain: string;
|
externalDomain: string;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
|
@ -2363,6 +2381,14 @@ export function getSearchSuggestions({ country, make, model, state, $type }: {
|
||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export function getAboutInfo(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: ServerAboutResponseDto;
|
||||||
|
}>("/server-info/about", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
export function getServerConfig(opts?: Oazapfts.RequestOpts) {
|
export function getServerConfig(opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
|
|
|
@ -59,6 +59,22 @@ RUN npm link && npm install -g @immich/cli && npm cache clean --force
|
||||||
COPY LICENSE /licenses/LICENSE.txt
|
COPY LICENSE /licenses/LICENSE.txt
|
||||||
COPY LICENSE /LICENSE
|
COPY LICENSE /LICENSE
|
||||||
ENV PATH="${PATH}:/usr/src/app/bin"
|
ENV PATH="${PATH}:/usr/src/app/bin"
|
||||||
|
|
||||||
|
ARG BUILD_ID
|
||||||
|
ARG BUILD_IMAGE
|
||||||
|
ARG BUILD_SOURCE_REF
|
||||||
|
ARG BUILD_SOURCE_COMMIT
|
||||||
|
|
||||||
|
ENV IMMICH_BUILD=${BUILD_ID}
|
||||||
|
ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID}
|
||||||
|
ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE}
|
||||||
|
ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-server
|
||||||
|
ENV IMMICH_REPOSITORY=immich-app/immich
|
||||||
|
ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich
|
||||||
|
ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
|
||||||
|
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
|
||||||
|
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
|
||||||
|
|
||||||
VOLUME /usr/src/app/upload
|
VOLUME /usr/src/app/upload
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
ENTRYPOINT ["tini", "--", "/bin/bash"]
|
ENTRYPOINT ["tini", "--", "/bin/bash"]
|
||||||
|
|
|
@ -429,3 +429,15 @@ export const clsConfig: ClsModuleOptions = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBuildMetadata = () => ({
|
||||||
|
build: process.env.IMMICH_BUILD,
|
||||||
|
buildUrl: process.env.IMMICH_BUILD_URL,
|
||||||
|
buildImage: process.env.IMMICH_BUILD_IMAGE,
|
||||||
|
buildImageUrl: process.env.IMMICH_BUILD_IMAGE_URL,
|
||||||
|
repository: process.env.IMMICH_REPOSITORY,
|
||||||
|
repositoryUrl: process.env.IMMICH_REPOSITORY_URL,
|
||||||
|
sourceRef: process.env.IMMICH_SOURCE_REF,
|
||||||
|
sourceCommit: process.env.IMMICH_SOURCE_COMMIT,
|
||||||
|
sourceUrl: process.env.IMMICH_SOURCE_URL,
|
||||||
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
ServerAboutResponseDto,
|
||||||
ServerConfigDto,
|
ServerConfigDto,
|
||||||
ServerFeaturesDto,
|
ServerFeaturesDto,
|
||||||
ServerMediaTypesResponseDto,
|
ServerMediaTypesResponseDto,
|
||||||
|
@ -22,6 +23,12 @@ export class ServerInfoController {
|
||||||
private versionService: VersionService,
|
private versionService: VersionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get('about')
|
||||||
|
@Authenticated()
|
||||||
|
getAboutInfo(): Promise<ServerAboutResponseDto> {
|
||||||
|
return this.service.getAboutInfo();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('storage')
|
@Get('storage')
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
getStorage(): Promise<ServerStorageResponseDto> {
|
getStorage(): Promise<ServerStorageResponseDto> {
|
||||||
|
|
|
@ -7,6 +7,29 @@ export class ServerPingResponse {
|
||||||
res!: string;
|
res!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ServerAboutResponseDto {
|
||||||
|
version!: string;
|
||||||
|
versionUrl!: string;
|
||||||
|
|
||||||
|
repository?: string;
|
||||||
|
repositoryUrl?: string;
|
||||||
|
|
||||||
|
sourceRef?: string;
|
||||||
|
sourceCommit?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
|
||||||
|
build?: string;
|
||||||
|
buildUrl?: string;
|
||||||
|
buildImage?: string;
|
||||||
|
buildImageUrl?: string;
|
||||||
|
|
||||||
|
nodejs?: string;
|
||||||
|
ffmpeg?: string;
|
||||||
|
imagemagick?: string;
|
||||||
|
libvips?: string;
|
||||||
|
exiftool?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ServerStorageResponseDto {
|
export class ServerStorageResponseDto {
|
||||||
diskSize!: string;
|
diskSize!: string;
|
||||||
diskUse!: string;
|
diskUse!: string;
|
||||||
|
|
|
@ -8,8 +8,17 @@ export interface GitHubRelease {
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerBuildVersions {
|
||||||
|
nodejs: string;
|
||||||
|
ffmpeg: string;
|
||||||
|
libvips: string;
|
||||||
|
exiftool: string;
|
||||||
|
imagemagick: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const IServerInfoRepository = 'IServerInfoRepository';
|
export const IServerInfoRepository = 'IServerInfoRepository';
|
||||||
|
|
||||||
export interface IServerInfoRepository {
|
export interface IServerInfoRepository {
|
||||||
getGitHubRelease(): Promise<GitHubRelease>;
|
getGitHubRelease(): Promise<GitHubRelease>;
|
||||||
|
getBuildVersions(): Promise<ServerBuildVersions>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,45 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { GitHubRelease, IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
import { exiftool } from 'exiftool-vendored';
|
||||||
|
import { exec as execCallback } from 'node:child_process';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
|
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
|
||||||
import { Instrumentation } from 'src/utils/instrumentation';
|
import { Instrumentation } from 'src/utils/instrumentation';
|
||||||
|
|
||||||
|
const exec = promisify(execCallback);
|
||||||
|
const maybeFirstLine = async (command: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec(command);
|
||||||
|
return stdout.trim().split('\n')[0] || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildLockfile = {
|
||||||
|
sources: Array<{ name: string; version: string }>;
|
||||||
|
packages: Array<{ name: string; version: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => {
|
||||||
|
if (!lockfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...(lockfile.sources || []), ...(lockfile?.packages || [])];
|
||||||
|
const item = items.find((item) => item.name === name);
|
||||||
|
return item?.version;
|
||||||
|
};
|
||||||
|
|
||||||
@Instrumentation()
|
@Instrumentation()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServerInfoRepository implements IServerInfoRepository {
|
export class ServerInfoRepository implements IServerInfoRepository {
|
||||||
|
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
|
||||||
|
this.logger.setContext(ServerInfoRepository.name);
|
||||||
|
}
|
||||||
|
|
||||||
async getGitHubRelease(): Promise<GitHubRelease> {
|
async getGitHubRelease(): Promise<GitHubRelease> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest');
|
const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest');
|
||||||
|
@ -18,4 +53,25 @@ export class ServerInfoRepository implements IServerInfoRepository {
|
||||||
throw new Error(`Failed to fetch GitHub release: ${error}`);
|
throw new Error(`Failed to fetch GitHub release: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBuildVersions(): Promise<ServerBuildVersions> {
|
||||||
|
const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([
|
||||||
|
maybeFirstLine('node --version'),
|
||||||
|
maybeFirstLine('ffmpeg -version'),
|
||||||
|
maybeFirstLine('convert --version'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lockfile = await readFile('build-lock.json')
|
||||||
|
.then((buffer) => JSON.parse(buffer.toString()))
|
||||||
|
.catch(() => this.logger.warn('Failed to read build-lock.json'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodejs: nodejsOutput || process.env.NODE_VERSION || '',
|
||||||
|
exiftool: await exiftool.version(),
|
||||||
|
ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '',
|
||||||
|
libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips,
|
||||||
|
imagemagick:
|
||||||
|
getLockfileVersion('imagemagick', lockfile) || magickOutput.replaceAll('Version: ImageMagick ', '') || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
|
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||||
import { ServerInfoService } from 'src/services/server-info.service';
|
import { ServerInfoService } from 'src/services/server-info.service';
|
||||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||||
|
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
|
||||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||||
|
@ -13,16 +15,18 @@ describe(ServerInfoService.name, () => {
|
||||||
let sut: ServerInfoService;
|
let sut: ServerInfoService;
|
||||||
let storageMock: Mocked<IStorageRepository>;
|
let storageMock: Mocked<IStorageRepository>;
|
||||||
let userMock: Mocked<IUserRepository>;
|
let userMock: Mocked<IUserRepository>;
|
||||||
|
let serverInfoMock: Mocked<IServerInfoRepository>;
|
||||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||||
let loggerMock: Mocked<ILoggerRepository>;
|
let loggerMock: Mocked<ILoggerRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
|
serverInfoMock = newServerInfoRepositoryMock();
|
||||||
systemMock = newSystemMetadataRepositoryMock();
|
systemMock = newSystemMetadataRepositoryMock();
|
||||||
loggerMock = newLoggerRepositoryMock();
|
loggerMock = newLoggerRepositoryMock();
|
||||||
|
|
||||||
sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock);
|
sut = new ServerInfoService(userMock, storageMock, systemMock, serverInfoMock, loggerMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { getBuildMetadata } from 'src/config';
|
||||||
|
import { serverVersion } from 'src/constants';
|
||||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
import {
|
import {
|
||||||
|
ServerAboutResponseDto,
|
||||||
ServerConfigDto,
|
ServerConfigDto,
|
||||||
ServerFeaturesDto,
|
ServerFeaturesDto,
|
||||||
ServerMediaTypesResponseDto,
|
ServerMediaTypesResponseDto,
|
||||||
|
@ -12,6 +15,7 @@ import {
|
||||||
} from 'src/dtos/server-info.dto';
|
} from 'src/dtos/server-info.dto';
|
||||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
|
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
|
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
|
||||||
|
@ -27,6 +31,7 @@ export class ServerInfoService {
|
||||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
|
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
|
||||||
|
@Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository,
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(ServerInfoService.name);
|
this.logger.setContext(ServerInfoService.name);
|
||||||
|
@ -42,6 +47,19 @@ export class ServerInfoService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAboutInfo(): Promise<ServerAboutResponseDto> {
|
||||||
|
const version = serverVersion.toString();
|
||||||
|
const buildMetadata = getBuildMetadata();
|
||||||
|
const buildVersions = await this.serverInfoRepository.getBuildVersions();
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`,
|
||||||
|
...buildMetadata,
|
||||||
|
...buildVersions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getStorage(): Promise<ServerStorageResponseDto> {
|
async getStorage(): Promise<ServerStorageResponseDto> {
|
||||||
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||||
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);
|
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { VersionService } from 'src/services/version.service';
|
||||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||||
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
|
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
|
||||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||||
import { Mocked } from 'vitest';
|
import { Mocked } from 'vitest';
|
||||||
|
|
||||||
|
|
|
@ -4,5 +4,6 @@ import { Mocked, vitest } from 'vitest';
|
||||||
export const newServerInfoRepositoryMock = (): Mocked<IServerInfoRepository> => {
|
export const newServerInfoRepositoryMock = (): Mocked<IServerInfoRepository> => {
|
||||||
return {
|
return {
|
||||||
getGitHubRelease: vitest.fn(),
|
getGitHubRelease: vitest.fn(),
|
||||||
|
getBuildVersions: vitest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
|
@ -0,0 +1,156 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
|
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||||
|
import { type ServerAboutResponseDto } from '@immich/sdk';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
export let info: ServerAboutResponseDto;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Portal>
|
||||||
|
<FullScreenModal title={$t('about')} {onClose}>
|
||||||
|
<div
|
||||||
|
class="immich-scrollbar max-h-[500px] overflow-y-auto flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
|
||||||
|
>Immich</label
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={info.versionUrl}
|
||||||
|
class="underline text-sm immich-form-label"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
id="version-desc"
|
||||||
|
>
|
||||||
|
{info.version}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
|
||||||
|
>ExifTool</label
|
||||||
|
>
|
||||||
|
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
|
||||||
|
{info.exiftool}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="nodejs-desc"
|
||||||
|
>Node.js</label
|
||||||
|
>
|
||||||
|
<p class="immich-form-label pb-2 text-sm" id="nodejs-desc">
|
||||||
|
{info.nodejs}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="vips-desc"
|
||||||
|
>Libvips</label
|
||||||
|
>
|
||||||
|
<p class="immich-form-label pb-2 text-sm" id="vips-desc">
|
||||||
|
{info.libvips}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={(info.imagemagick?.length || 0) > 10 ? 'col-span-2' : ''}>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="imagemagick-desc"
|
||||||
|
>ImageMagick</label
|
||||||
|
>
|
||||||
|
<p class="immich-form-label pb-2 text-sm" id="imagemagick-desc">
|
||||||
|
{info.imagemagick}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={(info.ffmpeg?.length || 0) > 10 ? 'col-span-2' : ''}>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
|
||||||
|
>FFmpeg</label
|
||||||
|
>
|
||||||
|
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
|
||||||
|
{info.ffmpeg}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if info.repository && info.repositoryUrl}
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
|
||||||
|
>{$t('repository')}</label
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={info.repositoryUrl}
|
||||||
|
class="underline text-sm immich-form-label"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
id="version-desc"
|
||||||
|
>
|
||||||
|
{info.repository}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if info.sourceRef && info.sourceCommit && info.sourceUrl}
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="git-desc"
|
||||||
|
>{$t('source')}</label
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={info.sourceUrl}
|
||||||
|
class="underline text-sm immich-form-label"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
id="git-desc"
|
||||||
|
>
|
||||||
|
{info.sourceRef}@{info.sourceCommit.slice(0, 9)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if info.build && info.buildUrl}
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-desc"
|
||||||
|
>{$t('build')}</label
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={info.buildUrl}
|
||||||
|
class="underline text-sm immich-form-label"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
id="build-desc"
|
||||||
|
>
|
||||||
|
{info.build}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if info.buildImage && info.buildImage}
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-image-desc"
|
||||||
|
>{$t('build_image')}</label
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={info.buildImageUrl}
|
||||||
|
class="underline text-sm immich-form-label"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
id="build-image-desc"
|
||||||
|
>
|
||||||
|
{info.buildImage}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</FullScreenModal>
|
||||||
|
</Portal>
|
|
@ -1,19 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { websocketStore } from '$lib/stores/websocket';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { getByteUnitString } from '../../utils/byte-units';
|
|
||||||
import LoadingSpinner from './loading-spinner.svelte';
|
|
||||||
import { mdiChartPie, mdiDns } from '@mdi/js';
|
|
||||||
import { serverInfo } from '$lib/stores/server-info.store';
|
import { serverInfo } from '$lib/stores/server-info.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
|
import { websocketStore } from '$lib/stores/websocket';
|
||||||
import { requestServerInfo } from '$lib/utils/auth';
|
import { requestServerInfo } from '$lib/utils/auth';
|
||||||
|
import { mdiChartPie, mdiDns } from '@mdi/js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { getByteUnitString } from '../../utils/byte-units';
|
||||||
|
import LoadingSpinner from './loading-spinner.svelte';
|
||||||
|
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
const { serverVersion, connected } = websocketStore;
|
const { serverVersion, connected } = websocketStore;
|
||||||
|
|
||||||
let usageClasses = '';
|
let usageClasses = '';
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
|
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
|
||||||
$: hasQuota = $user?.quotaSizeInBytes !== null;
|
$: hasQuota = $user?.quotaSizeInBytes !== null;
|
||||||
|
@ -21,6 +24,8 @@
|
||||||
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
|
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
|
||||||
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
|
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
|
||||||
|
|
||||||
|
let aboutInfo: ServerAboutResponseDto;
|
||||||
|
|
||||||
const onUpdate = () => {
|
const onUpdate = () => {
|
||||||
usageClasses = getUsageClass();
|
usageClasses = getUsageClass();
|
||||||
};
|
};
|
||||||
|
@ -41,9 +46,14 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await requestServerInfo();
|
await requestServerInfo();
|
||||||
|
aboutInfo = await getAboutInfo();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="dark:text-immich-dark-fg">
|
<div class="dark:text-immich-dark-fg">
|
||||||
<div
|
<div
|
||||||
class="storage-status grid grid-cols-[64px_auto]"
|
class="storage-status grid grid-cols-[64px_auto]"
|
||||||
|
@ -96,13 +106,11 @@
|
||||||
<div class="mt-2 flex justify-between justify-items-center">
|
<div class="mt-2 flex justify-between justify-items-center">
|
||||||
<p>{$t('version')}</p>
|
<p>{$t('version')}</p>
|
||||||
{#if $connected && version}
|
{#if $connected && version}
|
||||||
<a
|
<button
|
||||||
href="https://github.com/immich-app/immich/releases"
|
type="button"
|
||||||
class="font-medium text-immich-primary dark:text-immich-dark-primary"
|
on:click={() => (isOpen = true)}
|
||||||
target="_blank"
|
class="font-medium text-immich-primary dark:text-immich-dark-primary">{version}</button
|
||||||
>
|
>
|
||||||
{version}
|
|
||||||
</a>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="font-medium text-red-500">{$t('unknown')}</p>
|
<p class="font-medium text-red-500">{$t('unknown')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"about": "About",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Account Settings",
|
||||||
"acknowledge": "Acknowledge",
|
"acknowledge": "Acknowledge",
|
||||||
|
@ -380,6 +381,8 @@
|
||||||
"birthdate_saved": "Date of birth saved successfully",
|
"birthdate_saved": "Date of birth saved successfully",
|
||||||
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
|
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
|
||||||
"blurred_background": "Blurred background",
|
"blurred_background": "Blurred background",
|
||||||
|
"build": "Build",
|
||||||
|
"build_image": "Build Image",
|
||||||
"bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!",
|
"bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!",
|
||||||
"bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.",
|
"bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.",
|
||||||
"bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.",
|
"bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.",
|
||||||
|
@ -904,6 +907,7 @@
|
||||||
"repair": "Repair",
|
"repair": "Repair",
|
||||||
"repair_no_results_message": "Untracked and missing files will show up here",
|
"repair_no_results_message": "Untracked and missing files will show up here",
|
||||||
"replace_with_upload": "Replace with upload",
|
"replace_with_upload": "Replace with upload",
|
||||||
|
"repository": "Repository",
|
||||||
"require_password": "Require password",
|
"require_password": "Require password",
|
||||||
"require_user_to_change_password_on_first_login": "Require user to change password on first login",
|
"require_user_to_change_password_on_first_login": "Require user to change password on first login",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
|
@ -1016,6 +1020,7 @@
|
||||||
"sort_oldest": "Oldest photo",
|
"sort_oldest": "Oldest photo",
|
||||||
"sort_recent": "Most recent photo",
|
"sort_recent": "Most recent photo",
|
||||||
"sort_title": "Title",
|
"sort_title": "Title",
|
||||||
|
"source": "Source",
|
||||||
"stack": "Stack",
|
"stack": "Stack",
|
||||||
"stack_selected_photos": "Stack selected photos",
|
"stack_selected_photos": "Stack selected photos",
|
||||||
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
|
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
|
||||||
|
|
Loading…
Add table
Reference in a new issue