mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 17:25:35 +03:00
Compare commits
5 Commits
v2.4.1
...
release/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47cb72424 | ||
|
|
a17f188e97 | ||
|
|
5b80323326 | ||
|
|
1425b3da6b | ||
|
|
3d2196b0f2 |
176
CHANGELOG.md
Normal file
176
CHANGELOG.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
|
||||||
|
# v2.4.2
|
||||||
|
|
||||||
|
# v2.4.0
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
Welcome to the release `v2.4.0` of Immich. This release focuses on bug fixes, QoL improvements, and polished UI components across mobile and the web. Let's dive right in.
|
||||||
|
|
||||||
|
* Show the owner's name in the shared album
|
||||||
|
* Command palette
|
||||||
|
* Change search type directly in the search bar
|
||||||
|
* Better action button placement in the mobile asset viewer
|
||||||
|
* Notable fix: fix an issue where metadata extraction could fail on high concurrency
|
||||||
|
|
||||||
|
### Show the owner's name in the shared album.
|
||||||
|
|
||||||
|
On the web, in shared albums, you can now toggle an option to display the asset's owner name at the bottom right corner of the thumbnail.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Command palette
|
||||||
|
|
||||||
|
The web app now has an integrated command palette, which can be opened with `ctrl + k` on Windows/Linux or `cmd + k` on macOS. The first iteration of this lets you quickly navigate between administration pages by typing the name of the page you want to go to. It also already supports some common actions when on the respective admin pages, many of which also support shortcuts. Have a look around and check them out!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Change search type directly in the search bar
|
||||||
|
|
||||||
|
You can now click on the pill from the search bar to select a different search type without opening the search filter panel.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
### Better placement of action buttons in the mobile asset viewer
|
||||||
|
|
||||||
|
Previously, to perform a specific action on the asset, you needed first to swipe up to open the detail panel, then swipe all the way to the right and tap the action. It limits the discoverability of some actions. To help resolve that issue, all the action buttons in the detail panel are now moved to the drop-down menu when tapping on the vertical dot icon (or kebab menu), along with some buttons that used to be on the top bar, clearing up space to display more useful information when viewing the asset.
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
### 🫥 Deprecated Changes
|
||||||
|
|
||||||
|
* feat: queues by @jrasm91 in <https://github.com/immich-app/immich/pull/24142>
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
* feat: improve performance: don't sort timeline buckets from server by @midzelis in <https://github.com/immich-app/immich/pull/24032>
|
||||||
|
* feat: command palette by @danieldietzler in <https://github.com/immich-app/immich/pull/23693>
|
||||||
|
* feat(web): Shared album owner labels by @xCJPECKOVERx and @idubnori in <https://github.com/immich-app/immich/pull/21171>
|
||||||
|
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in <https://github.com/immich-app/immich/pull/22133>
|
||||||
|
* feat: queue detail page by @jrasm91 in <https://github.com/immich-app/immich/pull/24352>
|
||||||
|
* chore(mobile): add kebabu menu in asset viewer by @idubnori in <https://github.com/immich-app/immich/pull/24387>
|
||||||
|
* feat(mobile): create new album from add to modal by @YarosMallorca in <https://github.com/immich-app/immich/pull/24431>
|
||||||
|
* feat(mobile): move buttons in the bottom sheet to the kebabu menu by @idubnori in <https://github.com/immich-app/immich/pull/24175>
|
||||||
|
|
||||||
|
### 🌟 Enhancements
|
||||||
|
|
||||||
|
* feat(web): allow navigating the map with arrow keys by @lukashass in <https://github.com/immich-app/immich/pull/24080>
|
||||||
|
* feat: separate camera and lens info in detail panel by @fabianbees in <https://github.com/immich-app/immich/pull/23670>
|
||||||
|
* feat(web): shared link card tweaks by @jrasm91 in <https://github.com/immich-app/immich/pull/24192>
|
||||||
|
* feat(server): exclude syncthing folders from external libraries by @SaphuA in <https://github.com/immich-app/immich/pull/24240>
|
||||||
|
* feat(web): search type selection dropdown by @YarosMallorca in <https://github.com/immich-app/immich/pull/24091>
|
||||||
|
* feat: header context menu by @jrasm91 in <https://github.com/immich-app/immich/pull/24374>
|
||||||
|
* feat(mobile): move top bar buttons into kebabu menu in AssetViewer by @idubnori in <https://github.com/immich-app/immich/pull/24461>
|
||||||
|
* feat(web): asset selection bar in tags view by @YarosMallorca in <https://github.com/immich-app/immich/pull/24522>
|
||||||
|
* feat(web): slideshow feature on shared albums by @YarosMallorca in <https://github.com/immich-app/immich/pull/24598>
|
||||||
|
* feat: replace heart icons to thumbs-up across activity by @idubnori in <https://github.com/immich-app/immich/pull/24590>
|
||||||
|
|
||||||
|
### 🐛 Bug fixes
|
||||||
|
|
||||||
|
* fix: effect loop by @jrasm91 in <https://github.com/immich-app/immich/pull/24014>
|
||||||
|
* fix: do not clear hash on updated_at change by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24039>
|
||||||
|
* fix: disable animation "add to" action menu by @bwees in <https://github.com/immich-app/immich/pull/24040>
|
||||||
|
* fix: Use correct app store link by @Mraedis in <https://github.com/immich-app/immich/pull/24062>
|
||||||
|
* fix: show archived assets in favorite page by @bwees in <https://github.com/immich-app/immich/pull/24052>
|
||||||
|
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in <https://github.com/immich-app/immich/pull/23906>
|
||||||
|
* feat(web): show detected faces in spherical photos by @meesfrensel in <https://github.com/immich-app/immich/pull/23974>
|
||||||
|
* fix: add users to album by @danieldietzler in <https://github.com/immich-app/immich/pull/24133>
|
||||||
|
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in <https://github.com/immich-app/immich/pull/23333>
|
||||||
|
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24137>
|
||||||
|
* fix: albums page reactivity loops by @danieldietzler in <https://github.com/immich-app/immich/pull/24046>
|
||||||
|
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24131>
|
||||||
|
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in <https://github.com/immich-app/immich/pull/24018>
|
||||||
|
* fix: don't get OCR data in shared link by @alextran1502 in <https://github.com/immich-app/immich/pull/24152>
|
||||||
|
* fix: duration extraction by @jrasm91 in <https://github.com/immich-app/immich/pull/24178>
|
||||||
|
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in <https://github.com/immich-app/immich/pull/24045>
|
||||||
|
* fix: update timeline-manager after archive actions by @midzelis in <https://github.com/immich-app/immich/pull/24010>
|
||||||
|
* fix: theme switcher by @jrasm91 in <https://github.com/immich-app/immich/pull/24209>
|
||||||
|
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in <https://github.com/immich-app/immich/pull/24232>
|
||||||
|
* fix(mobile): enable backup text overflows by @YarosMallorca in <https://github.com/immich-app/immich/pull/24227>
|
||||||
|
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in <https://github.com/immich-app/immich/pull/24189>
|
||||||
|
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in <https://github.com/immich-app/immich/pull/24249>
|
||||||
|
* fix: only generate memory based on users assets by @alextran1502 in <https://github.com/immich-app/immich/pull/24151>
|
||||||
|
* fix(mobile): docs link by @mmomjian in <https://github.com/immich-app/immich/pull/24277>
|
||||||
|
* fix(server): use bigrams for cjk by @mertalev in <https://github.com/immich-app/immich/pull/24285>
|
||||||
|
* fix(ml): do not upscale preview by @mertalev in <https://github.com/immich-app/immich/pull/24322>
|
||||||
|
* fix(web): open onboarding documentation link in new tab by @carbonemys in <https://github.com/immich-app/immich/pull/24289>
|
||||||
|
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in <https://github.com/immich-app/immich/pull/24310>
|
||||||
|
* fix(web): folder view sort oder by @etnoy in <https://github.com/immich-app/immich/pull/24337>
|
||||||
|
* fix(server): do not delete offline assets by @mertalev in <https://github.com/immich-app/immich/pull/24355>
|
||||||
|
* fix: exposure info and better readability by @alextran1502 in <https://github.com/immich-app/immich/pull/24344>
|
||||||
|
* fix: Adjust the zoom level by @jforseth210 in <https://github.com/immich-app/immich/pull/24353>
|
||||||
|
* fix: local full sync on Android on resume by @alextran1502 in <https://github.com/immich-app/immich/pull/24348>
|
||||||
|
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in <https://github.com/immich-app/immich/pull/24372>
|
||||||
|
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24047>
|
||||||
|
* fix(server): update exiftool-vendored to v34 for more robust metadata extraction by @skatsubo in <https://github.com/immich-app/immich/pull/24424>
|
||||||
|
* fix(mobile): cannot create album while name field is focused by @YarosMallorca in <https://github.com/immich-app/immich/pull/24449>
|
||||||
|
* fix(web): \[album table view\] long album title overflows table row by @simonkub in <https://github.com/immich-app/immich/pull/24450>
|
||||||
|
* fix(mobile): fix overflow text in backup card by @YarosMallorca in <https://github.com/immich-app/immich/pull/24448>
|
||||||
|
* fix(mobile): timeline bottom padding on selection by @YarosMallorca in <https://github.com/immich-app/immich/pull/24480>
|
||||||
|
* feat(mobile): Localized backup upload details page by @ArnyminerZ in <https://github.com/immich-app/immich/pull/21136>
|
||||||
|
* fix(mobile): iOS local permission dialog extra whitespace by @kurtmckee in <https://github.com/immich-app/immich/pull/24491>
|
||||||
|
* fix(mobile): versionStatus.message text overflow by @idubnori in <https://github.com/immich-app/immich/pull/24504>
|
||||||
|
* fix(server): prevent metadata extraction failures on large video files by @hubert-taieb in <https://github.com/immich-app/immich/pull/24094>
|
||||||
|
* fix(web): show inferred timezone in date editor by @skatsubo in <https://github.com/immich-app/immich/pull/24513>
|
||||||
|
* fix(mobile): local videos with '#' don't play on android by @YarosMallorca in <https://github.com/immich-app/immich/pull/24373>
|
||||||
|
* fix: refresh appear in list after asset is added to a current or new album by @alextran1502 in <https://github.com/immich-app/immich/pull/24523>
|
||||||
|
* fix(mobile): birthday off by one day on remote by @YarosMallorca in <https://github.com/immich-app/immich/pull/24527>
|
||||||
|
* fix(web): download panel being hidden by admin sidebar by @diogotcorreia in <https://github.com/immich-app/immich/pull/24583>
|
||||||
|
* fix(web): recent search doesn't use search type by @YarosMallorca in <https://github.com/immich-app/immich/pull/24578>
|
||||||
|
* fix(server): only extract image's duration if format supports animation by @meesfrensel in <https://github.com/immich-app/immich/pull/24587>
|
||||||
|
* fix(mobile): local delete missing from sheet on some routes by @YarosMallorca in <https://github.com/immich-app/immich/pull/24505>
|
||||||
|
* fix(mobile): better UI for metadata panel by @kao-byte in <https://github.com/immich-app/immich/pull/24428>
|
||||||
|
* fix: shared link expiration and small styling by @alextran1502 in <https://github.com/immich-app/immich/pull/24566>
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in <https://github.com/immich-app/immich/pull/24215>
|
||||||
|
* fix(docs): build `cli` for e2e tests by @roschaefer in <https://github.com/immich-app/immich/pull/24184>
|
||||||
|
* docs(faq): add more info on archiving by @etnoy in <https://github.com/immich-app/immich/pull/24326>
|
||||||
|
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in <https://github.com/immich-app/immich/pull/24335>
|
||||||
|
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in <https://github.com/immich-app/immich/pull/24351>
|
||||||
|
* fix(docs): obsolete docs about rootless docker by @roschaefer in <https://github.com/immich-app/immich/pull/24376>
|
||||||
|
* fix(docs): websockets in nginx example by @fourthwall in <https://github.com/immich-app/immich/pull/24411>
|
||||||
|
* fix(docs): slow upload speed with example nginx reverse proxy config by @goalie2002 in <https://github.com/immich-app/immich/pull/24490>
|
||||||
|
* fix(docs): typo in maintenance mode command by @bartvanvelden in <https://github.com/immich-app/immich/pull/24518>
|
||||||
|
|
||||||
|
### 🌐 Translations
|
||||||
|
|
||||||
|
* chore: add new language requests by @danieldietzler in <https://github.com/immich-app/immich/pull/23991>
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
|
||||||
|
* @ujjwal123123 made their first contribution in <https://github.com/immich-app/immich/pull/24101>
|
||||||
|
* @lutostag made their first contribution in <https://github.com/immich-app/immich/pull/23333>
|
||||||
|
* @LukaPrebil made their first contribution in <https://github.com/immich-app/immich/pull/24045>
|
||||||
|
* @kimsey0 made their first contribution in <https://github.com/immich-app/immich/pull/24232>
|
||||||
|
* @SaphuA made their first contribution in <https://github.com/immich-app/immich/pull/24240>
|
||||||
|
* @dionysius made their first contribution in <https://github.com/immich-app/immich/pull/24215>
|
||||||
|
* @NiklasvonM made their first contribution in <https://github.com/immich-app/immich/pull/24249>
|
||||||
|
* @kao-byte made their first contribution in <https://github.com/immich-app/immich/pull/24098>
|
||||||
|
* @carbonemys made their first contribution in <https://github.com/immich-app/immich/pull/24289>
|
||||||
|
* @kiloomar made their first contribution in <https://github.com/immich-app/immich/pull/24372>
|
||||||
|
* @fourthwall made their first contribution in <https://github.com/immich-app/immich/pull/24411>
|
||||||
|
* @simonkub made their first contribution in <https://github.com/immich-app/immich/pull/24450>
|
||||||
|
* @ArnyminerZ made their first contribution in <https://github.com/immich-app/immich/pull/21136>
|
||||||
|
* @kurtmckee made their first contribution in <https://github.com/immich-app/immich/pull/24491>
|
||||||
|
* @hubert-taieb made their first contribution in <https://github.com/immich-app/immich/pull/24094>
|
||||||
|
* @bartvanvelden made their first contribution in <https://github.com/immich-app/immich/pull/24518>
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0>
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### 🐛 Bug fixes
|
||||||
|
* fix(maintenance): prevent enable/disable maintenance CLI hanging on occasion by @insertish in https://github.com/immich-app/immich/pull/24713
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.4.1...v2.4.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.105",
|
"version": "2.2.106",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v2.4.2",
|
||||||
|
"url": "https://docs.v2.4.2.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v2.4.1",
|
"label": "v2.4.1",
|
||||||
"url": "https://docs.v2.4.1.archive.immich.app"
|
"url": "https://docs.v2.4.1.archive.immich.app"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "2.4.1",
|
"version": "2.4.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "immich-ml"
|
name = "immich-ml"
|
||||||
version = "2.4.1"
|
version = "2.4.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||||
requires-python = ">=3.10,<4.0"
|
requires-python = ">=3.10,<4.0"
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 3030,
|
"android.injected.version.code" => 3031,
|
||||||
"android.injected.version.name" => "2.4.1",
|
"android.injected.version.name" => "2.4.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')
|
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')
|
||||||
|
|||||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 2.4.1
|
- API version: 2.4.2
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.4.1+3030
|
version: 2.4.2+3031
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
|
|||||||
@@ -14268,7 +14268,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "2.4.1",
|
"version": "2.4.2",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "2.4.1",
|
"version": "2.4.2",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 2.4.1
|
* 2.4.2
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "2.4.1",
|
"version": "2.4.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
|
|||||||
|
|
||||||
afterInit(websocketServer: Server) {
|
afterInit(websocketServer: Server) {
|
||||||
this.logger.log('Initialized websocket server');
|
this.logger.log('Initialized websocket server');
|
||||||
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
|
|
||||||
|
websocketServer.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
|
||||||
|
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
||||||
|
|
||||||
|
ack?.('ok');
|
||||||
|
this.appRepository.exitApp();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { Server as SocketIO } from 'socket.io';
|
||||||
import { ExitCode } from 'src/enum';
|
import { ExitCode } from 'src/enum';
|
||||||
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppRepository {
|
export class AppRepository {
|
||||||
@@ -17,4 +22,26 @@ export class AppRepository {
|
|||||||
setCloseFn(fn: () => Promise<void>) {
|
setCloseFn(fn: () => Promise<void>) {
|
||||||
this.closeFn = fn;
|
this.closeFn = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
|
||||||
|
const server = new SocketIO();
|
||||||
|
const { redis } = new ConfigRepository().getEnv();
|
||||||
|
const pubClient = new Redis({ ...redis, lazyConnect: true });
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
|
||||||
|
await Promise.all([pubClient.connect(), subClient.connect()]);
|
||||||
|
|
||||||
|
server.adapter(createAdapter(pubClient, subClient));
|
||||||
|
|
||||||
|
// => corresponds to notification.service.ts#onAppRestart
|
||||||
|
server.emit('AppRestartV1', state, async () => {
|
||||||
|
const responses = await server.serverSideEmitWithAck('AppRestart', state);
|
||||||
|
if (responses.some((response) => response !== 'ok')) {
|
||||||
|
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
|
||||||
|
}
|
||||||
|
|
||||||
|
pubClient.disconnect();
|
||||||
|
subClient.disconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ describe(CliService.name, () => {
|
|||||||
alreadyDisabled: true,
|
alreadyDisabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
@@ -99,6 +100,7 @@ describe(CliService.name, () => {
|
|||||||
alreadyDisabled: false,
|
alreadyDisabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
});
|
});
|
||||||
@@ -114,6 +116,7 @@ describe(CliService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
@@ -126,6 +129,7 @@ describe(CliService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
isMaintenanceMode: true,
|
isMaintenanceMode: true,
|
||||||
secret: expect.stringMatching(/^\w{128}$/),
|
secret: expect.stringMatching(/^\w{128}$/),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
|||||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -55,8 +55,7 @@ export class CliService extends BaseService {
|
|||||||
|
|
||||||
const state = { isMaintenanceMode: false as const };
|
const state = { isMaintenanceMode: false as const };
|
||||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
||||||
|
await this.appRepository.sendOneShotAppRestart(state);
|
||||||
sendOneShotAppRestart(state);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alreadyDisabled: false,
|
alreadyDisabled: false,
|
||||||
@@ -89,7 +88,7 @@ export class CliService extends BaseService {
|
|||||||
secret,
|
secret,
|
||||||
});
|
});
|
||||||
|
|
||||||
sendOneShotAppRestart({
|
await this.appRepository.sendOneShotAppRestart({
|
||||||
isMaintenanceMode: true,
|
isMaintenanceMode: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { MaintenanceModeState } from 'src/types';
|
import { MaintenanceModeState } from 'src/types';
|
||||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
||||||
@@ -31,7 +32,10 @@ export class MaintenanceService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'AppRestart', server: true })
|
@OnEvent({ name: 'AppRestart', server: true })
|
||||||
onRestart(): void {
|
onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
|
||||||
|
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
||||||
|
|
||||||
|
ack?.('ok');
|
||||||
this.appRepository.exitApp();
|
this.appRepository.exitApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,6 @@
|
|||||||
import { createAdapter } from '@socket.io/redis-adapter';
|
|
||||||
import Redis from 'ioredis';
|
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Server as SocketIO } from 'socket.io';
|
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
|
||||||
|
|
||||||
export function sendOneShotAppRestart(state: AppRestartEvent): void {
|
|
||||||
const server = new SocketIO();
|
|
||||||
const { redis } = new ConfigRepository().getEnv();
|
|
||||||
const pubClient = new Redis(redis);
|
|
||||||
const subClient = pubClient.duplicate();
|
|
||||||
server.adapter(createAdapter(pubClient, subClient));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep trying until we manage to stop Immich
|
|
||||||
*
|
|
||||||
* Sometimes there appear to be communication
|
|
||||||
* issues between to the other servers.
|
|
||||||
*
|
|
||||||
* This issue only occurs with this method.
|
|
||||||
*/
|
|
||||||
async function tryTerminate() {
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
const responses = await server.serverSideEmitWithAck('AppRestart', state);
|
|
||||||
if (responses.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
console.error('Encountered an error while telling Immich to stop.');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(
|
|
||||||
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 1e3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// => corresponds to notification.service.ts#onAppRestart
|
|
||||||
server.emit('AppRestartV1', state, () => {
|
|
||||||
void tryTerminate().finally(() => {
|
|
||||||
pubClient.disconnect();
|
|
||||||
subClient.disconnect();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createMaintenanceLoginUrl(
|
export async function createMaintenanceLoginUrl(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
const { ctx } = newMediumService(BaseService, {
|
||||||
|
database: db || defaultDatabase,
|
||||||
|
real: [],
|
||||||
|
mock: [LoggingRepository],
|
||||||
|
});
|
||||||
|
return { ctx, sut: ctx.get(AssetRepository) };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
defaultDatabase = await getKyselyDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(AssetRepository.name, () => {
|
||||||
|
describe('upsertExif', () => {
|
||||||
|
it('should append to locked columns', async () => {
|
||||||
|
const { ctx, sut } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
await ctx.newExif({
|
||||||
|
assetId: asset.id,
|
||||||
|
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||||
|
lockedProperties: ['dateTimeOriginal'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_exif')
|
||||||
|
.select('lockedProperties')
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
||||||
|
|
||||||
|
await sut.upsertExif(
|
||||||
|
{ assetId: asset.id, lockedProperties: ['description'] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_exif')
|
||||||
|
.select('lockedProperties')
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate locked columns', async () => {
|
||||||
|
const { ctx, sut } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
await ctx.newExif({
|
||||||
|
assetId: asset.id,
|
||||||
|
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||||
|
lockedProperties: ['dateTimeOriginal', 'description'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_exif')
|
||||||
|
.select('lockedProperties')
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
|
||||||
|
|
||||||
|
await sut.upsertExif(
|
||||||
|
{ assetId: asset.id, lockedProperties: ['description'] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_exif')
|
||||||
|
.select('lockedProperties')
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -270,13 +270,13 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('should update dateTimeOriginal', async () => {
|
it('should automatically lock lockable columns', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const auth = factory.auth({ user });
|
const auth = factory.auth({ user });
|
||||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ctx.database
|
ctx.database
|
||||||
@@ -285,7 +285,14 @@ describe(AssetService.name, () => {
|
|||||||
.where('assetId', '=', asset.id)
|
.where('assetId', '=', asset.id)
|
||||||
.executeTakeFirstOrThrow(),
|
.executeTakeFirstOrThrow(),
|
||||||
).resolves.toEqual({ lockedProperties: null });
|
).resolves.toEqual({ lockedProperties: null });
|
||||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00' });
|
|
||||||
|
await sut.update(auth, asset.id, {
|
||||||
|
latitude: 42,
|
||||||
|
longitude: 42,
|
||||||
|
rating: 3,
|
||||||
|
description: 'foo',
|
||||||
|
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ctx.database
|
ctx.database
|
||||||
@@ -293,7 +300,21 @@ describe(AssetService.name, () => {
|
|||||||
.select('lockedProperties')
|
.select('lockedProperties')
|
||||||
.where('assetId', '=', asset.id)
|
.where('assetId', '=', asset.id)
|
||||||
.executeTakeFirstOrThrow(),
|
.executeTakeFirstOrThrow(),
|
||||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
).resolves.toEqual({
|
||||||
|
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update dateTimeOriginal', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||||
|
|
||||||
|
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||||
|
|
||||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
||||||
@@ -309,22 +330,8 @@ describe(AssetService.name, () => {
|
|||||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||||
|
|
||||||
await expect(
|
|
||||||
ctx.database
|
|
||||||
.selectFrom('asset_exif')
|
|
||||||
.select('lockedProperties')
|
|
||||||
.where('assetId', '=', asset.id)
|
|
||||||
.executeTakeFirstOrThrow(),
|
|
||||||
).resolves.toEqual({ lockedProperties: null });
|
|
||||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||||
|
|
||||||
await expect(
|
|
||||||
ctx.database
|
|
||||||
.selectFrom('asset_exif')
|
|
||||||
.select('lockedProperties')
|
|
||||||
.where('assetId', '=', asset.id)
|
|
||||||
.executeTakeFirstOrThrow(),
|
|
||||||
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
|
|
||||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
||||||
@@ -334,6 +341,42 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('updateAll', () => {
|
describe('updateAll', () => {
|
||||||
|
it('should automatically lock lockable columns', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_exif')
|
||||||
|
.select('lockedProperties')
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({ lockedProperties: null });
|
||||||
|
|
||||||
|
await sut.updateAll(auth, {
|
||||||
|
ids: [asset.id],
|
||||||
|
latitude: 42,
|
||||||
|
description: 'foo',
|
||||||
|
longitude: 42,
|
||||||
|
rating: 3,
|
||||||
|
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_exif')
|
||||||
|
.select('lockedProperties')
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({
|
||||||
|
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should relatively update assets', async () => {
|
it('should relatively update assets', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||||
@@ -344,13 +387,6 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 });
|
await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 });
|
||||||
|
|
||||||
await expect(
|
|
||||||
ctx.database
|
|
||||||
.selectFrom('asset_exif')
|
|
||||||
.select('lockedProperties')
|
|
||||||
.where('assetId', '=', asset.id)
|
|
||||||
.executeTakeFirstOrThrow(),
|
|
||||||
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
|
|
||||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
exifInfo: expect.objectContaining({
|
exifInfo: expect.objectContaining({
|
||||||
@@ -359,66 +395,39 @@ describe(AssetService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('should update dateTimeOriginal', async () => {
|
it('should update dateTimeOriginal', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const auth = factory.auth({ user });
|
const auth = factory.auth({ user });
|
||||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||||
|
|
||||||
await expect(
|
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||||
ctx.database
|
|
||||||
.selectFrom('asset_exif')
|
|
||||||
.select('lockedProperties')
|
|
||||||
.where('assetId', '=', asset.id)
|
|
||||||
.executeTakeFirstOrThrow(),
|
|
||||||
).resolves.toEqual({ lockedProperties: null });
|
|
||||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00' });
|
|
||||||
|
|
||||||
await expect(
|
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||||
ctx.database
|
expect.objectContaining({
|
||||||
.selectFrom('asset_exif')
|
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
||||||
.select('lockedProperties')
|
}),
|
||||||
.where('assetId', '=', asset.id)
|
);
|
||||||
.executeTakeFirstOrThrow(),
|
});
|
||||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
|
||||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update dateTimeOriginal with time zone', async () => {
|
it('should update dateTimeOriginal with time zone', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const auth = factory.auth({ user });
|
const auth = factory.auth({ user });
|
||||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||||
|
|
||||||
await expect(
|
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||||
ctx.database
|
|
||||||
.selectFrom('asset_exif')
|
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||||
.select('lockedProperties')
|
expect.objectContaining({
|
||||||
.where('assetId', '=', asset.id)
|
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
||||||
.executeTakeFirstOrThrow(),
|
}),
|
||||||
).resolves.toEqual({ lockedProperties: null });
|
);
|
||||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
});
|
||||||
await expect(
|
|
||||||
ctx.database
|
|
||||||
.selectFrom('asset_exif')
|
|
||||||
.select('lockedProperties')
|
|
||||||
.where('assetId', '=', asset.id)
|
|
||||||
.executeTakeFirstOrThrow(),
|
|
||||||
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
|
|
||||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "2.4.1",
|
"version": "2.4.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
33
web/src/lib/components/AdminCard.svelte
Normal file
33
web/src/lib/components/AdminCard.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle, Icon, type ActionItem, type IconLike } from '@immich/ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: IconLike;
|
||||||
|
title: string;
|
||||||
|
headerAction?: ActionItem;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon, title, headerAction, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card color="secondary">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||||
|
<div class="flex gap-2 text-primary">
|
||||||
|
<Icon {icon} size="1.5rem" />
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
{#if headerAction}
|
||||||
|
<HeaderActionButton action={headerAction} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div class="px-4 pb-7">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
44
web/src/lib/modals/LibraryCreateModal.svelte
Normal file
44
web/src/lib/modals/LibraryCreateModal.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
|
import { handleCreateLibrary } from '$lib/services/library.service';
|
||||||
|
import { user } from '$lib/stores/user.store';
|
||||||
|
import { searchUsersAdmin } from '@immich/sdk';
|
||||||
|
import { FormModal, Text } from '@immich/ui';
|
||||||
|
import { mdiFolderSync } from '@mdi/js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { onClose }: Props = $props();
|
||||||
|
|
||||||
|
let ownerId: string = $state($user.id);
|
||||||
|
|
||||||
|
let userOptions: { value: string; text: string }[] = $state([]);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const users = await searchUsersAdmin({});
|
||||||
|
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const success = await handleCreateLibrary({ ownerId });
|
||||||
|
if (success) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
title={$t('create_library')}
|
||||||
|
icon={mdiFolderSync}
|
||||||
|
{onClose}
|
||||||
|
size="small"
|
||||||
|
{onSubmit}
|
||||||
|
submitText={$t('create')}
|
||||||
|
>
|
||||||
|
<SettingSelect label={$t('owner')} bind:value={ownerId} options={userOptions} name="user" />
|
||||||
|
<Text color="warning" size="small">{$t('admin.note_cannot_be_changed_later')}</Text>
|
||||||
|
</FormModal>
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
|
||||||
import { user } from '$lib/stores/user.store';
|
|
||||||
import { searchUsersAdmin } from '@immich/sdk';
|
|
||||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
|
||||||
import { mdiFolderSync } from '@mdi/js';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClose: (ownerId?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onClose }: Props = $props();
|
|
||||||
|
|
||||||
let ownerId: string = $state($user.id);
|
|
||||||
|
|
||||||
let userOptions: { value: string; text: string }[] = $state([]);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const users = await searchUsersAdmin({});
|
|
||||||
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
|
||||||
});
|
|
||||||
|
|
||||||
const onsubmit = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onClose(ownerId);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal title={$t('select_library_owner')} icon={mdiFolderSync} {onClose} size="small">
|
|
||||||
<ModalBody>
|
|
||||||
<form {onsubmit} autocomplete="off" id="select-library-owner-form">
|
|
||||||
<p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p>
|
|
||||||
|
|
||||||
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
|
|
||||||
</form>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
<HStack fullWidth>
|
|
||||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
|
||||||
<Button shape="round" type="submit" fullWidth form="select-library-owner-form">{$t('create')}</Button>
|
|
||||||
</HStack>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import LibraryCreateModal from '$lib/modals/LibraryCreateModal.svelte';
|
||||||
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
|
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
|
||||||
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
|
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
|
||||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||||
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
|
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
|
||||||
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
|
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
|
||||||
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
runQueueCommandLegacy,
|
runQueueCommandLegacy,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
updateLibrary,
|
updateLibrary,
|
||||||
|
type CreateLibraryDto,
|
||||||
type LibraryResponseDto,
|
type LibraryResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
@@ -37,7 +38,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
|||||||
title: $t('create_library'),
|
title: $t('create_library'),
|
||||||
type: $t('command'),
|
type: $t('command'),
|
||||||
icon: mdiPlusBoxOutline,
|
icon: mdiPlusBoxOutline,
|
||||||
onAction: () => handleCreateLibrary(),
|
onAction: () => handleShowLibraryCreateModal(),
|
||||||
shortcuts: { shift: true, key: 'n' },
|
shortcuts: { shift: true, key: 'n' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,20 +153,17 @@ export const handleViewLibrary = async (library: LibraryResponseDto) => {
|
|||||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleCreateLibrary = async () => {
|
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
const ownerId = await modalManager.show(LibraryUserPickerModal, {});
|
|
||||||
if (!ownerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
|
const library = await createLibrary({ createLibraryDto: dto });
|
||||||
eventManager.emit('LibraryCreate', createdLibrary);
|
eventManager.emit('LibraryCreate', library);
|
||||||
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
|
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_create_library'));
|
handleError(error, $t('errors.unable_to_create_library'));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,3 +357,7 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
|||||||
handleError(error, $t('errors.unable_to_update_library'));
|
handleError(error, $t('errors.unable_to_update_library'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleShowLibraryCreateModal = async () => {
|
||||||
|
await modalManager.show(LibraryCreateModal, {});
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { getLibrariesActions, handleCreateLibrary, handleViewLibrary } from '$lib/services/library.service';
|
import { getLibrariesActions, handleShowLibraryCreateModal, handleViewLibrary } from '$lib/services/library.service';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
import { getLibrary, getLibraryStatistics, getUserAdmin, type LibraryResponseDto } from '@immich/sdk';
|
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||||
import { Button, CommandPaletteContext } from '@immich/ui';
|
import { Button, CommandPaletteContext } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
@@ -23,15 +23,11 @@
|
|||||||
let statistics = $state(data.statistics);
|
let statistics = $state(data.statistics);
|
||||||
let owners = $state(data.owners);
|
let owners = $state(data.owners);
|
||||||
|
|
||||||
const handleLibraryAdd = async (library: LibraryResponseDto) => {
|
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
||||||
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
|
||||||
owners[library.id] = await getUserAdmin({ id: library.ownerId });
|
|
||||||
libraries.push(library);
|
|
||||||
|
|
||||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLibraryUpdate = async (library: LibraryResponseDto) => {
|
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||||
const index = libraries.findIndex(({ id }) => id === library.id);
|
const index = libraries.findIndex(({ id }) => id === library.id);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -42,7 +38,7 @@
|
|||||||
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLibrary = ({ id }: { id: string }) => {
|
const onLibraryDelete = ({ id }: { id: string }) => {
|
||||||
libraries = libraries.filter((library) => library.id !== id);
|
libraries = libraries.filter((library) => library.id !== id);
|
||||||
delete statistics[id];
|
delete statistics[id];
|
||||||
delete owners[id];
|
delete owners[id];
|
||||||
@@ -51,11 +47,7 @@
|
|||||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<OnEvents
|
<OnEvents {onLibraryCreate} {onLibraryUpdate} {onLibraryDelete} />
|
||||||
onLibraryCreate={handleLibraryAdd}
|
|
||||||
onLibraryUpdate={handleLibraryUpdate}
|
|
||||||
onLibraryDelete={handleDeleteLibrary}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CommandPaletteContext commands={[Create, ScanAll]} />
|
<CommandPaletteContext commands={[Create, ScanAll]} />
|
||||||
|
|
||||||
@@ -106,7 +98,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={handleCreateLibrary} class="mt-10 mx-auto" />
|
<EmptyPlaceholder
|
||||||
|
text={$t('no_libraries_message')}
|
||||||
|
onClick={handleShowLibraryCreateModal}
|
||||||
|
class="mt-10 mx-auto"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
||||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
import AdminCard from '$lib/components/AdminCard.svelte';
|
||||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||||
@@ -15,18 +15,7 @@
|
|||||||
getLibraryFolderActions,
|
getLibraryFolderActions,
|
||||||
} from '$lib/services/library.service';
|
} from '$lib/services/library.service';
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
import {
|
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Code,
|
|
||||||
CommandPaletteContext,
|
|
||||||
Container,
|
|
||||||
Heading,
|
|
||||||
Icon,
|
|
||||||
modalManager,
|
|
||||||
} from '@immich/ui';
|
|
||||||
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -64,77 +53,53 @@
|
|||||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
|
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
|
||||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
|
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
|
||||||
</div>
|
</div>
|
||||||
<Card color="secondary">
|
|
||||||
<CardHeader>
|
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
||||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
{#if library.importPaths.length === 0}
|
||||||
<div class="flex gap-2 text-primary">
|
<EmptyPlaceholder
|
||||||
<Icon icon={mdiFolderOutline} size="1.5rem" />
|
src={emptyFoldersUrl}
|
||||||
<CardTitle>{$t('folders')}</CardTitle>
|
text={$t('admin.library_folder_description')}
|
||||||
</div>
|
fullWidth
|
||||||
<HeaderActionButton action={AddFolder} />
|
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
|
||||||
</div>
|
/>
|
||||||
</CardHeader>
|
{:else}
|
||||||
<CardBody>
|
<table class="w-full">
|
||||||
<div class="px-4 pb-7">
|
<tbody>
|
||||||
{#if library.importPaths.length === 0}
|
{#each library.importPaths as folder (folder)}
|
||||||
<EmptyPlaceholder
|
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
|
||||||
src={emptyFoldersUrl}
|
<tr class="h-12">
|
||||||
text={$t('admin.library_folder_description')}
|
<td>
|
||||||
fullWidth
|
<Code>{folder}</Code>
|
||||||
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
|
</td>
|
||||||
/>
|
<td class="flex gap-2 justify-end">
|
||||||
{:else}
|
<TableButton action={Edit} />
|
||||||
<table class="w-full">
|
<TableButton action={Delete} />
|
||||||
<tbody>
|
</td>
|
||||||
{#each library.importPaths as folder (folder)}
|
</tr>
|
||||||
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
|
{/each}
|
||||||
<tr class="h-12">
|
</tbody>
|
||||||
<td>
|
</table>
|
||||||
<Code>{folder}</Code>
|
{/if}
|
||||||
</td>
|
</AdminCard>
|
||||||
<td class="flex gap-2 justify-end">
|
|
||||||
<TableButton action={Edit} />
|
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
|
||||||
<TableButton action={Delete} />
|
<table class="w-full">
|
||||||
</td>
|
<tbody>
|
||||||
</tr>
|
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
||||||
{/each}
|
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
|
||||||
</tbody>
|
<tr class="h-12">
|
||||||
</table>
|
<td>
|
||||||
{/if}
|
<Code>{exclusionPattern}</Code>
|
||||||
</div>
|
</td>
|
||||||
</CardBody>
|
<td class="flex gap-2 justify-end">
|
||||||
</Card>
|
<TableButton action={Edit} />
|
||||||
<Card color="secondary">
|
<TableButton action={Delete} />
|
||||||
<CardHeader>
|
</td>
|
||||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
</tr>
|
||||||
<div class="flex gap-2 text-primary">
|
{/each}
|
||||||
<Icon icon={mdiFilterMinusOutline} size="1.5rem" />
|
</tbody>
|
||||||
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
|
</table>
|
||||||
</div>
|
</AdminCard>
|
||||||
<HeaderActionButton action={AddExclusionPattern} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div class="px-4 pb-7">
|
|
||||||
<table class="w-full">
|
|
||||||
<tbody>
|
|
||||||
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
|
||||||
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
|
|
||||||
<tr class="h-12">
|
|
||||||
<td>
|
|
||||||
<Code>{exclusionPattern}</Code>
|
|
||||||
</td>
|
|
||||||
<td class="flex gap-2 justify-end">
|
|
||||||
<TableButton action={Edit} />
|
|
||||||
<TableButton action={Delete} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import AdminCard from '$lib/components/AdminCard.svelte';
|
||||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||||
@@ -15,10 +16,6 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Code,
|
Code,
|
||||||
CommandPaletteContext,
|
CommandPaletteContext,
|
||||||
Container,
|
Container,
|
||||||
@@ -131,128 +128,90 @@
|
|||||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Card color="secondary">
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
|
||||||
<Icon icon={mdiAccountOutline} size="1.5rem" />
|
|
||||||
<CardTitle>{$t('profile')}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div class="px-4 pb-7">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<div>
|
|
||||||
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
|
|
||||||
<Text>{user.name}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
|
|
||||||
<Text>{user.email}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
|
|
||||||
<Text>{userCreatedAtDateAndTime}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
|
|
||||||
<Text>{userUpdatedAtDateAndTime}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
|
|
||||||
<Code>{user.id}</Code>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
<Card color="secondary">
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
|
||||||
<Icon icon={mdiFeatureSearchOutline} size="1.5rem" />
|
|
||||||
<CardTitle>{$t('features')}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div class="px-4 pb-4">
|
|
||||||
<Stack gap={3}>
|
|
||||||
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
|
||||||
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
|
||||||
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
|
|
||||||
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
|
|
||||||
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
|
|
||||||
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
|
|
||||||
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
|
|
||||||
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
|
||||||
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
<Card color="secondary">
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
|
||||||
<Icon icon={mdiChartPieOutline} size="1.5rem" />
|
|
||||||
<CardTitle>{$t('storage_quota')}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div class="px-4 pb-4">
|
|
||||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
|
||||||
<Text>
|
|
||||||
{$t('storage_usage', {
|
|
||||||
values: {
|
|
||||||
used: getByteUnitString(usedBytes, $locale, 3),
|
|
||||||
available: getByteUnitString(availableBytes, $locale, 3),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
{:else}
|
|
||||||
<Text class="flex items-center gap-1">
|
|
||||||
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
|
|
||||||
{$t('unlimited')}
|
|
||||||
</Text>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
|
||||||
<div
|
<Stack gap={2}>
|
||||||
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
|
<div>
|
||||||
title={$t('storage_usage', {
|
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
|
||||||
values: {
|
<Text>{user.name}</Text>
|
||||||
used: getByteUnitString(usedBytes, $locale, 3),
|
</div>
|
||||||
available: getByteUnitString(availableBytes, $locale, 3),
|
<div>
|
||||||
},
|
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
|
||||||
})}
|
<Text>{user.email}</Text>
|
||||||
>
|
</div>
|
||||||
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
<div>
|
||||||
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
|
||||||
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
<Text>{userCreatedAtDateAndTime}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
|
||||||
|
<Text>{userUpdatedAtDateAndTime}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
|
||||||
|
<Code>{user.id}</Code>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
||||||
|
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
||||||
|
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
|
||||||
|
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
|
||||||
|
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
|
||||||
|
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
|
||||||
|
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
|
||||||
|
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
||||||
|
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
||||||
|
</Stack>
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
|
||||||
|
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||||
|
<Text>
|
||||||
|
{$t('storage_usage', {
|
||||||
|
values: {
|
||||||
|
used: getByteUnitString(usedBytes, $locale, 3),
|
||||||
|
available: getByteUnitString(availableBytes, $locale, 3),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{:else}
|
||||||
|
<Text class="flex items-center gap-1">
|
||||||
|
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
|
||||||
|
{$t('unlimited')}
|
||||||
|
</Text>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||||
|
<div
|
||||||
|
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
|
||||||
|
title={$t('storage_usage', {
|
||||||
|
values: {
|
||||||
|
used: getByteUnitString(usedBytes, $locale, 3),
|
||||||
|
available: getByteUnitString(availableBytes, $locale, 3),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
||||||
|
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
<Card color="secondary">
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
|
||||||
<Icon icon={mdiDevices} size="1.5rem" />
|
|
||||||
<CardTitle>{$t('authorized_devices')}</CardTitle>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
{/if}
|
||||||
<CardBody>
|
</AdminCard>
|
||||||
<div class="px-4 pb-7">
|
|
||||||
<Stack gap={3}>
|
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
|
||||||
{#each userSessions as session (session.id)}
|
<Stack gap={3}>
|
||||||
<DeviceCard {session} />
|
{#each userSessions as session (session.id)}
|
||||||
{:else}
|
<DeviceCard {session} />
|
||||||
<span class="text-dark">{$t('no_devices')}</span>
|
{:else}
|
||||||
{/each}
|
<span class="text-dark">{$t('no_devices')}</span>
|
||||||
</Stack>
|
{/each}
|
||||||
</div>
|
</Stack>
|
||||||
</CardBody>
|
</AdminCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user