Compare commits

...

18 Commits

Author SHA1 Message Date
github-actions
8bbcd5c31e chore: version v1.116.2 2024-09-27 18:17:49 +00:00
Alex
4ed1517e60 chore(mobile): post release task (#12991) 2024-09-27 14:13:24 -04:00
Zack Pollard
789937d4a2 fix: library pagination to 10k to avoid too many postgres query params (#12993) 2024-09-27 18:15:44 +01:00
bo0tzz
dbe542803f docs: update FAQ CLIP search explanation (#12986) 2024-09-27 13:07:00 -04:00
github-actions
7c15e11efc chore: version v1.116.1 2024-09-27 15:32:16 +00:00
Alex
03aa346020 fix(mobile): incorrect filename is retrieved during upload (#12990)
* fix(mobile): incorrect filename is retrieve during upload

* use the same convention to get local id

* revert previous change

* pr feedback
2024-09-27 22:28:31 +07:00
martin
3a37fc8bfd feat: no slideshow transition (#12989) 2024-09-27 15:05:07 +00:00
Jason Rasmussen
36ee72cd87 refactor(server): access env via repository (#12987) 2024-09-27 10:28:56 -04:00
Jason Rasmussen
12da250028 refactor: enums (#12988) 2024-09-27 10:28:42 -04:00
Ryan Ribeiro
5b282733fe chore(Brazilian README): fix broken image links and update translation (#12980) 2024-09-27 08:15:25 -04:00
Alex
971ba63447 fix(mobile): uninitialize provider causes unable to logging in (#12970)
fix(mobile): use uninitialize provider
2024-09-27 09:40:55 +07:00
KD-MM2
d5ee823fbc refactor(docs): fix heading tag, update Vietnamese translation for image alt, formatting features table (#12971)
* feat(readme): add Vietnamese translation

* feat(readme): add Vietnamese translation

* refactor(readme): update Vietnamese translation section

* Update README_vi_VN.md

* refactor(docs): fix heading tag, update Vietnamese translation for image alt, formatting features table

---------

Co-authored-by: tdcaot <cao@sohobb.jp>
2024-09-27 02:40:00 +00:00
KD-MM2
26f33652e1 feat(docs): add Vietnamese translation (#12967)
* feat(readme): add Vietnamese translation

* feat(readme): add Vietnamese translation

* refactor(readme): update Vietnamese translation section

---------

Co-authored-by: tdcaot <cao@sohobb.jp>
2024-09-27 01:57:26 +00:00
Spencer Fasulo
c86fa81e47 docs(web): JSDoc comments for svelte actions (#12963)
* Web: JSDoc comments for Actions

* Remove comment
2024-09-27 01:41:22 +00:00
Lauritz Tieste
42ad3e6bb0 fix(mobile): navigation panel overlaps with right rotate (#12950)
fix: navigation panel overlaps with right rotate
2024-09-27 08:40:07 +07:00
Alex
a6e703ed6b chore(mobile): post release task (#12955) 2024-09-27 08:11:22 +07:00
Jason Rasmussen
b6f871786c fix(server): handle numeric hierarchical subject values (#12949) 2024-09-26 14:32:10 -04:00
Gus Price
62a490eca2 docs: add clarity to non root user section (#12956)
* clarity

* prettier
2024-09-26 17:34:01 +00:00
98 changed files with 816 additions and 445 deletions

View File

@@ -33,6 +33,7 @@
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
<a href="readme_i18n/README_ar_JO.md">العربية</a>
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
</p>

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.20",
"version": "2.2.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.20",
"version": "2.2.22",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.116.0",
"version": "1.116.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.20",
"version": "2.2.22",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -187,7 +187,7 @@ However, when the trash is emptied, the files will re-appear in the main timelin
### How does smart search work?
Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip).
Immich uses CLIP models. An ML model converts each image to an "embedding", which is essentially a string of numbers that semantically encodes what is in the image. The same is done for the text that you enter when you do a search, and that text embedding is then compared with those of the images to find similar ones. As such, there are no "tags", "labels", or "descriptions" generated that you can look at. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip).
### How does facial recognition work?
@@ -333,7 +333,11 @@ You may need to add mount points or docker volumes for the following internal co
- `immich-machine-learning:/.cache`
- `redis:/data`
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION` and `/cache` for machine-learning.
:::note Docker Compose Volumes
The Docker Compose top level volume element does not support non-root access, all of the above volumes must be local volume mounts.
:::
For a further hardened system, you can add the following block to every container except for `immich_postgres`.

View File

@@ -1,4 +1,12 @@
[
{
"label": "v1.116.2",
"url": "https://v1.116.2.archive.immich.app"
},
{
"label": "v1.116.1",
"url": "https://v1.116.1.archive.immich.app"
},
{
"label": "v1.116.0",
"url": "https://v1.116.0.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.116.0",
"version": "1.116.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.116.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.20",
"version": "2.2.22",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.116.0",
"version": "1.116.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.116.0",
"version": "1.116.2",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.116.0"
version = "1.116.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 160,
"android.injected.version.name" => "1.116.0",
"android.injected.version.code" => 161,
"android.injected.version.name" => "1.116.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

@@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 175;
CURRENT_PROJECT_VERSION = 177;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 175;
CURRENT_PROJECT_VERSION = 177;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -571,7 +571,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 175;
CURRENT_PROJECT_VERSION = 177;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.115.0</string>
<string>1.116.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>175</string>
<string>177</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.116.0"
version_number: "1.116.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -4,4 +4,7 @@ abstract interface class IAssetMediaRepository {
Future<List<String>> deleteAll(List<String> ids);
Future<Asset?> get(String id);
/// Obtaining the correct original filename of the asset
Future<String?> getOriginalFilename(String id);
}

View File

@@ -51,107 +51,109 @@ class CropImagePage extends HookWidget {
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(
controller: cropController,
image: image,
gridColor: Colors.white,
),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(
controller: cropController,
image: image,
gridColor: Colors.white,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: 10,
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Icons.rotate_left,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateLeft();
},
),
IconButton(
icon: Icon(
Icons.rotate_right,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateRight();
},
),
],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Icons.rotate_left,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateLeft();
},
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
IconButton(
icon: Icon(
Icons.rotate_right,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateRight();
},
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
],
),
),
),
),
),
],
);
},
],
);
},
),
),
);
}

View File

@@ -86,12 +86,16 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
void handleAppPause() {
state = AppLifeCycleEnum.paused;
_wasPaused = true;
// Do not cancel backup if manual upload is in progress
if (_ref.read(backupProvider.notifier).backupProgress !=
BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup();
if (_ref.read(authenticationProvider).isAuthenticated) {
// Do not cancel backup if manual upload is in progress
if (_ref.read(backupProvider.notifier).backupProgress !=
BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup();
}
_ref.read(websocketProvider.notifier).disconnect();
}
_ref.read(websocketProvider.notifier).disconnect();
ImmichLogger().flush();
}

View File

@@ -43,4 +43,17 @@ class AssetMediaRepository implements IAssetMediaRepository {
asset.local = local;
return asset;
}
@override
Future<String?> getOriginalFilename(String id) async {
final entity = await AssetEntity.fromId(id);
if (entity == null) {
return null;
}
// titleAsync gets the correct original filename for some assets on iOS
// otherwise using the `entity.title` would return a random GUID
return await entity.titleAsync;
}
}

View File

@@ -15,6 +15,7 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
@@ -368,6 +369,7 @@ class BackgroundService {
BackupRepository backupAlbumRepository = BackupRepository(db);
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
FileMediaRepository fileMediaRepository = FileMediaRepository();
AssetMediaRepository assetMediaRepository = AssetMediaRepository();
UserRepository userRepository = UserRepository(db);
UserApiRepository userApiRepository =
UserApiRepository(apiService.usersApi);
@@ -409,6 +411,7 @@ class BackgroundService {
albumService,
albumMediaRepository,
fileMediaRepository,
assetMediaRepository,
);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();

View File

@@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -40,6 +42,7 @@ final backupServiceProvider = Provider(
ref.watch(albumServiceProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref.watch(assetMediaRepositoryProvider),
),
);
@@ -52,6 +55,7 @@ class BackupService {
final AlbumService _albumService;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
final IAssetMediaRepository _assetMediaRepository;
BackupService(
this._apiService,
@@ -60,6 +64,7 @@ class BackupService {
this._albumService,
this._albumMediaRepository,
this._fileMediaRepository,
this._assetMediaRepository,
);
Future<List<String>?> getDeviceBackupAsset() async {
@@ -329,7 +334,9 @@ class BackupService {
}
if (file != null) {
String originalFileName = asset.fileName;
String? originalFileName =
await _assetMediaRepository.getOriginalFilename(asset.localId!);
originalFileName ??= asset.fileName;
if (asset.local!.isLivePhoto) {
if (livePhotoFile == null) {

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.116.0
- API version: 1.116.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.116.0+160
version: 1.116.2+161
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@@ -7409,7 +7409,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.116.0",
"version": "1.116.2",
"contact": {}
},
"tags": [],

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.116.0",
"version": "1.116.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.116.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.116.0",
"version": "1.116.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.116.0
* 1.116.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,84 +1,90 @@
<p align="center">
<br/>
<p align="center">
<br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Licença: AGPLv3"></a>
<a href="https://discord.immich.app">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login com URL customizada">
<img src="../design/immich-logo-stacked-light.svg" width="150" title="Immich Logo">
</p>
<h3 align="center">Immich - Solução self-hosted de alta performance para backup de fotos e vídeos</h3>
<h3 align="center">Solução self-hosted de alta performance para backup de fotos e vídeos</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Captura de tela princial">
<img src="../design/immich-screenshots.png" title="Captura de tela princial">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_ru_RU.md">Русский</a>
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="../README.md">English</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_ru_RU.md">Русский</a>
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
</p>
## Avisos
- ⚠️ Este projeto está sob **desenvolvimento constante**.
- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a compatibilidade com versões anteriores).
- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e vídeos.**
- ⚠️ Sempre siga o plano [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup para as suas mídias preciosas!
- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a
compatibilidade com versões anteriores).
- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e
vídeos.**
- ⚠️ Sempre siga o plano
[3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup
para as suas mídias preciosas!
## Conteúdo
> [!NOTE]
> Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/.
- [Documentação Oficial](https://immich.app/docs)
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
- [Demonstração](#demo)
- [Recursos](#features)
- [Introdução](https://immich.app/docs/overview/introduction)
## Links
- [Documentação](https://immich.app/docs)
- [Sobre](https://immich.app/docs/overview/introduction)
- [Instalação](https://immich.app/docs/install/requirements)
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
- [Demonstração](#demonstração)
- [Funcionalidades](#funcionalidades)
- [Traduções](https://immich.app/docs/developer/translations)
- [Diretrizes de Contribuição](https://immich.app/docs/overview/support-the-project)
## Documentação
Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/.
## Demonstração
Você pode acessar a demonstração web em https://demo.immich.app
Acesse a demonstração [aqui](https://demo.immich.app). A demonstração está
hospedada no Nível Gratuito da Oracle VM em Amsterdam com um processador 2.4Ghz
quad-core ARM64 e 24GB de RAM.
No aplicativo para dispositivos móveis, você pode usar `https://demo.immich.app/api` no campo `Server Endpoint URL`
No aplicativo para dispositivos móveis, você pode usar
`https://demo.immich.app/api` no campo `Server Endpoint URL`
```bash title="Credenciais de Demonstração"
Credenciais de Demonstração
email: demo@immich.app
senha: demo
```
### Credenciais de login
```
Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
| Email | Senha |
| --------------- | ----- |
| demo@immich.app | demo |
## Atividades
![Atividades](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de Analytics do Repobeats")
## Recursos
## Funcionalidades
| Recursos | Aplicativo Móvel | Web |
|:----------------------------------------------------|------------------|-----|
| Funcionalidades | Aplicativo Móvel | Web |
| :-------------------------------------------------- | ---------------- | --- |
| Fazer upload e visualizar fotos e vídeos | Sim | Sim |
| Backup automático ao abrir o aplicativo | Sim | N/A |
| Prevenir a duplicação de arquivos | Sim | Sim |
@@ -88,17 +94,17 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR
| Criação de álbuns e álbuns compartilhados | Sim | Sim |
| Barra de rolagem arrastável | Sim | Sim |
| Suporta formatos RAW | Sim | Sim |
| Visualização de metadados (EXIF, map) | Sim | Sim |
| Pesquisar por metadados, objetos, rostos, and CLIP | Sim | Sim |
| Visualização de metadados (EXIF, mapa) | Sim | Sim |
| Pesquisar por metadados, objetos, rostos, e CLIP | Sim | Sim |
| Funções administrativas (gerenciamento de usuários) | Não | Sim |
| Backup em segundo plano | Sim | N/A |
| Virtual scroll | Sim | Sim |
| Rolagem virtual | Sim | Sim |
| Suporte OAuth | Sim | Sim |
| Chaves de API | N/A | Sim |
| Backup e visualização de LivePhoto/MotionPhoto | Sim | Sim |
| Backup e reprodução de LivePhoto/MotionPhoto | Sim | Sim |
| Visualização de imagens 360º | Não | Sim |
| Estrutura de armazenamento definida pelo usuário | Sim | Sim |
| Compartilhar com o público | Não | Sim |
| Compartilhar com o público | Sim | Sim |
| Arquivo e Favoritos | Sim | Sim |
| Mapa Global | Sim | Sim |
| Compartilhamento com parceiro | Sim | Sim |
@@ -108,6 +114,29 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR
| Galeria em modo apenas leitura | Sim | Sim |
| Empilhamento de fotos | Sim | Sim |
## Traduções
Leia mais sobre as traduções
[aqui](https://immich.app/docs/developer/translations).
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Status da tradução" />
</a>
## Atividade do repositório
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de análise de atividade Repobeats")
## Histórico de estrelas
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Gráfico de histórico de estrelas" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Contribuidores
<a href="https://github.com/alextran1502/immich/graphs/contributors">

133
readme_i18n/README_vi_VN.md Normal file
View File

@@ -0,0 +1,133 @@
<p align="center">
<br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Giấy phép: AGPLv3"></a>
<a href="https://discord.immich.app">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Đăng nhập bằng URL Tuỳ chỉnh">
</p>
<h3 align="center">Giải pháp quản lý ảnh và video tự lưu trữ hiệu suất cao</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Ảnh chụp màn hình chính">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_ru_RU.md">Русский</a>
<a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
</p>
## Tuyên bố miễn trừ trách nhiệm
- ⚠️ Dự án đang được phát triển **rất tích cực**.
- ⚠️ Dự kiến sẽ có lỗi và thay đổi đột ngột.
- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.**
- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn!
> [!NOTE]
> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/.
## Liên kết
- [Tài liệu](https://immich.app/docs)
- [Giới thiệu](https://immich.app/docs/overview/introduction)
- [Cài đặt](https://immich.app/docs/install/requirements)
- [Lộ trình](https://immich.app/roadmap)
- [Demo](#demo)
- [Tính năng](#Tính-năng)
- [Dịch thuật](https://immich.app/docs/developer/translations)
- [Đóng góp](https://immich.app/docs/overview/support-the-project)
## Demo
Truy cập bản demo [tại đây](https://demo.immich.app). Bản demo đang chạy trên máy ảo Oracle Free-tier ở Amsterdam với CPU ARM64 lõi tứ 2,4 GHz và RAM 24 GB.
Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app/api` cho `Server Endpoint URL`
### Thông tin đăng nhập
| Email | Mật khẩu |
| --------------- | -------- |
| demo@immich.app | demo |
## Tính năng
| Tính năng | Mobile | Web |
| :--------------------------------------------------- | ------ | ----- |
| Tải lên và xem video, ảnh | Có | Có |
| Tự động sao lưu khi ứng dụng được mở | Có | N/A |
| Ngăn chặn sự trùng lặp nội dung | Có | Có |
| Album được chọn để sao lưu | Có | N/A |
| Tải ảnh và video xuống thiết bị cục bộ | Có | Có |
| Hỗ trợ nhiều người dùng | Có | Có |
| Album và Album được chia sẻ | Có | Có |
| Thanh cuộn có thể chà / kéo | Có | Có |
| Hỗ trợ định dạng raw | Có | Có |
| Xem metadata (EXIF, bản đồ) | Có | Có |
| Tìm kiếm theo metadata, đối tượng, khuôn mặt và CLIP | Có | Có |
| Chức năng quản trị (quản lý người dùng) | Không | Có |
| Sao lưu trong nền | Có | N/A |
| Cuộn ảo | Có | Có |
| Hỗ trợ OAuth | Có | Có |
| API Keys | N/A | Có |
| Sao lưu và phát lại Live Photo/Motion Photo | Có | Có |
| Hỗ trợ hiển thị hình ảnh 360 độ | Không | Có |
| Cấu trúc lưu trữ do người dùng xác định | Có | Có |
| Chia sẻ công khai | Có | Có |
| Lưu trữ và Yêu thích | Có | Có |
| Bản đồ toàn cầu | Có | Có |
| Chia sẻ đối tác | Có | Có |
| Nhận dạng khuôn mặt và phân cụm | Có | Có |
| Kỷ niệm (x năm trước) | Có | Có |
| Hỗ trợ ngoại tuyến | Có | Không |
| Thư viện chỉ đọc | Có | Có |
| Ảnh xếp chồng | Có | Có |
## Dịch thuật
Đọc thêm về dịch thuật [tại đây](https://immich.app/docs/developer/translations).
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Tình trạng dịch thuật" />
</a>
## Hoạt động của repository
![Hoạt động](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Hình ảnh phân tích Repobeats")
## Lịch sử Đánh dấu sao
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Biểu đồ Lịch sử Đánh dấu" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Người đóng góp
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.116.0",
"version": "1.116.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.116.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",

View File

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

View File

@@ -7,83 +7,20 @@ import { RedisOptions } from 'ioredis';
import Joi, { Root } from 'joi';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { ImmichHeader } from 'src/dtos/auth.dto';
import {
AudioCodec,
Colorspace,
CQMode,
ImageFormat,
LogLevel,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
VideoContainer,
} from 'src/enum';
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
export enum TranscodePolicy {
ALL = 'all',
OPTIMAL = 'optimal',
BITRATE = 'bitrate',
REQUIRED = 'required',
DISABLED = 'disabled',
}
export enum TranscodeTarget {
NONE,
AUDIO,
VIDEO,
ALL,
}
export enum VideoCodec {
H264 = 'h264',
HEVC = 'hevc',
VP9 = 'vp9',
AV1 = 'av1',
}
export enum AudioCodec {
MP3 = 'mp3',
AAC = 'aac',
LIBOPUS = 'libopus',
}
export enum VideoContainer {
MOV = 'mov',
MP4 = 'mp4',
OGG = 'ogg',
WEBM = 'webm',
}
export enum TranscodeHWAccel {
NVENC = 'nvenc',
QSV = 'qsv',
VAAPI = 'vaapi',
RKMPP = 'rkmpp',
DISABLED = 'disabled',
}
export enum ToneMapping {
HABLE = 'hable',
MOBIUS = 'mobius',
REINHARD = 'reinhard',
DISABLED = 'disabled',
}
export enum CQMode {
AUTO = 'auto',
CQP = 'cqp',
ICQ = 'icq',
}
export enum Colorspace {
SRGB = 'srgb',
P3 = 'p3',
}
export enum ImageFormat {
JPEG = 'jpeg',
WEBP = 'webp',
}
export enum LogLevel {
VERBOSE = 'verbose',
DEBUG = 'debug',
LOG = 'log',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
export interface SystemConfig {
ffmpeg: {
crf: number;

View File

@@ -54,11 +54,6 @@ export const resourcePaths = {
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
export const LOGIN_URL = '/auth/login?autoLaunch=0';
export enum AuthType {
PASSWORD = 'password',
OAUTH = 'oauth',
}
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
export const FACE_THUMBNAIL_SIZE = 250;

View File

@@ -33,16 +33,17 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto';
import { RouteKey } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor';
import { FileUploadInterceptor, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor';
import { AssetMediaService } from 'src/services/asset-media.service';
import { sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags('Assets')
@Controller(Route.ASSET)
@Controller(RouteKey.ASSET)
export class AssetMediaController {
constructor(
@Inject(ILoggerRepository) private logger: ILoggerRepository,

View File

@@ -14,13 +14,13 @@ import {
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto';
import { RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { Route } from 'src/middleware/file-upload.interceptor';
import { AssetService } from 'src/services/asset.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Assets')
@Controller(Route.ASSET)
@Controller(RouteKey.ASSET)
export class AssetController {
constructor(private service: AssetService) {}

View File

@@ -1,7 +1,6 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthType } from 'src/constants';
import {
AuthDto,
ChangePasswordDto,
@@ -13,6 +12,7 @@ import {
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { AuthType } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';

View File

@@ -1,7 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthType } from 'src/constants';
import {
AuthDto,
ImmichCookie,
@@ -11,6 +10,7 @@ import {
OAuthConfigDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { AuthType } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';

View File

@@ -21,15 +21,16 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { RouteKey } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { UserService } from 'src/services/user.service';
import { sendFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Users')
@Controller(Route.USER)
@Controller(RouteKey.USER)
export class UserController {
constructor(
private service: UserService,

View File

@@ -1,12 +1,10 @@
import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { ImageFormat } from 'src/config';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, PathType, PersonPathType } from 'src/entities/move.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetFileType } from 'src/enum';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@@ -16,14 +14,6 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getAssetFiles } from 'src/utils/asset.util';
export enum StorageFolder {
ENCODED_VIDEO = 'encoded-video',
LIBRARY = 'library',
UPLOAD = 'upload',
PROFILE = 'profile',
THUMBNAILS = 'thumbs',
}
export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS));
export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO));

View File

@@ -4,8 +4,8 @@ import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces';
import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { MetadataKey } from 'src/enum';
import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface';
import { Metadata } from 'src/middleware/auth.guard';
import { setUnion } from 'src/utils/set';
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
@@ -141,7 +141,7 @@ export type EmitConfig = {
/** lower value has higher priority, defaults to 0 */
priority?: number;
};
export const OnEmit = (config: EmitConfig) => SetMetadata(Metadata.ON_EMIT_CONFIG, config);
export const OnEmit = (config: EmitConfig) => SetMetadata(MetadataKey.ON_EMIT_CONFIG, config);
type LifecycleRelease = 'NEXT_RELEASE' | string;
type LifecycleMetadata = {

View File

@@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator';
import { AssetPathType, PathType, PersonPathType, UserPathType } from 'src/entities/move.entity';
import { EntityType } from 'src/enum';
import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from 'src/enum';
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType });

View File

@@ -18,20 +18,20 @@ import {
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { SystemConfig } from 'src/config';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
import {
AudioCodec,
CQMode,
Colorspace,
ImageFormat,
LogLevel,
SystemConfig,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
VideoContainer,
} from 'src/config';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
} from 'src/enum';
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
import { ValidateBoolean, validateCronExpression } from 'src/validation';

View File

@@ -1,3 +1,4 @@
import { PathType } from 'src/enum';
import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity('move_history')
@@ -21,21 +22,3 @@ export class MoveEntity {
@Column({ type: 'varchar' })
newPath!: string;
}
export enum AssetPathType {
ORIGINAL = 'original',
PREVIEW = 'preview',
THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded_video',
SIDECAR = 'sidecar',
}
export enum PersonPathType {
FACE = 'face',
}
export enum UserPathType {
PROFILE = 'profile',
}
export type PathType = AssetPathType | PersonPathType | UserPathType;

View File

@@ -1,3 +1,8 @@
export enum AuthType {
PASSWORD = 'password',
OAUTH = 'oauth',
}
export enum AssetType {
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
@@ -148,6 +153,14 @@ export enum SharedLinkType {
INDIVIDUAL = 'INDIVIDUAL',
}
export enum StorageFolder {
ENCODED_VIDEO = 'encoded-video',
LIBRARY = 'library',
UPLOAD = 'upload',
PROFILE = 'profile',
THUMBNAILS = 'thumbs',
}
export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
@@ -198,3 +211,120 @@ export enum ManualJobName {
TAG_CLEANUP = 'tag-cleanup',
USER_CLEANUP = 'user-cleanup',
}
export enum AssetPathType {
ORIGINAL = 'original',
PREVIEW = 'preview',
THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded_video',
SIDECAR = 'sidecar',
}
export enum PersonPathType {
FACE = 'face',
}
export enum UserPathType {
PROFILE = 'profile',
}
export type PathType = AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy {
ALL = 'all',
OPTIMAL = 'optimal',
BITRATE = 'bitrate',
REQUIRED = 'required',
DISABLED = 'disabled',
}
export enum TranscodeTarget {
NONE,
AUDIO,
VIDEO,
ALL,
}
export enum VideoCodec {
H264 = 'h264',
HEVC = 'hevc',
VP9 = 'vp9',
AV1 = 'av1',
}
export enum AudioCodec {
MP3 = 'mp3',
AAC = 'aac',
LIBOPUS = 'libopus',
}
export enum VideoContainer {
MOV = 'mov',
MP4 = 'mp4',
OGG = 'ogg',
WEBM = 'webm',
}
export enum TranscodeHWAccel {
NVENC = 'nvenc',
QSV = 'qsv',
VAAPI = 'vaapi',
RKMPP = 'rkmpp',
DISABLED = 'disabled',
}
export enum ToneMapping {
HABLE = 'hable',
MOBIUS = 'mobius',
REINHARD = 'reinhard',
DISABLED = 'disabled',
}
export enum CQMode {
AUTO = 'auto',
CQP = 'cqp',
ICQ = 'icq',
}
export enum Colorspace {
SRGB = 'srgb',
P3 = 'p3',
}
export enum ImageFormat {
JPEG = 'jpeg',
WEBP = 'webp',
}
export enum LogLevel {
VERBOSE = 'verbose',
DEBUG = 'debug',
LOG = 'log',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
export enum MetadataKey {
AUTH_ROUTE = 'auth_route',
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
API_KEY_SECURITY = 'api_key',
ON_EMIT_CONFIG = 'on_emit_config',
}
export enum RouteKey {
ASSET = 'assets',
USER = 'users',
}
export enum CacheControl {
PRIVATE_WITH_CACHE = 'private_with_cache',
PRIVATE_WITHOUT_CACHE = 'private_without_cache',
NONE = 'none',
}
export enum PaginationMode {
LIMIT_OFFSET = 'limit-offset',
SKIP_TAKE = 'skip-take',
}

View File

@@ -0,0 +1,14 @@
import { VectorExtension } from 'src/interfaces/database.interface';
export const IConfigRepository = 'IConfigRepository';
export interface EnvData {
database: {
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
}
export interface IConfigRepository {
getEnv(): EnvData;
}

View File

@@ -116,7 +116,7 @@ export enum JobName {
}
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000;
export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000;
export interface IBaseJob {
force?: boolean;

View File

@@ -1,4 +1,4 @@
import { LogLevel } from 'src/config';
import { LogLevel } from 'src/enum';
export const ILoggerRepository = 'ILoggerRepository';

View File

@@ -1,5 +1,5 @@
import { Writable } from 'node:stream';
import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/config';
import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum';
export const IMediaRepository = 'IMediaRepository';

View File

@@ -7,7 +7,18 @@ export interface ExifDuration {
Scale?: number;
}
type TagsWithWrongTypes = 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo';
type StringOrNumber = string | number;
type TagsWithWrongTypes =
| 'FocalLength'
| 'Duration'
| 'Description'
| 'ImageDescription'
| 'RegionInfo'
| 'TagsList'
| 'Keywords'
| 'HierarchicalSubject'
| 'ISO';
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
ContentIdentifier?: string;
MotionPhoto?: number;
@@ -20,10 +31,14 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
EmbeddedVideoType?: string;
EmbeddedVideoFile?: BinaryField;
MotionPhotoVideo?: BinaryField;
TagsList?: StringOrNumber[];
HierarchicalSubject?: StringOrNumber[];
Keywords?: StringOrNumber | StringOrNumber[];
ISO?: number | number[];
// Type is wrong, can also be number.
Description?: string | number;
ImageDescription?: string | number;
Description?: StringOrNumber;
ImageDescription?: StringOrNumber;
// Extended properties for image regions, such as faces
RegionInfo?: {

View File

@@ -1,4 +1,5 @@
import { MoveEntity, PathType } from 'src/entities/move.entity';
import { MoveEntity } from 'src/entities/move.entity';
import { PathType } from 'src/enum';
export const IMoveRepository = 'IMoveRepository';

View File

@@ -2,7 +2,7 @@ import { CommandFactory } from 'nest-commander';
import { fork } from 'node:child_process';
import { Worker } from 'node:worker_threads';
import { ImmichAdminModule } from 'src/app.module';
import { LogLevel } from 'src/config';
import { LogLevel } from 'src/enum';
import { getWorkers } from 'src/utils/workers';
const immichApp = process.argv[2] || process.env.IMMICH_APP;

View File

@@ -11,19 +11,11 @@ import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthDto, ImmichQuery } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { MetadataKey, Permission } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UAParser } from 'ua-parser-js';
export enum Metadata {
AUTH_ROUTE = 'auth_route',
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
API_KEY_SECURITY = 'api_key',
ON_EMIT_CONFIG = 'on_emit_config',
}
type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true };
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
@@ -32,8 +24,8 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator =
const decorators: MethodDecorator[] = [
ApiBearerAuth(),
ApiCookieAuth(),
ApiSecurity(Metadata.API_KEY_SECURITY),
SetMetadata(Metadata.AUTH_ROUTE, options || {}),
ApiSecurity(MetadataKey.API_KEY_SECURITY),
SetMetadata(MetadataKey.AUTH_ROUTE, options || {}),
];
if ((options as SharedLinkRoute)?.sharedLink) {
@@ -85,7 +77,7 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(Metadata.AUTH_ROUTE, targets);
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AUTH_ROUTE, targets);
if (!options) {
return true;
}

View File

@@ -7,6 +7,7 @@ import multer, { StorageEngine, diskStorage } from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { Observable } from 'rxjs';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { RouteKey } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService, UploadFile } from 'src/services/asset-media.service';
@@ -28,11 +29,6 @@ export function getFiles(files: UploadFiles) {
};
}
export enum Route {
ASSET = 'assets',
USER = 'users',
}
export interface ImmichFile extends Express.Multer.File {
/** sha1 hash of file */
uuid: string;
@@ -115,7 +111,7 @@ export class FileUploadInterceptor implements NestInterceptor {
const context_ = context.switchToHttp();
const route = this.reflect.get<string>(PATH_METADATA, context.getClass());
const handler: RequestHandler | null = this.getHandler(route as Route);
const handler: RequestHandler | null = this.getHandler(route as RouteKey);
if (handler) {
await new Promise<void>((resolve, reject) => {
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
@@ -176,13 +172,13 @@ export class FileUploadInterceptor implements NestInterceptor {
return false;
}
private getHandler(route: Route) {
private getHandler(route: RouteKey) {
switch (route) {
case Route.ASSET: {
case RouteKey.ASSET: {
return this.handlers.assetUpload;
}
case Route.USER: {
case RouteKey.USER: {
return this.handlers.userProfile;
}

View File

@@ -6,7 +6,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, PaginationMode } from 'src/enum';
import {
AssetBuilderOptions,
AssetCreate,
@@ -30,7 +30,7 @@ import {
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface';
import { searchAssetBuilder } from 'src/utils/database';
import { Instrumentation } from 'src/utils/instrumentation';
import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
import {
Brackets,
FindOptionsOrder,

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { getVectorExtension } from 'src/database.config';
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
@Injectable()
export class ConfigRepository implements IConfigRepository {
getEnv(): EnvData {
return {
database: {
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: getVectorExtension(),
},
};
}
}

View File

@@ -5,6 +5,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@@ -39,6 +40,7 @@ import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
@@ -74,6 +76,7 @@ export const repositories = [
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAuditRepository, useClass: AuditRepository },
{ provide: IConfigRepository, useClass: ConfigRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IDatabaseRepository, useClass: DatabaseRepository },
{ provide: IEventRepository, useClass: EventRepository },

View File

@@ -1,7 +1,7 @@
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
import { ClsService } from 'nestjs-cls';
import { LogLevel } from 'src/config';
import { LogLevel } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { LogColor } from 'src/utils/logger';

View File

@@ -5,7 +5,7 @@ import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { Colorspace } from 'src/config';
import { Colorspace } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
IMediaRepository,

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { MoveEntity, PathType } from 'src/entities/move.entity';
import { MoveEntity } from 'src/entities/move.entity';
import { PathType } from 'src/enum';
import { IMoveRepository, MoveCreate } from 'src/interfaces/move.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm';

View File

@@ -6,7 +6,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { PaginationMode, SourceType } from 'src/enum';
import {
AssetFaceId,
DeleteAllFacesOptions,
@@ -19,7 +19,7 @@ import {
UpdateFacesData,
} from 'src/interfaces/person.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
@Instrumentation()

View File

@@ -8,7 +8,7 @@ import { ExifEntity } from 'src/entities/exif.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { AssetType } from 'src/enum';
import { AssetType, PaginationMode } from 'src/enum';
import { DatabaseExtension } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
@@ -23,7 +23,7 @@ import {
} from 'src/interfaces/search.interface';
import { asVector, searchAssetBuilder } from 'src/utils/database';
import { Instrumentation } from 'src/utils/instrumentation';
import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination';
import { Paginated, PaginationResult, paginatedBuilder } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
import { Repository, SelectQueryBuilder } from 'typeorm';

View File

@@ -4,7 +4,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStatus, AssetType, CacheControl } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@@ -12,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetMediaService } from 'src/services/asset-media.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -27,7 +27,7 @@ import {
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, Permission } from 'src/enum';
import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@@ -37,7 +37,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess, requireUploadAccess } from 'src/utils/access';
import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
import { QueryFailedError } from 'typeorm';

View File

@@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import {
AuditDeletesDto,
AuditDeletesResponseDto,
@@ -12,8 +12,15 @@ import {
PathEntityType,
} from 'src/dtos/audit.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity';
import { AssetFileType, DatabaseAction, Permission } from 'src/enum';
import {
AssetFileType,
AssetPathType,
DatabaseAction,
Permission,
PersonPathType,
StorageFolder,
UserPathType,
} from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';

View File

@@ -1,9 +1,9 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Issuer, generators } from 'openid-client';
import { AuthType } from 'src/constants';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';

View File

@@ -12,7 +12,7 @@ import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { SystemConfig } from 'src/config';
import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import {
@@ -31,7 +31,7 @@ import {
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { AuthType, Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';

View File

@@ -1,12 +1,20 @@
import { DatabaseExtension, EXTENSION_NAMES, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import {
DatabaseExtension,
EXTENSION_NAMES,
IDatabaseRepository,
VectorExtension,
} from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DatabaseService } from 'src/services/database.service';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { Mocked } from 'vitest';
describe(DatabaseService.name, () => {
let sut: DatabaseService;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let extensionRange: string;
@@ -16,9 +24,11 @@ describe(DatabaseService.name, () => {
let versionAboveRange: string;
beforeEach(() => {
configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new DatabaseService(databaseMock, loggerMock);
sut = new DatabaseService(configMock, databaseMock, loggerMock);
extensionRange = '0.2.x';
databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange);
@@ -33,11 +43,6 @@ describe(DatabaseService.name, () => {
});
});
afterEach(() => {
delete process.env.DB_SKIP_MIGRATIONS;
delete process.env.DB_VECTOR_EXTENSION;
});
it('should work', () => {
expect(sut).toBeDefined();
});
@@ -50,12 +55,12 @@ describe(DatabaseService.name, () => {
expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1);
});
describe.each([
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
{ extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] },
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => {
process.env.DB_VECTOR_EXTENSION = extensionName;
configMock.getEnv.mockReturnValue({ database: { skipMigrations: false, vectorExtension: extension } });
});
it(`should start up successfully with ${extension}`, async () => {
@@ -236,18 +241,28 @@ describe(DatabaseService.name, () => {
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
});
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
process.env.DB_SKIP_MIGRATIONS = 'true';
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
configMock.getEnv.mockReturnValue({
database: {
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
},
});
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw error if pgvector extension could not be created`, async () => {
process.env.DB_VECTOR_EXTENSION = 'pgvector';
configMock.getEnv.mockReturnValue({
database: {
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,
},
});
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null,
availableVersion: minVersionInRange,

View File

@@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Duration } from 'luxon';
import semver from 'semver';
import { getVectorExtension } from 'src/database.config';
import { OnEmit } from 'src/decorators';
import { IConfigRepository } from 'src/interfaces/config.interface';
import {
DatabaseExtension,
DatabaseLock,
@@ -67,6 +67,7 @@ export class DatabaseService {
private reconnection?: NodeJS.Timeout;
constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
@@ -85,7 +86,8 @@ export class DatabaseService {
}
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
const extension = getVectorExtension();
const envData = this.configRepository.getEnv();
const extension = envData.database.vectorExtension;
const name = EXTENSION_NAMES[extension];
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
@@ -116,7 +118,8 @@ export class DatabaseService {
await this.checkReindexing();
if (process.env.DB_SKIP_MIGRATIONS !== 'true') {
const { database } = this.configRepository.getEnv();
if (!database.skipMigrations) {
await this.databaseRepository.runMigrations();
}
});

View File

@@ -1,5 +1,8 @@
import { Stats } from 'node:fs';
import { ExifEntity } from 'src/entities/exif.entity';
import {
AssetFileType,
AssetType,
AudioCodec,
Colorspace,
ImageFormat,
@@ -7,9 +10,7 @@ import {
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
} from 'src/config';
import { ExifEntity } from 'src/entities/exif.entity';
import { AssetFileType, AssetType } from 'src/enum';
} from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';

View File

@@ -1,21 +1,23 @@
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
import { dirname } from 'node:path';
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import {
AssetFileType,
AssetPathType,
AssetType,
AudioCodec,
Colorspace,
ImageFormat,
StorageFolder,
TranscodeHWAccel,
TranscodePolicy,
TranscodeTarget,
VideoCodec,
VideoContainer,
} from 'src/config';
import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/entities/move.entity';
import { AssetFileType, AssetType } from 'src/enum';
} from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {

View File

@@ -316,7 +316,7 @@ describe(MetadataService.name, () => {
it('should handle lists of numbers', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ ISO: [160] as any });
metadataMock.readTags.mockResolvedValue({ ISO: [160] });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
@@ -411,7 +411,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a list with a number', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] });
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] });
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -467,6 +467,17 @@ describe(MetadataService.name, () => {
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
});
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent', 2024] });
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined });
});
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] });

View File

@@ -11,6 +11,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
@@ -236,7 +237,7 @@ export class MetadataService {
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
const exifData = {
const exifData: Partial<ExifEntity> = {
assetId: asset.id,
// dates
@@ -264,7 +265,7 @@ export class MetadataService {
make: exifTags.Make ?? null,
model: exifTags.Model ?? null,
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO),
iso: validate(exifTags.ISO) as number,
exposureTime: exifTags.ExposureTime ?? null,
lensModel: exifTags.LensModel ?? null,
fNumber: validate(exifTags.FNumber),
@@ -395,13 +396,13 @@ export class MetadataService {
}
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
const tags: Array<string | number> = [];
const tags: string[] = [];
if (exifTags.TagsList) {
tags.push(...exifTags.TagsList);
tags.push(...exifTags.TagsList.map(String));
} else if (exifTags.HierarchicalSubject) {
tags.push(
...exifTags.HierarchicalSubject.map((tag) =>
tag
String(tag)
// convert | to /
.replaceAll('/', '<PLACEHOLDER>')
.replaceAll('|', '/')
@@ -413,10 +414,10 @@ export class MetadataService {
if (!Array.isArray(keywords)) {
keywords = [keywords];
}
tags.push(...keywords);
tags.push(...keywords.map(String));
}
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) });
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) });
}

View File

@@ -1,9 +1,8 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Colorspace } from 'src/config';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { SourceType, SystemMetadataKey } from 'src/enum';
import { CacheControl, Colorspace, SourceType, SystemMetadataKey } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@@ -16,7 +15,7 @@ import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.inter
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { PersonService } from 'src/services/person.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub';

View File

@@ -1,5 +1,4 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ImageFormat } from 'src/config';
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
@@ -23,9 +22,16 @@ import {
} from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { PersonPathType } from 'src/entities/move.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, Permission, SourceType, SystemMetadataKey } from 'src/enum';
import {
AssetType,
CacheControl,
ImageFormat,
Permission,
PersonPathType,
SourceType,
SystemMetadataKey,
} from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@@ -51,7 +57,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';

View File

@@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
import { serverVersion } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
@@ -15,7 +15,7 @@ import {
ServerStorageResponseDto,
UsageByUserDto,
} from 'src/dtos/server.dto';
import { SystemMetadataKey } from 'src/enum';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';

View File

@@ -2,7 +2,7 @@ import { Stats } from 'node:fs';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/entities/move.entity';
import { AssetPathType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';

View File

@@ -13,12 +13,11 @@ import {
supportedWeekTokens,
supportedYearTokens,
} from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/entities/move.entity';
import { AssetType } from 'src/enum';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';

View File

@@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import { OnEmit } from 'src/decorators';
import { SystemMetadataKey } from 'src/enum';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';

View File

@@ -1,19 +1,18 @@
import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config';
import {
AudioCodec,
CQMode,
Colorspace,
CQMode,
ImageFormat,
LogLevel,
SystemConfig,
SystemMetadataKey,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
VideoContainer,
defaults,
} from 'src/config';
import { SystemMetadataKey } from 'src/enum';
} from 'src/enum';
import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
import { QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';

View File

@@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import _ from 'lodash';
import { LogLevel, SystemConfig, defaults } from 'src/config';
import { SystemConfig, defaults } from 'src/config';
import {
supportedDayTokens,
supportedHourTokens,
@@ -15,6 +15,7 @@ import {
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit, OnServerEvent } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
import { LogLevel } from 'src/enum';
import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';

View File

@@ -1,6 +1,6 @@
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { UserEntity } from 'src/entities/user.entity';
import { UserMetadataKey } from 'src/enum';
import { CacheControl, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@@ -9,7 +9,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { UserService } from 'src/services/user.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';

View File

@@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes
import { DateTime } from 'luxon';
import { getClientLicensePublicKey, getServerLicensePublicKey } from 'src/config';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
@@ -11,7 +11,7 @@ import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { UserMetadataKey } from 'src/enum';
import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@@ -19,7 +19,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { ImmichFileResponse } from 'src/utils/file';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
@Injectable()

View File

@@ -1,8 +1,8 @@
import { ModuleRef, Reflector } from '@nestjs/core';
import _ from 'lodash';
import { EmitConfig } from 'src/decorators';
import { MetadataKey } from 'src/enum';
import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface';
import { Metadata } from 'src/middleware/auth.guard';
import { services } from 'src/services';
type Item<T extends EmitEvent> = {
@@ -35,7 +35,7 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => {
continue;
}
const options = reflector.get<EmitConfig>(Metadata.ON_EMIT_CONFIG, handler);
const options = reflector.get<EmitConfig>(MetadataKey.ON_EMIT_CONFIG, handler);
if (!options) {
continue;
}

View File

@@ -3,6 +3,7 @@ import { NextFunction, Response } from 'express';
import { access, constants } from 'node:fs/promises';
import { basename, extname, isAbsolute } from 'node:path';
import { promisify } from 'node:util';
import { CacheControl } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ImmichReadStream } from 'src/interfaces/storage.interface';
import { isConnectionAborted } from 'src/utils/misc';
@@ -19,12 +20,6 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string
return getFileNameWithoutExtension(stillName) + extname(motionName);
}
export enum CacheControl {
PRIVATE_WITH_CACHE = 'private_with_cache',
PRIVATE_WITHOUT_CACHE = 'private_without_cache',
NONE = 'none',
}
export class ImmichFileResponse {
public readonly path!: string;
public readonly contentType!: string;

View File

@@ -1,5 +1,5 @@
import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/config';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/enum';
import {
AudioStreamInfo,
BitrateDistribution,

View File

@@ -13,8 +13,8 @@ import path from 'node:path';
import { SystemConfig } from 'src/config';
import { CLIP_MODEL_INFO, isDev, serverVersion } from 'src/constants';
import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto';
import { MetadataKey } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Metadata } from 'src/middleware/auth.guard';
/**
* @returns a list of strings representing the keys of the object in dot notation
@@ -210,7 +210,7 @@ export const useSwagger = (app: INestApplication, force = false) => {
in: 'header',
name: ImmichHeader.API_KEY,
},
Metadata.API_KEY_SECURITY,
MetadataKey.API_KEY_SECURITY,
)
.addServer('/api')
.build();

View File

@@ -1,4 +1,5 @@
import _ from 'lodash';
import { PaginationMode } from 'src/enum';
import { FindManyOptions, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
export interface PaginationOptions {
@@ -6,11 +7,6 @@ export interface PaginationOptions {
skip?: number;
}
export enum PaginationMode {
LIMIT_OFFSET = 'limit-offset',
SKIP_TAKE = 'skip-take',
}
export interface PaginatedBuilderOptions {
take: number;
skip?: number;

View File

@@ -0,0 +1,14 @@
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseExtension } from 'src/interfaces/database.interface';
import { Mocked, vitest } from 'vitest';
export const newConfigRepositoryMock = (): Mocked<IConfigRepository> => {
return {
getEnv: vitest.fn().mockReturnValue({
database: {
skipMigration: false,
vectorExtension: DatabaseExtension.VECTORS,
},
}),
};
};

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.116.0",
"version": "1.116.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.116.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8",
@@ -74,7 +74,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.116.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.116.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",

View File

@@ -6,6 +6,12 @@ interface Options {
onEscape?: () => void;
}
/**
* Calls a function when a click occurs outside of the element, or when the escape key is pressed.
* @param node
* @param options Object containing onOutclick and onEscape functions
* @returns
*/
export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn {
const { onOutclick, onEscape } = options;

View File

@@ -2,6 +2,11 @@ interface Options {
onFocusOut?: (event: FocusEvent) => void;
}
/**
* Calls a function when focus leaves the element.
* @param node
* @param options Object containing onFocusOut function
*/
export function focusOutside(node: HTMLElement, options: Options = {}) {
const { onFocusOut } = options;

View File

@@ -1,3 +1,4 @@
/** Focus the given element when it is mounted. */
export const initInput = (element: HTMLInputElement) => {
element.focus();
};

View File

@@ -13,7 +13,9 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem
type OnSeparateCallback = (element: HTMLElement) => unknown;
type IntersectionObserverActionProperties = {
key?: string;
/** Function to execute when the element leaves the viewport */
onSeparate?: OnSeparateCallback;
/** Function to execute when the element enters the viewport */
onIntersect?: OnIntersectCallback;
root?: Element | Document | null;
@@ -112,6 +114,12 @@ function _intersectionObserver(
};
}
/**
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
* @param element
* @param properties One or multiple configurations for the IntersectionObserver(s)
* @returns
*/
export function intersectionObserver(
element: HTMLElement,
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],

View File

@@ -1,6 +1,11 @@
import { shortcuts } from '$lib/actions/shortcut';
import type { Action } from 'svelte/action';
/**
* Enables keyboard navigation (up and down arrows) for a list of elements.
* @param node Element which listens for keyboard events
* @param container Element containing the list of elements
*/
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
const moveFocus = (direction: 'up' | 'down') => {
const children = Array.from(container?.children);

View File

@@ -10,11 +10,16 @@ export type Shortcut = {
export type ShortcutOptions<T = HTMLElement> = {
shortcut: Shortcut;
/** If true, the event handler will not execute if the event comes from an input field */
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
};
/** Determines whether an event should be ignored. The event will be ignored if:
* - The element dispatching the event is not the same as the element which the event listener is attached to
* - The element dispatching the event is an input field
*/
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) {
return false;
@@ -33,6 +38,7 @@ export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
);
};
/** Bind a single keyboard shortcut to node. */
export const shortcut = <T extends HTMLElement>(
node: T,
option: ShortcutOptions<T>,
@@ -47,6 +53,7 @@ export const shortcut = <T extends HTMLElement>(
};
};
/** Binds multiple keyboard shortcuts to node */
export const shortcuts = <T extends HTMLElement>(
node: T,
options: ShortcutOptions<T>[],

View File

@@ -1,6 +1,11 @@
import { decodeBase64 } from '$lib/utils';
import { thumbHashToRGBA } from 'thumbhash';
/**
* Renders a thumbnail onto a canvas from a base64 encoded hash.
* @param canvas
* @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString)
*/
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
const ctx = canvas.getContext('2d');
if (ctx) {

View File

@@ -67,6 +67,7 @@
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
} = slideshowStore;
let appearsInAlbums: AlbumResponseDto[] = [];
@@ -82,13 +83,14 @@
let numberOfComments: number;
let fullscreenElement: Element;
let unsubscribes: (() => void)[] = [];
let selectedEditType: string = '';
let stack: StackResponseDto | null = null;
let zoomToggle = () => void 0;
let copyImage: () => Promise<void>;
$: isFullScreen = fullscreenElement !== null;
let stack: StackResponseDto | null = null;
const refreshStack = async () => {
if (isSharedLink()) {
return;
@@ -390,11 +392,9 @@
onAction?.(action);
};
let selectedEditType: string = '';
function handleUpdateSelectedEditType(type: string) {
const handleUpdateSelectedEditType = (type: string) => {
selectedEditType = type;
}
};
</script>
<svelte:document bind:fullscreenElement />
@@ -508,6 +508,7 @@
onNextAsset={() => navigateAsset('next')}
on:close={closeViewer}
{sharedLink}
haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition}
/>
{/if}
{:else}

View File

@@ -18,7 +18,7 @@
import SettingDropdown from './shared-components/settings/setting-dropdown.svelte';
import { t } from 'svelte-i18n';
const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore;
const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook, slideshowTransition } = slideshowStore;
export let onClose = () => {};
@@ -65,6 +65,7 @@
}}
/>
<SettingSwitch title={$t('show_progress_bar')} bind:checked={$showProgressBar} />
<SettingSwitch title={$t('show_slideshow_transition')} bind:checked={$slideshowTransition} />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('duration')}

View File

@@ -1144,6 +1144,7 @@
"show_person_options": "Show person options",
"show_progress_bar": "Show Progress Bar",
"show_search_options": "Show search options",
"show_slideshow_transition": "Show slideshow transition",
"show_supporter_badge": "Supporter badge",
"show_supporter_badge_description": "Show a supporter badge",
"shuffle": "Shuffle",

View File

@@ -38,6 +38,7 @@ function createSlideshowStore() {
const showProgressBar = persisted<boolean>('slideshow-show-progressbar', true);
const slideshowDelay = persisted<number>('slideshow-delay', 5, {});
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
return {
restartProgress: {
@@ -67,6 +68,7 @@ function createSlideshowStore() {
slideshowState,
slideshowDelay,
showProgressBar,
slideshowTransition,
};
}