Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe4c6c6365 | |||
| 63ed6283fc | |||
| 41f138d3c8 | |||
| 6b5defc27b | |||
| 2604940f09 | |||
| 32f908baf1 | |||
| 944ea7dbcd | |||
| 4b5657c21e | |||
| f5c4af73aa | |||
| 24ae4ecff1 | |||
| 64a7baec8c | |||
| caf6c0996d | |||
| 6729782c3f | |||
| a60209db3e | |||
| d1169e3b2f | |||
| df972ef711 | |||
| 33263cf9f3 | |||
| 1b5811d992 | |||
| 1fa0122eda | |||
| d1085e8a02 | |||
| d6a70bc7e5 | |||
| d3fe238eef | |||
| 35f24270fe | |||
| 1f1a4ab1a3 | |||
| 0b3742cf13 | |||
| 9203a61709 | |||
| 11403abfbc | |||
| 5a2af558fb | |||
| de993289ad | |||
| c58bd307ce | |||
| 333ca8827e | |||
| 3dad19883d | |||
| 4ca27a3e7f | |||
| b0bb11f9e0 | |||
| ecb8349085 | |||
| e1feba2198 | |||
| 53a7ac3868 | |||
| f2e950d89c | |||
| 8ba2c99b08 | |||
| 93346496fc | |||
| a9525de356 |
@@ -0,0 +1,2 @@
|
|||||||
|
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:dc2c3654370fe92a55daeefe9d2d95839d85bdc1f68f7fd4ab86621f49e5818a
|
||||||
|
FROM ${BASEIMAGE}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Immich devcontainers",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"args": {
|
||||||
|
"BASEIMAGE": "mcr.microsoft.com/devcontainers/typescript-node:22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"svelte.svelte-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forwardPorts": [],
|
||||||
|
"postCreateCommand": "make install-all",
|
||||||
|
"remoteUser": "node"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: PR Conventional Commit Validation
|
- name: PR Conventional Commit Validation
|
||||||
uses: ytanikin/PRConventionalCommits@1.2.0
|
uses: ytanikin/PRConventionalCommits@1.3.0
|
||||||
with:
|
with:
|
||||||
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
|
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
|
||||||
add_label: 'false'
|
add_label: 'false'
|
||||||
|
|||||||
@@ -41,4 +41,4 @@
|
|||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ attach-server:
|
|||||||
renovate:
|
renovate:
|
||||||
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
||||||
|
|
||||||
MODULES = e2e server web cli sdk
|
MODULES = e2e server web cli sdk docs
|
||||||
|
|
||||||
audit-%:
|
audit-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
||||||
@@ -48,11 +48,9 @@ install-%:
|
|||||||
build-cli: build-sdk
|
build-cli: build-sdk
|
||||||
build-web: build-sdk
|
build-web: build-sdk
|
||||||
build-%: install-%
|
build-%: install-%
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'build' >/dev/null \
|
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
|
||||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build || true
|
|
||||||
format-%:
|
format-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'format:fix' >/dev/null \
|
npm --prefix $* run format:fix
|
||||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run format:fix || true
|
|
||||||
lint-%:
|
lint-%:
|
||||||
npm --prefix $* run lint:fix
|
npm --prefix $* run lint:fix
|
||||||
check-%:
|
check-%:
|
||||||
@@ -79,14 +77,14 @@ test-medium:
|
|||||||
test-medium-dev:
|
test-medium-dev:
|
||||||
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
||||||
|
|
||||||
build-all: $(foreach M,$(MODULES),build-$M) ;
|
build-all: $(foreach M,$(filter-out e2e,$(MODULES)),build-$M) ;
|
||||||
install-all: $(foreach M,$(MODULES),install-$M) ;
|
install-all: $(foreach M,$(MODULES),install-$M) ;
|
||||||
check-all: $(foreach M,$(MODULES),check-$M) ;
|
check-all: $(foreach M,$(filter-out sdk cli docs,$(MODULES)),check-$M) ;
|
||||||
lint-all: $(foreach M,$(MODULES),lint-$M) ;
|
lint-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),lint-$M) ;
|
||||||
format-all: $(foreach M,$(MODULES),format-$M) ;
|
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
|
||||||
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
||||||
hygiene-all: lint-all format-all check-all sql audit-all;
|
hygiene-all: lint-all format-all check-all sql audit-all;
|
||||||
test-all: $(foreach M,$(MODULES),test-$M) ;
|
test-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),test-$M) ;
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
|
AssetBulkUploadCheckItem,
|
||||||
AssetBulkUploadCheckResult,
|
AssetBulkUploadCheckResult,
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetMediaStatus,
|
AssetMediaStatus,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
getSupportedMediaTypes,
|
getSupportedMediaTypes,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import byteSize from 'byte-size';
|
import byteSize from 'byte-size';
|
||||||
import { Presets, SingleBar } from 'cli-progress';
|
import { MultiBar, Presets, SingleBar } from 'cli-progress';
|
||||||
import { chunk } from 'lodash-es';
|
import { chunk } from 'lodash-es';
|
||||||
import { Stats, createReadStream } from 'node:fs';
|
import { Stats, createReadStream } from 'node:fs';
|
||||||
import { stat, unlink } from 'node:fs/promises';
|
import { stat, unlink } from 'node:fs/promises';
|
||||||
@@ -90,23 +91,23 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
return { newFiles: files, duplicates: [] };
|
return { newFiles: files, duplicates: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressBar = new SingleBar(
|
const multiBar = new MultiBar(
|
||||||
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||||
Presets.shades_classic,
|
Presets.shades_classic,
|
||||||
);
|
);
|
||||||
|
|
||||||
progressBar.start(files.length, 0);
|
const hashProgressBar = multiBar.create(files.length, 0, { message: 'Hashing files ' });
|
||||||
|
const checkProgressBar = multiBar.create(files.length, 0, { message: 'Checking for duplicates' });
|
||||||
|
|
||||||
const newFiles: string[] = [];
|
const newFiles: string[] = [];
|
||||||
const duplicates: Asset[] = [];
|
const duplicates: Asset[] = [];
|
||||||
|
|
||||||
const queue = new Queue<string[], AssetBulkUploadCheckResults>(
|
const checkBulkUploadQueue = new Queue<AssetBulkUploadCheckItem[], void>(
|
||||||
async (filepaths: string[]) => {
|
async (assets: AssetBulkUploadCheckItem[]) => {
|
||||||
const dto = await Promise.all(
|
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
|
||||||
filepaths.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })),
|
|
||||||
);
|
|
||||||
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
|
|
||||||
const results = response.results as AssetBulkUploadCheckResults;
|
const results = response.results as AssetBulkUploadCheckResults;
|
||||||
|
|
||||||
for (const { id: filepath, assetId, action } of results) {
|
for (const { id: filepath, assetId, action } of results) {
|
||||||
if (action === Action.Accept) {
|
if (action === Action.Accept) {
|
||||||
newFiles.push(filepath);
|
newFiles.push(filepath);
|
||||||
@@ -115,19 +116,46 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
duplicates.push({ id: assetId as string, filepath });
|
duplicates.push({ id: assetId as string, filepath });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progressBar.increment(filepaths.length);
|
|
||||||
|
checkProgressBar.increment(assets.length);
|
||||||
|
},
|
||||||
|
{ concurrency, retry: 3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: { id: string; checksum: string }[] = [];
|
||||||
|
let checkBulkUploadRequests: AssetBulkUploadCheckItem[] = [];
|
||||||
|
|
||||||
|
const queue = new Queue<string, AssetBulkUploadCheckItem[]>(
|
||||||
|
async (filepath: string): Promise<AssetBulkUploadCheckItem[]> => {
|
||||||
|
const dto = { id: filepath, checksum: await sha1(filepath) };
|
||||||
|
|
||||||
|
results.push(dto);
|
||||||
|
checkBulkUploadRequests.push(dto);
|
||||||
|
if (checkBulkUploadRequests.length === 5000) {
|
||||||
|
const batch = checkBulkUploadRequests;
|
||||||
|
checkBulkUploadRequests = [];
|
||||||
|
void checkBulkUploadQueue.push(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
hashProgressBar.increment();
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
{ concurrency, retry: 3 },
|
{ concurrency, retry: 3 },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const items of chunk(files, concurrency)) {
|
for (const item of files) {
|
||||||
await queue.push(items);
|
void queue.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
await queue.drained();
|
await queue.drained();
|
||||||
|
|
||||||
progressBar.stop();
|
if (checkBulkUploadRequests.length > 0) {
|
||||||
|
void checkBulkUploadQueue.push(checkBulkUploadRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkBulkUploadQueue.drained();
|
||||||
|
|
||||||
|
multiBar.stop();
|
||||||
|
|
||||||
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
|
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
|
||||||
|
|
||||||
@@ -201,8 +229,8 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo
|
|||||||
{ concurrency, retry: 3 },
|
{ concurrency, retry: 3 },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const filepath of files) {
|
for (const item of files) {
|
||||||
await queue.push(filepath);
|
void queue.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
await queue.drained();
|
await queue.drained();
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export class Queue<T, R> {
|
|||||||
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
|
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
|
||||||
* This promise could be ignored as it will not lead to a `unhandledRejection`.
|
* This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||||
*/
|
*/
|
||||||
async drained(): Promise<void> {
|
drained(): Promise<void> {
|
||||||
await this.queue.drain();
|
return this.queue.drained();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -94,7 +94,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:378f4e03703557d1c6419e6caccf922f96e6d88a530f7431d66a4c4f4b1000fe
|
image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ docker compose up -d # Start remainder of Immich apps
|
|||||||
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
|
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
|
||||||
|
|
||||||
```powershell title='Backup'
|
```powershell title='Backup'
|
||||||
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | Set-Content -Encoding utf8 "C:\path\to\backup\dump.sql"
|
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres))
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell title='Restore'
|
```powershell title='Restore'
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# PR Checklist
|
# PR Checklist
|
||||||
|
|
||||||
|
A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
|
||||||
|
:::warning
|
||||||
|
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
|
||||||
|
:::
|
||||||
When contributing code through a pull request, please check the following:
|
When contributing code through a pull request, please check the following:
|
||||||
|
|
||||||
## Web Checks
|
## Web Checks
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ SELECT * FROM "move_history";
|
|||||||
SELECT * FROM "users";
|
SELECT * FROM "users";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```sql title="Get owner info from asset ID"
|
||||||
|
SELECT "users".* FROM "users" JOIN "assets" ON "users"."id" = "assets"."ownerId" WHERE "assets"."id" = 'fa310b01-2f26-4b7a-9042-d578226e021f';
|
||||||
|
```
|
||||||
|
|
||||||
## System Config
|
## System Config
|
||||||
|
|
||||||
```sql title="Custom settings"
|
```sql title="Custom settings"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -7,7 +7,9 @@ sidebar_position: 80
|
|||||||
:::note
|
:::note
|
||||||
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
||||||
|
|
||||||
**Please report issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
|
||||||
|
|
||||||
|
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
|
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
|
||||||
@@ -20,18 +22,26 @@ TrueNAS SCALE makes installing and updating Immich easy, but you must use the Im
|
|||||||
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
|
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, SCALE alerts and provides easy updates.
|
||||||
|
|
||||||
Before installing the Immich app in SCALE, review the [Environment Variables](/docs/install/environment-variables.md) documentation to see if you want to configure any during installation.
|
Before installing the Immich app in SCALE, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
|
||||||
You can configure environment variables at any time after deploying the application.
|
You may also configure environment variables at any time after deploying the application.
|
||||||
|
|
||||||
You can allow SCALE to create the datasets Immich requires automatically during app installation.
|
### Setting up Storage Datasets
|
||||||
Or before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
|
|
||||||
Immich requires seven datasets: **library**, **pgBackup**, **pgData**, **profile**, **thumbs**, **uploads**, and **video**.
|
Before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
|
||||||
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
|
Immich requires seven datasets: `library`, `upload`, `thumbs`, `profile`, `video`, `backups`, and `pgData`.
|
||||||
|
You can organize these as one parent with seven child datasets, for example `/mnt/tank/immich/library`, `/mnt/tank/immich/upload`, and so on.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas12.png').default}
|
||||||
|
width="30%"
|
||||||
|
alt="Immich App Widget"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
:::info Permissions
|
:::info Permissions
|
||||||
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
||||||
|
|
||||||
The **library** dataset must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command.
|
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Installing the Immich Application
|
## Installing the Immich Application
|
||||||
@@ -47,6 +57,8 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Click on the widget to open the **Immich** application details screen.
|
Click on the widget to open the **Immich** application details screen.
|
||||||
|
|
||||||
|
<br/><br/>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={require('./img/truenas02.png').default}
|
src={require('./img/truenas02.png').default}
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -56,9 +68,13 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Click **Install** to open the Immich application configuration screen.
|
Click **Install** to open the Immich application configuration screen.
|
||||||
|
|
||||||
|
<br/><br/>
|
||||||
|
|
||||||
Application configuration settings are presented in several sections, each explained below.
|
Application configuration settings are presented in several sections, each explained below.
|
||||||
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
|
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
|
||||||
|
|
||||||
|
### Application Name and Version
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={require('./img/truenas03.png').default}
|
src={require('./img/truenas03.png').default}
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -66,21 +82,123 @@ alt="Install Immich Screen"
|
|||||||
className="border rounded-xl"
|
className="border rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
Accept the default values in **Application Name** and **Version**.
|
Accept the default value or enter a name in **Application Name** field.
|
||||||
|
In most cases use the default name, but if adding a second deployment of the application you must change this name.
|
||||||
|
|
||||||
|
Accept the default version number in **Version**.
|
||||||
|
When a new version becomes available, the application has an update badge.
|
||||||
|
The **Installed Applications** screen shows the option to update applications.
|
||||||
|
|
||||||
|
### Immich Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas05.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configuration Settings"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
Accept the default value in **Timezone** or change to match your local timezone.
|
Accept the default value in **Timezone** or change to match your local timezone.
|
||||||
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
||||||
|
|
||||||
Accept the default port in **Web Port**.
|
Untick **Enable Machine Learning** if you will not use face recognition, image search, and smart duplicate detection.
|
||||||
|
|
||||||
|
Accept the default option or select the **Machine Learning Image Type** for your hardware based on the [Hardware-Accelerated Machine Learning Supported Backends](/docs/features/ml-hardware-acceleration.md#supported-backends).
|
||||||
|
|
||||||
|
Immich's default is `postgres` but you should consider setting the **Database Password** to a custom value using only the characters `A-Za-z0-9`.
|
||||||
|
|
||||||
|
The **Redis Password** should be set to a custom value using only the characters `A-Za-z0-9`.
|
||||||
|
|
||||||
|
Accept the **Log Level** default of **Log**.
|
||||||
|
|
||||||
|
Leave **Hugging Face Endpoint** blank. (This is for downloading ML models from a different source.)
|
||||||
|
|
||||||
|
Leave **Additional Environment Variables** blank or see [Environment Variables](#environment-variables) to set before installing.
|
||||||
|
|
||||||
|
### Network Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas06.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Networking Settings"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Accept the default port `30041` in **WebUI Port** or enter a custom port number.
|
||||||
|
:::info Allowed Port Numbers
|
||||||
|
Only numbers within the range 9000-65535 may be used on SCALE versions below TrueNAS Scale 24.10 Electric Eel.
|
||||||
|
|
||||||
|
Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/references/defaultports/).
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Storage Configuration
|
||||||
|
|
||||||
Immich requires seven storage datasets.
|
Immich requires seven storage datasets.
|
||||||
You can allow SCALE to create them for you, or use the dataset(s) created in [First Steps](#first-steps).
|
|
||||||
Select the storage options you want to use for **Immich Uploads Storage**, **Immich Library Storage**, **Immich Thumbs Storage**, **Immich Profile Storage**, **Immich Video Storage**, **Immich Postgres Data Storage**, **Immich Postgres Backup Storage**.
|
|
||||||
Select **ixVolume (dataset created automatically by the system)** in **Type** to let SCALE create the dataset or select **Host Path** to use the existing datasets created on the system.
|
|
||||||
|
|
||||||
Accept the defaults in Resources or change the CPU and memory limits to suit your use case.
|
<img
|
||||||
|
src={require('./img/truenas07.png').default}
|
||||||
|
width="20%"
|
||||||
|
alt="Configure Storage ixVolumes"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
Click **Install**.
|
:::note Default Setting (Not recommended)
|
||||||
|
The default setting for datasets is **ixVolume (dataset created automatically by the system)** but this results in your data being harder to access manually and can result in data loss if you delete the immich app. (Not recommended)
|
||||||
|
:::
|
||||||
|
|
||||||
|
For each Storage option select **Host Path (Path that already exists on the system)** and then select the matching dataset [created before installing the app](#setting-up-storage-datasets): **Immich Library Storage**: `library`, **Immich Uploads Storage**: `upload`, **Immich Thumbs Storage**: `thumbs`, **Immich Profile Storage**: `profile`, **Immich Video Storage**: `video`, **Immich Backups Storage**: `backups`, **Postgres Data Storage**: `pgData`.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas08.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configure Storage Host Paths"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
The image above has example values.
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
### Additional Storage [(External Libraries)](/docs/features/libraries)
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas10.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configure Storage Host Paths"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
|
||||||
|
The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich.
|
||||||
|
The **Host Path** is the location on the TrueNAS SCALE server where your external library is located.
|
||||||
|
|
||||||
|
<!-- A section for Labels would go here but I don't know what they do. -->
|
||||||
|
|
||||||
|
### Resources Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas09.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Resource Limits"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core).
|
||||||
|
|
||||||
|
Accept the default **Memory** limit of `4096` MB or specify the number of MB of RAM. If you're using Machine Learning you should probably set this above 8000 MB.
|
||||||
|
|
||||||
|
:::info Older SCALE Versions
|
||||||
|
Before TrueNAS SCALE version 24.10 Electric Eel:
|
||||||
|
|
||||||
|
The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads.
|
||||||
|
|
||||||
|
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
|
||||||
|
:::
|
||||||
|
|
||||||
|
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passtrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough)
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
Finally, click **Install**.
|
||||||
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
|
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
|
||||||
When the installation completes it changes to **Running**.
|
When the installation completes it changes to **Running**.
|
||||||
|
|
||||||
@@ -97,102 +215,41 @@ Click **Web Portal** on the **Application Info** widget to open the Immich web i
|
|||||||
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
|
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Editing Environment Variables
|
## Edit App Settings
|
||||||
|
|
||||||
Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
||||||
Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
|
- Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
|
||||||
The settings on the edit screen are the same as on the install screen.
|
- Change any settings you would like to change.
|
||||||
You cannot edit **Storage Configuration** paths after the initial app install.
|
- The settings on the edit screen are the same as on the install screen.
|
||||||
|
- Click **Update** at the very bottom of the page to save changes.
|
||||||
|
- TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated settings.
|
||||||
|
|
||||||
Click **Update** to save changes.
|
## Environment Variables
|
||||||
TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated environment variables.
|
|
||||||
|
You can set [Environment Variables](/docs/install/environment-variables) by clicking **Add** on the **Additional Environment Variables** option and filling in the **Name** and **Value**.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas11.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Environment Variables"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Some Environment Variables are not available for the TrueNAS SCALE app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
|
||||||
|
|
||||||
|
Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
|
||||||
|
:::
|
||||||
|
|
||||||
## Updating the App
|
## Updating the App
|
||||||
|
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, SCALE alerts and provides easy updates.
|
||||||
To update the app to the latest version, click **Update** on the **Application Info** widget from the **Installed Applications** screen.
|
To update the app to the latest version:
|
||||||
|
|
||||||
Update opens an update window for the application that includes two selectable options, Images (to be updated) and Changelog. Click on the down arrow to see the options available for each.
|
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
||||||
|
- Click **Update** on the **Application Info** widget from the **Installed Applications** screen.
|
||||||
Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress. When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
|
- This opens an update window with some options
|
||||||
|
- You may select an Image update too.
|
||||||
## Understanding Immich Settings in TrueNAS SCALE
|
- You may view the Changelog.
|
||||||
|
- Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress.
|
||||||
Accept the default value or enter a name in **Application Name** field.
|
- When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
|
||||||
In most cases use the default name, but if adding a second deployment of the application you must change this name.
|
|
||||||
|
|
||||||
Accept the default version number in **Version**.
|
|
||||||
When a new version becomes available, the application has an update badge.
|
|
||||||
The **Installed Applications** screen shows the option to update applications.
|
|
||||||
|
|
||||||
### Immich Configuration Settings
|
|
||||||
|
|
||||||
You can accept the defaults in the **Immich Configuration** settings, or enter the settings you want to use.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas05.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configuration Settings"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Accept the default setting in **Timezone** or change to match your local timezone.
|
|
||||||
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
|
||||||
|
|
||||||
You can enter a **Public Login Message** to display on the login page, or leave it blank.
|
|
||||||
|
|
||||||
### Networking Settings
|
|
||||||
|
|
||||||
Accept the default port numbers in **Web Port**.
|
|
||||||
The SCALE Immich app listens on port **30041**.
|
|
||||||
|
|
||||||
Refer to the TrueNAS [default port list](https://www.truenas.com/docs/references/defaultports/) for a list of assigned port numbers.
|
|
||||||
To change the port numbers, enter a number within the range 9000-65535.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas06.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Networking Settings"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Storage Settings
|
|
||||||
|
|
||||||
You can install Immich using the default setting **ixVolume (dataset created automatically by the system)** or use the host path option with datasets [created before installing the app](#first-steps).
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas07.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configure Storage ixVolumes"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Select **Host Path (Path that already exists on the system)** to browse to and select the datasets.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas08.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configure Storage Host Paths"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Resource Configuration Settings
|
|
||||||
|
|
||||||
Accept the default values in **Resources Configuration** or enter new CPU and memory values
|
|
||||||
By default, this application is limited to use no more than 4 CPU cores and 8 Gigabytes available memory. The application might use considerably less system resources.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas09.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Resource Limits"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
To customize the CPU and memory allocated to the container Immich uses, enter new CPU values as a plain integer value followed by the suffix m (milli).
|
|
||||||
Default is 4000m.
|
|
||||||
|
|
||||||
Accept the default value 8Gi allocated memory or enter a new limit in bytes.
|
|
||||||
Enter a plain integer followed by the measurement suffix, for example 129M or 123Mi.
|
|
||||||
|
|
||||||
Systems with compatible GPU(s) display devices in **GPU Configuration**.
|
|
||||||
See [Managing GPUs](https://www.truenas.com/docs/scale/scaletutorials/systemsettings/advanced/managegpuscale/) for more information about allocating isolated GPU devices in TrueNAS SCALE.
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ services:
|
|||||||
- 2285:2285
|
- 2285:2285
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||||
|
|||||||
@@ -163,11 +163,15 @@ describe('/server', () => {
|
|||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
photos: 0,
|
photos: 0,
|
||||||
usage: 0,
|
usage: 0,
|
||||||
|
usagePhotos: 0,
|
||||||
|
usageVideos: 0,
|
||||||
usageByUser: [
|
usageByUser: [
|
||||||
{
|
{
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
photos: 0,
|
photos: 0,
|
||||||
usage: 0,
|
usage: 0,
|
||||||
|
usagePhotos: 0,
|
||||||
|
usageVideos: 0,
|
||||||
userName: 'Immich Admin',
|
userName: 'Immich Admin',
|
||||||
userId: admin.userId,
|
userId: admin.userId,
|
||||||
videos: 0,
|
videos: 0,
|
||||||
@@ -176,6 +180,8 @@ describe('/server', () => {
|
|||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
photos: 0,
|
photos: 0,
|
||||||
usage: 0,
|
usage: 0,
|
||||||
|
usagePhotos: 0,
|
||||||
|
usageVideos: 0,
|
||||||
userName: 'User 1',
|
userName: 'User 1',
|
||||||
userId: nonAdmin.userId,
|
userId: nonAdmin.userId,
|
||||||
videos: 0,
|
videos: 0,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ describe(`immich upload`, () => {
|
|||||||
describe(`immich upload /path/to/file.jpg`, () => {
|
describe(`immich upload /path/to/file.jpg`, () => {
|
||||||
it('should upload a single file', async () => {
|
it('should upload a single file', async () => {
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||||
);
|
);
|
||||||
@@ -126,7 +126,7 @@ describe(`immich upload`, () => {
|
|||||||
const expectedCount = Object.entries(files).filter((entry) => entry[1]).length;
|
const expectedCount = Object.entries(files).filter((entry) => entry[1]).length;
|
||||||
|
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]),
|
expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]),
|
||||||
);
|
);
|
||||||
@@ -154,7 +154,7 @@ describe(`immich upload`, () => {
|
|||||||
cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]);
|
cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]);
|
||||||
|
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
|
||||||
);
|
);
|
||||||
@@ -169,7 +169,7 @@ describe(`immich upload`, () => {
|
|||||||
|
|
||||||
it('should skip a duplicate file', async () => {
|
it('should skip a duplicate file', async () => {
|
||||||
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(first.stderr).toBe('');
|
expect(first.stderr).toContain('{message}');
|
||||||
expect(first.stdout.split('\n')).toEqual(
|
expect(first.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||||
);
|
);
|
||||||
@@ -179,7 +179,7 @@ describe(`immich upload`, () => {
|
|||||||
expect(assets.total).toBe(1);
|
expect(assets.total).toBe(1);
|
||||||
|
|
||||||
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(second.stderr).toBe('');
|
expect(second.stderr).toContain('{message}');
|
||||||
expect(second.stdout.split('\n')).toEqual(
|
expect(second.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.stringContaining('Found 0 new files and 1 duplicate'),
|
expect.stringContaining('Found 0 new files and 1 duplicate'),
|
||||||
@@ -205,7 +205,7 @@ describe(`immich upload`, () => {
|
|||||||
`${testAssetDir}/albums/nature/silver_fir.jpg`,
|
`${testAssetDir}/albums/nature/silver_fir.jpg`,
|
||||||
'--dry-run',
|
'--dry-run',
|
||||||
]);
|
]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]),
|
expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]),
|
||||||
);
|
);
|
||||||
@@ -217,7 +217,7 @@ describe(`immich upload`, () => {
|
|||||||
|
|
||||||
it('dry run should handle duplicates', async () => {
|
it('dry run should handle duplicates', async () => {
|
||||||
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(first.stderr).toBe('');
|
expect(first.stderr).toContain('{message}');
|
||||||
expect(first.stdout.split('\n')).toEqual(
|
expect(first.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||||
);
|
);
|
||||||
@@ -227,7 +227,7 @@ describe(`immich upload`, () => {
|
|||||||
expect(assets.total).toBe(1);
|
expect(assets.total).toBe(1);
|
||||||
|
|
||||||
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--dry-run']);
|
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--dry-run']);
|
||||||
expect(second.stderr).toBe('');
|
expect(second.stderr).toContain('{message}');
|
||||||
expect(second.stdout.split('\n')).toEqual(
|
expect(second.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.stringContaining('Found 8 new files and 1 duplicate'),
|
expect.stringContaining('Found 8 new files and 1 duplicate'),
|
||||||
@@ -241,7 +241,7 @@ describe(`immich upload`, () => {
|
|||||||
describe('immich upload --recursive', () => {
|
describe('immich upload --recursive', () => {
|
||||||
it('should upload a folder recursively', async () => {
|
it('should upload a folder recursively', async () => {
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
||||||
);
|
);
|
||||||
@@ -267,7 +267,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Successfully updated 9 assets'),
|
expect.stringContaining('Successfully updated 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -283,7 +283,7 @@ describe(`immich upload`, () => {
|
|||||||
expect(response1.stdout.split('\n')).toEqual(
|
expect(response1.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
||||||
);
|
);
|
||||||
expect(response1.stderr).toBe('');
|
expect(response1.stderr).toContain('{message}');
|
||||||
expect(response1.exitCode).toBe(0);
|
expect(response1.exitCode).toBe(0);
|
||||||
|
|
||||||
const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -299,7 +299,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Successfully updated 9 assets'),
|
expect.stringContaining('Successfully updated 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(response2.stderr).toBe('');
|
expect(response2.stderr).toContain('{message}');
|
||||||
expect(response2.exitCode).toBe(0);
|
expect(response2.exitCode).toBe(0);
|
||||||
|
|
||||||
const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -325,7 +325,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Would have updated albums of 9 assets'),
|
expect.stringContaining('Would have updated albums of 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -351,7 +351,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Successfully updated 9 assets'),
|
expect.stringContaining('Successfully updated 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -377,7 +377,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Would have updated albums of 9 assets'),
|
expect.stringContaining('Would have updated albums of 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -408,7 +408,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Deleting assets that have been uploaded'),
|
expect.stringContaining('Deleting assets that have been uploaded'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -434,7 +434,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Would have deleted 9 local assets'),
|
expect.stringContaining('Would have deleted 9 local assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -493,7 +493,7 @@ describe(`immich upload`, () => {
|
|||||||
'2',
|
'2',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 9 new files and 0 duplicates',
|
'Found 9 new files and 0 duplicates',
|
||||||
@@ -534,7 +534,7 @@ describe(`immich upload`, () => {
|
|||||||
'silver_fir.jpg',
|
'silver_fir.jpg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 8 new files and 0 duplicates',
|
'Found 8 new files and 0 duplicates',
|
||||||
@@ -555,7 +555,7 @@ describe(`immich upload`, () => {
|
|||||||
'!(*_*_*).jpg',
|
'!(*_*_*).jpg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 1 new files and 0 duplicates',
|
'Found 1 new files and 0 duplicates',
|
||||||
@@ -577,7 +577,7 @@ describe(`immich upload`, () => {
|
|||||||
'--dry-run',
|
'--dry-run',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 8 new files and 0 duplicates',
|
'Found 8 new files and 0 duplicates',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
SharedLinkCreateDto,
|
SharedLinkCreateDto,
|
||||||
UserAdminCreateDto,
|
UserAdminCreateDto,
|
||||||
|
UserPreferencesUpdateDto,
|
||||||
ValidateLibraryDto,
|
ValidateLibraryDto,
|
||||||
checkExistingAssets,
|
checkExistingAssets,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
createPartner,
|
createPartner,
|
||||||
createPerson,
|
createPerson,
|
||||||
createSharedLink,
|
createSharedLink,
|
||||||
|
createStack,
|
||||||
createUserAdmin,
|
createUserAdmin,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
getAllJobsStatus,
|
getAllJobsStatus,
|
||||||
@@ -28,10 +30,13 @@ import {
|
|||||||
searchMetadata,
|
searchMetadata,
|
||||||
setBaseUrl,
|
setBaseUrl,
|
||||||
signUpAdmin,
|
signUpAdmin,
|
||||||
|
tagAssets,
|
||||||
updateAdminOnboarding,
|
updateAdminOnboarding,
|
||||||
updateAlbumUser,
|
updateAlbumUser,
|
||||||
updateAssets,
|
updateAssets,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
updateMyPreferences,
|
||||||
|
upsertTags,
|
||||||
validate,
|
validate,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
@@ -444,6 +449,18 @@ export const utils = {
|
|||||||
|
|
||||||
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
|
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
|
updateMyPreferences: (accessToken: string, userPreferencesUpdateDto: UserPreferencesUpdateDto) =>
|
||||||
|
updateMyPreferences({ userPreferencesUpdateDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
|
createStack: (accessToken: string, assetIds: string[]) =>
|
||||||
|
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
|
upsertTags: (accessToken: string, tags: string[]) =>
|
||||||
|
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
|
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||||
|
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
||||||
await context.addCookies([
|
await context.addCookies([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, Page, test } from '@playwright/test';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
async function ensureDetailPanelVisible(page: Page) {
|
||||||
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
|
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||||
|
if (!isVisible) {
|
||||||
|
await page.keyboard.press('i');
|
||||||
|
await page.waitForSelector('#detail-panel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Asset Viewer stack', () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
let assetOne: AssetMediaResponseDto;
|
||||||
|
let assetTwo: AssetMediaResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
|
||||||
|
|
||||||
|
assetOne = await utils.createAsset(admin.accessToken);
|
||||||
|
assetTwo = await utils.createAsset(admin.accessToken);
|
||||||
|
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
|
||||||
|
|
||||||
|
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
|
||||||
|
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
|
||||||
|
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
|
||||||
|
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
|
||||||
|
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stack slideshow is visible', async ({ page, context }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await page.goto(`/photos/${assetOne.id}`);
|
||||||
|
|
||||||
|
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||||
|
await expect(stackAssets.first()).toBeVisible();
|
||||||
|
await expect(stackAssets.nth(1)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags of primary asset are visible', async ({ page, context }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await page.goto(`/photos/${assetOne.id}`);
|
||||||
|
await ensureDetailPanelVisible(page);
|
||||||
|
|
||||||
|
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||||
|
await expect(tags.first()).toHaveText('test/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags of second asset are visible', async ({ page, context }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await page.goto(`/photos/${assetOne.id}`);
|
||||||
|
await ensureDetailPanelVisible(page);
|
||||||
|
|
||||||
|
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||||
|
await stackAssets.nth(1).click();
|
||||||
|
|
||||||
|
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||||
|
await expect(tags.first()).toHaveText('test/2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
##---------------Begin: proguard configuration for Gson ----------
|
||||||
|
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||||
|
# removes such information by default, so configure it to keep all of it.
|
||||||
|
-keepattributes Signature
|
||||||
|
|
||||||
|
# For using GSON @Expose annotation
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
|
# Gson specific classes
|
||||||
|
-dontwarn sun.misc.**
|
||||||
|
#-keep class com.google.gson.stream.** { *; }
|
||||||
|
|
||||||
|
# Application classes that will be serialized/deserialized over Gson
|
||||||
|
-keep class com.google.gson.examples.android.model.** { <fields>; }
|
||||||
|
|
||||||
|
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
|
||||||
|
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
|
||||||
|
-keep class * extends com.google.gson.TypeAdapter
|
||||||
|
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||||
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
|
|
||||||
|
# Prevent R8 from leaving Data object members always null
|
||||||
|
-keepclassmembers,allowobfuscation class * {
|
||||||
|
@com.google.gson.annotations.SerializedName <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
|
||||||
|
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
|
||||||
|
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
##---------------End: proguard configuration for Gson ----------
|
||||||
@@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096M
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
android.nonTransitiveRClass=false
|
android.nonTransitiveRClass=false
|
||||||
android.nonFinalResIds=false
|
android.nonFinalResIds=false
|
||||||
@@ -401,7 +401,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -58,11 +58,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.120.1</string>
|
<string>1.120.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>183</string>
|
<string>184</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const String defaultColorPresetName = "indigo";
|
|||||||
|
|
||||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||||
|
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
||||||
|
const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
|
||||||
|
const Color red400 = Color(0xFFEF5350);
|
||||||
|
|
||||||
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
||||||
ImmichColorPreset.indigo: ImmichTheme(
|
ImmichColorPreset.indigo: ImmichTheme(
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class CurrentUploadAsset {
|
|||||||
this.iCloudAsset,
|
this.iCloudAsset,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
bool get isIcloudAsset => iCloudAsset != null && iCloudAsset!;
|
||||||
|
|
||||||
CurrentUploadAsset copyWith({
|
CurrentUploadAsset copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
DateTime? fileCreatedAt,
|
DateTime? fileCreatedAt,
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
showFilterBottomSheet(
|
showFilterBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
isDismissible: false,
|
isDismissible: true,
|
||||||
child: FilterBottomSheetScaffold(
|
child: FilterBottomSheetScaffold(
|
||||||
title: 'search_filter_location_title'.tr(),
|
title: 'search_filter_location_title'.tr(),
|
||||||
onSearch: search,
|
onSearch: search,
|
||||||
@@ -238,7 +238,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
showFilterBottomSheet(
|
showFilterBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
isDismissible: false,
|
isDismissible: true,
|
||||||
child: FilterBottomSheetScaffold(
|
child: FilterBottomSheetScaffold(
|
||||||
title: 'search_filter_camera_title'.tr(),
|
title: 'search_filter_camera_title'.tr(),
|
||||||
onSearch: search,
|
onSearch: search,
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
_ref;
|
_ref;
|
||||||
final _log = Logger("AuthenticationNotifier");
|
final _log = Logger("AuthenticationNotifier");
|
||||||
|
|
||||||
|
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||||
|
|
||||||
Future<bool> login(
|
Future<bool> login(
|
||||||
String email,
|
String email,
|
||||||
String password,
|
String password,
|
||||||
@@ -102,12 +104,15 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
|
|
||||||
await _apiService.authenticationApi
|
await _apiService.authenticationApi
|
||||||
.logout()
|
.logout()
|
||||||
|
.timeout(_timeoutDuration)
|
||||||
.then((_) => log.info("Logout was successful for $userEmail"))
|
.then((_) => log.info("Logout was successful for $userEmail"))
|
||||||
.onError(
|
.onError(
|
||||||
(error, stackTrace) =>
|
(error, stackTrace) =>
|
||||||
log.severe("Logout failed for $userEmail", error, stackTrace),
|
log.severe("Logout failed for $userEmail", error, stackTrace),
|
||||||
);
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
log.severe('Logout failed', e, stack);
|
||||||
|
} finally {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
clearAssetsAndAlbums(_db),
|
clearAssetsAndAlbums(_db),
|
||||||
Store.delete(StoreKey.currentUser),
|
Store.delete(StoreKey.currentUser),
|
||||||
@@ -125,8 +130,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
shouldChangePassword: false,
|
shouldChangePassword: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
);
|
);
|
||||||
} catch (e, stack) {
|
|
||||||
log.severe('Logout failed', e, stack);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,10 +171,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
UserPreferencesResponseDto? userPreferences;
|
UserPreferencesResponseDto? userPreferences;
|
||||||
try {
|
try {
|
||||||
final responses = await Future.wait([
|
final responses = await Future.wait([
|
||||||
_apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
|
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
|
||||||
_apiService.usersApi
|
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
|
||||||
.getMyPreferences()
|
|
||||||
.timeout(const Duration(seconds: 7)),
|
|
||||||
]);
|
]);
|
||||||
userResponse = responses[0] as UserAdminResponseDto;
|
userResponse = responses[0] as UserAdminResponseDto;
|
||||||
userPreferences = responses[1] as UserPreferencesResponseDto;
|
userPreferences = responses[1] as UserPreferencesResponseDto;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class ImmichLocalThumbnailProvider
|
|||||||
|
|
||||||
ImmichLocalThumbnailProvider({
|
ImmichLocalThumbnailProvider({
|
||||||
required this.asset,
|
required this.asset,
|
||||||
this.height = 256,
|
this.height = 128,
|
||||||
this.width = 256,
|
this.width = 128,
|
||||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||||
|
|
||||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
|
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
|
||||||
import 'package:immich_mobile/services/partner.service.dart';
|
import 'package:immich_mobile/services/partner.service.dart';
|
||||||
@@ -9,9 +10,19 @@ import 'package:isar/isar.dart';
|
|||||||
|
|
||||||
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
|
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
|
||||||
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
|
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
|
||||||
final query = db.users.filter().isPartnerSharedWithEqualTo(true);
|
Function eq = const ListEquality<User>().equals;
|
||||||
query.findAll().then((partners) => state = partners);
|
final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById();
|
||||||
query.watch().listen((partners) => state = partners);
|
query.findAll().then((partners) {
|
||||||
|
if (!eq(state, partners)) {
|
||||||
|
state = partners;
|
||||||
|
}
|
||||||
|
}).then((_) {
|
||||||
|
query.watch().listen((partners) {
|
||||||
|
if (!eq(state, partners)) {
|
||||||
|
state = partners;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updatePartner(User partner, {required bool inTimeline}) {
|
Future<bool> updatePartner(User partner, {required bool inTimeline}) {
|
||||||
@@ -31,9 +42,19 @@ final partnerSharedWithProvider =
|
|||||||
|
|
||||||
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
|
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
|
||||||
PartnerSharedByNotifier(Isar db) : super([]) {
|
PartnerSharedByNotifier(Isar db) : super([]) {
|
||||||
final query = db.users.filter().isPartnerSharedByEqualTo(true);
|
Function eq = const ListEquality<User>().equals;
|
||||||
query.findAll().then((partners) => state = partners);
|
final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById();
|
||||||
streamSub = query.watch().listen((partners) => state = partners);
|
query.findAll().then((partners) {
|
||||||
|
if (!eq(state, partners)) {
|
||||||
|
state = partners;
|
||||||
|
}
|
||||||
|
}).then((_) {
|
||||||
|
streamSub = query.watch().listen((partners) {
|
||||||
|
if (!eq(state, partners)) {
|
||||||
|
state = partners;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final StreamSubscription<List<User>> streamSub;
|
late final StreamSubscription<List<User>> streamSub;
|
||||||
|
|||||||
@@ -76,10 +76,16 @@ class AlbumService {
|
|||||||
final Stopwatch sw = Stopwatch()..start();
|
final Stopwatch sw = Stopwatch()..start();
|
||||||
bool changes = false;
|
bool changes = false;
|
||||||
try {
|
try {
|
||||||
final List<String> excludedIds = await _backupAlbumRepository
|
final (selectedIds, excludedIds, onDevice) = await (
|
||||||
.getIdsBySelection(BackupSelection.exclude);
|
_backupAlbumRepository
|
||||||
final List<String> selectedIds = await _backupAlbumRepository
|
.getIdsBySelection(BackupSelection.select)
|
||||||
.getIdsBySelection(BackupSelection.select);
|
.then((value) => value.toSet()),
|
||||||
|
_backupAlbumRepository
|
||||||
|
.getIdsBySelection(BackupSelection.exclude)
|
||||||
|
.then((value) => value.toSet()),
|
||||||
|
_albumMediaRepository.getAll()
|
||||||
|
).wait;
|
||||||
|
_log.info("Found ${onDevice.length} device albums");
|
||||||
if (selectedIds.isEmpty) {
|
if (selectedIds.isEmpty) {
|
||||||
final numLocal = await _albumRepository.count(local: true);
|
final numLocal = await _albumRepository.count(local: true);
|
||||||
if (numLocal > 0) {
|
if (numLocal > 0) {
|
||||||
@@ -87,8 +93,6 @@ class AlbumService {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final List<Album> onDevice = await _albumMediaRepository.getAll();
|
|
||||||
_log.info("Found ${onDevice.length} device albums");
|
|
||||||
Set<String>? excludedAssets;
|
Set<String>? excludedAssets;
|
||||||
if (excludedIds.isNotEmpty) {
|
if (excludedIds.isNotEmpty) {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
@@ -108,22 +112,19 @@ class AlbumService {
|
|||||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final hasAll = selectedIds
|
|
||||||
.map(
|
final allAlbum = onDevice.firstWhereOrNull((album) => album.isAll);
|
||||||
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
|
final hasAll = allAlbum != null && selectedIds.contains(allAlbum.localId);
|
||||||
)
|
|
||||||
.whereNotNull()
|
|
||||||
.any((a) => a.isAll);
|
|
||||||
if (hasAll) {
|
if (hasAll) {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
// remove the virtual "Recent" album and keep and individual albums
|
// remove the virtual "Recent" album and keep and individual albums
|
||||||
// on Android, the virtual "Recent" `lastModified` value is always null
|
// on Android, the virtual "Recent" `lastModified` value is always null
|
||||||
onDevice.removeWhere((e) => e.isAll);
|
onDevice.removeWhere((album) => album.isAll);
|
||||||
_log.info("'Recents' is selected, keeping all individual albums");
|
_log.info("'Recents' is selected, keeping all individual albums");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// keep only the explicitly selected albums
|
// keep only the explicitly selected albums
|
||||||
onDevice.removeWhere((e) => !selectedIds.contains(e.localId));
|
onDevice.removeWhere((album) => !selectedIds.contains(album.localId));
|
||||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||||
}
|
}
|
||||||
changes =
|
changes =
|
||||||
@@ -138,15 +139,19 @@ class AlbumService {
|
|||||||
|
|
||||||
Future<Set<String>> _loadExcludedAssetIds(
|
Future<Set<String>> _loadExcludedAssetIds(
|
||||||
List<Album> albums,
|
List<Album> albums,
|
||||||
List<String> excludedAlbumIds,
|
Set<String> excludedAlbumIds,
|
||||||
) async {
|
) async {
|
||||||
final Set<String> result = HashSet<String>();
|
final Set<String> result = HashSet<String>();
|
||||||
for (Album album in albums) {
|
for (final batchAlbums in albums
|
||||||
if (excludedAlbumIds.contains(album.localId)) {
|
.where((album) => excludedAlbumIds.contains(album.localId))
|
||||||
final assetIds =
|
.slices(5)) {
|
||||||
await _albumMediaRepository.getAssetIds(album.localId!);
|
await batchAlbums
|
||||||
result.addAll(assetIds);
|
.map(
|
||||||
}
|
(album) => _albumMediaRepository
|
||||||
|
.getAssetIds(album.localId!)
|
||||||
|
.then((assetIds) => result.addAll(assetIds)),
|
||||||
|
)
|
||||||
|
.wait;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -163,11 +168,10 @@ class AlbumService {
|
|||||||
bool changes = false;
|
bool changes = false;
|
||||||
try {
|
try {
|
||||||
await _userService.refreshUsers();
|
await _userService.refreshUsers();
|
||||||
final List<Album> sharedAlbum =
|
final (sharedAlbum, ownedAlbum) = await (
|
||||||
await _albumApiRepository.getAll(shared: true);
|
_albumApiRepository.getAll(shared: true),
|
||||||
|
_albumApiRepository.getAll(shared: null)
|
||||||
final List<Album> ownedAlbum =
|
).wait;
|
||||||
await _albumApiRepository.getAll(shared: null);
|
|
||||||
|
|
||||||
final albums = HashSet<Album>(
|
final albums = HashSet<Album>(
|
||||||
equals: (a, b) => a.remoteId == b.remoteId,
|
equals: (a, b) => a.remoteId == b.remoteId,
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
|
||||||
class ImmichTheme {
|
class ImmichTheme {
|
||||||
ColorScheme light;
|
final ColorScheme light;
|
||||||
ColorScheme dark;
|
final ColorScheme dark;
|
||||||
|
|
||||||
ImmichTheme({required this.light, required this.dark});
|
const ImmichTheme({required this.light, required this.dark});
|
||||||
}
|
}
|
||||||
|
|
||||||
ImmichTheme? _immichDynamicTheme;
|
ImmichTheme? _immichDynamicTheme;
|
||||||
@@ -151,7 +151,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) {
|
|||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: colorScheme.brightness,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
primaryColor: primaryColor,
|
primaryColor: primaryColor,
|
||||||
hintColor: colorScheme.onSurfaceSecondary,
|
hintColor: colorScheme.onSurfaceSecondary,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||||
@@ -327,39 +328,51 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
child: Column(
|
child: DecoratedBox(
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
Visibility(
|
gradient: LinearGradient(
|
||||||
visible: showVideoPlayerControls,
|
begin: Alignment.bottomCenter,
|
||||||
child: const VideoControls(),
|
end: Alignment.topCenter,
|
||||||
|
colors: [blackOpacity90, Colors.transparent],
|
||||||
),
|
),
|
||||||
BottomNavigationBar(
|
),
|
||||||
backgroundColor: Colors.black.withOpacity(0.4),
|
position: DecorationPosition.background,
|
||||||
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
child: Padding(
|
||||||
selectedIconTheme: const IconThemeData(color: Colors.white),
|
padding: EdgeInsets.only(top: 40.0),
|
||||||
unselectedLabelStyle: const TextStyle(
|
child: Column(
|
||||||
color: Colors.white,
|
children: [
|
||||||
fontWeight: FontWeight.w500,
|
if (showVideoPlayerControls) const VideoControls(),
|
||||||
height: 2.3,
|
BottomNavigationBar(
|
||||||
),
|
elevation: 0.0,
|
||||||
selectedLabelStyle: const TextStyle(
|
backgroundColor: Colors.transparent,
|
||||||
color: Colors.white,
|
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
fontWeight: FontWeight.w500,
|
selectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
height: 2.3,
|
unselectedLabelStyle: const TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
unselectedFontSize: 14,
|
fontWeight: FontWeight.w500,
|
||||||
selectedFontSize: 14,
|
height: 2.3,
|
||||||
selectedItemColor: Colors.white,
|
),
|
||||||
unselectedItemColor: Colors.white,
|
selectedLabelStyle: const TextStyle(
|
||||||
showSelectedLabels: true,
|
color: Colors.white,
|
||||||
showUnselectedLabels: true,
|
fontWeight: FontWeight.w500,
|
||||||
items:
|
height: 2.3,
|
||||||
albumActions.map((e) => e.keys.first).toList(growable: false),
|
),
|
||||||
onTap: (index) {
|
unselectedFontSize: 14,
|
||||||
albumActions[index].values.first.call(index);
|
selectedFontSize: 14,
|
||||||
},
|
selectedItemColor: Colors.white,
|
||||||
|
unselectedItemColor: Colors.white,
|
||||||
|
showSelectedLabels: true,
|
||||||
|
showUnselectedLabels: true,
|
||||||
|
items: albumActions
|
||||||
|
.map((e) => e.keys.first)
|
||||||
|
.toList(growable: false),
|
||||||
|
onTap: (index) {
|
||||||
|
albumActions[index].values.first.call(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
String _formatDuration(Duration position) {
|
||||||
|
final seconds = position.inSeconds.remainder(60).toString().padLeft(2, "0");
|
||||||
|
final minutes = position.inMinutes.remainder(60).toString().padLeft(2, "0");
|
||||||
|
if (position.inHours == 0) {
|
||||||
|
return "$minutes:$seconds";
|
||||||
|
}
|
||||||
|
final hours = position.inHours.toString().padLeft(2, '0');
|
||||||
|
return "$hours:$minutes:$seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormattedDuration extends StatelessWidget {
|
||||||
|
final Duration data;
|
||||||
|
const FormattedDuration(this.data, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
|
||||||
|
child: Text(
|
||||||
|
_formatDuration(data),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14.0,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,125 +1,20 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
|
|
||||||
/// The video controls for the [videPlayerControlsProvider]
|
/// The video controls for the [videoPlayerControlsProvider]
|
||||||
class VideoControls extends ConsumerWidget {
|
class VideoControls extends ConsumerWidget {
|
||||||
const VideoControls({super.key});
|
const VideoControls({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final duration =
|
final isPortrait =
|
||||||
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
|
MediaQuery.orientationOf(context) == Orientation.portrait;
|
||||||
final position =
|
return isPortrait
|
||||||
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
|
? const VideoPosition()
|
||||||
|
: const Padding(
|
||||||
return AnimatedOpacity(
|
padding: EdgeInsets.symmetric(horizontal: 60.0),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
child: VideoPosition(),
|
||||||
duration: const Duration(milliseconds: 100),
|
);
|
||||||
child: OrientationBuilder(
|
|
||||||
builder: (context, orientation) => Container(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
|
|
||||||
),
|
|
||||||
color: Colors.black.withOpacity(0.4),
|
|
||||||
child: Padding(
|
|
||||||
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
|
||||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
|
||||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatDuration(position),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: duration == Duration.zero
|
|
||||||
? 0.0
|
|
||||||
: min(
|
|
||||||
position.inMicroseconds /
|
|
||||||
duration.inMicroseconds *
|
|
||||||
100,
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: Colors.white.withOpacity(0.75),
|
|
||||||
onChanged: (position) {
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).position =
|
|
||||||
position;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatDuration(duration),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
ref.watch(
|
|
||||||
videoPlayerControlsProvider.select((value) => value.mute),
|
|
||||||
)
|
|
||||||
? Icons.volume_off
|
|
||||||
: Icons.volume_up,
|
|
||||||
),
|
|
||||||
onPressed: () => ref
|
|
||||||
.read(videoPlayerControlsProvider.notifier)
|
|
||||||
.toggleMute(),
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(Duration position) {
|
|
||||||
final ms = position.inMilliseconds;
|
|
||||||
|
|
||||||
int seconds = ms ~/ 1000;
|
|
||||||
final int hours = seconds ~/ 3600;
|
|
||||||
seconds = seconds % 3600;
|
|
||||||
final minutes = seconds ~/ 60;
|
|
||||||
seconds = seconds % 60;
|
|
||||||
|
|
||||||
final hoursString = hours >= 10
|
|
||||||
? '$hours'
|
|
||||||
: hours == 0
|
|
||||||
? '00'
|
|
||||||
: '0$hours';
|
|
||||||
|
|
||||||
final minutesString = minutes >= 10
|
|
||||||
? '$minutes'
|
|
||||||
: minutes == 0
|
|
||||||
? '00'
|
|
||||||
: '0$minutes';
|
|
||||||
|
|
||||||
final secondsString = seconds >= 10
|
|
||||||
? '$seconds'
|
|
||||||
: seconds == 0
|
|
||||||
? '00'
|
|
||||||
: '0$seconds';
|
|
||||||
|
|
||||||
final formattedTime =
|
|
||||||
'${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
|
|
||||||
|
|
||||||
return formattedTime;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||||
|
|
||||||
|
class VideoPosition extends HookConsumerWidget {
|
||||||
|
const VideoPosition({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final (position, duration) = ref.watch(
|
||||||
|
videoPlaybackValueProvider.select((v) => (v.position, v.duration)),
|
||||||
|
);
|
||||||
|
final wasPlaying = useRef<bool>(true);
|
||||||
|
return duration == Duration.zero
|
||||||
|
? const _VideoPositionPlaceholder()
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
// align with slider's inherent padding
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
FormattedDuration(position),
|
||||||
|
FormattedDuration(duration),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: min(
|
||||||
|
position.inMicroseconds / duration.inMicroseconds * 100,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
onChangeStart: (value) {
|
||||||
|
final state =
|
||||||
|
ref.read(videoPlaybackValueProvider).state;
|
||||||
|
wasPlaying.value = state != VideoPlaybackState.paused;
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
},
|
||||||
|
onChangeEnd: (value) {
|
||||||
|
if (wasPlaying.value) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChanged: (position) {
|
||||||
|
ref
|
||||||
|
.read(videoPlayerControlsProvider.notifier)
|
||||||
|
.position = position;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPositionPlaceholder extends StatelessWidget {
|
||||||
|
const _VideoPositionPlaceholder();
|
||||||
|
|
||||||
|
static void _onChangedDummy(_) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
FormattedDuration(Duration.zero),
|
||||||
|
FormattedDuration(Duration.zero),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: 0.0,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
onChanged: _onChangedDummy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class BackupAssetInfoTable extends ConsumerWidget {
|
||||||
|
const BackupAssetInfoTable({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final asset = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.currentUploadAsset),
|
||||||
|
)
|
||||||
|
: ref.watch(backupProvider.select((value) => value.currentUploadAsset));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Table(
|
||||||
|
border: TableBorder.all(
|
||||||
|
color: context.colorScheme.outlineVariant,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
'backup_controller_page_filename',
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.onSurfaceSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
).tr(
|
||||||
|
args: [asset.fileName, asset.fileType.toLowerCase()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
"backup_controller_page_created",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.onSurfaceSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
).tr(
|
||||||
|
args: [_getAssetCreationDate(asset)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
"backup_controller_page_id",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.onSurfaceSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
).tr(args: [asset.id]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
String _getAssetCreationDate(CurrentUploadAsset asset) {
|
||||||
|
return DateFormat.yMMMMd().format(asset.fileCreatedAt.toLocal());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,296 +1,43 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/widgets/backup/asset_info_table.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/widgets/backup/error_chip.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/widgets/backup/icloud_download_progress_bar.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
import 'package:immich_mobile/widgets/backup/upload_progress_bar.dart';
|
||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
import 'package:immich_mobile/widgets/backup/upload_stats.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
|
|
||||||
|
|
||||||
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
class CurrentUploadingAssetInfoBox extends StatelessWidget {
|
||||||
const CurrentUploadingAssetInfoBox({super.key});
|
const CurrentUploadingAssetInfoBox({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
var isManualUpload = ref.watch(backupProvider).backupProgress ==
|
return ListTile(
|
||||||
BackUpProgressEnum.manualInProgress;
|
isThreeLine: true,
|
||||||
var asset = !isManualUpload
|
leading: Icon(
|
||||||
? ref.watch(backupProvider).currentUploadAsset
|
Icons.image_outlined,
|
||||||
: ref.watch(manualUploadProvider).currentUploadAsset;
|
color: context.primaryColor,
|
||||||
var uploadProgress = !isManualUpload
|
size: 30,
|
||||||
? ref.watch(backupProvider).progressInPercentage
|
),
|
||||||
: ref.watch(manualUploadProvider).progressInPercentage;
|
title: Row(
|
||||||
var uploadFileProgress = !isManualUpload
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
? ref.watch(backupProvider).progressInFileSize
|
|
||||||
: ref.watch(manualUploadProvider).progressInFileSize;
|
|
||||||
var uploadFileSpeed = !isManualUpload
|
|
||||||
? ref.watch(backupProvider).progressInFileSpeed
|
|
||||||
: ref.watch(manualUploadProvider).progressInFileSpeed;
|
|
||||||
var iCloudDownloadProgress =
|
|
||||||
ref.watch(backupProvider).iCloudDownloadProgress;
|
|
||||||
final isShowThumbnail = useState(false);
|
|
||||||
|
|
||||||
String formatUploadFileSpeed(double uploadFileSpeed) {
|
|
||||||
if (uploadFileSpeed < 1024) {
|
|
||||||
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
|
|
||||||
} else if (uploadFileSpeed < 1024 * 1024) {
|
|
||||||
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
|
|
||||||
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
|
|
||||||
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
|
|
||||||
} else {
|
|
||||||
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String getAssetCreationDate() {
|
|
||||||
return DateFormat.yMMMMd().format(
|
|
||||||
DateTime.parse(
|
|
||||||
asset.fileCreatedAt.toString(),
|
|
||||||
).toLocal(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildErrorChip() {
|
|
||||||
return ActionChip(
|
|
||||||
avatar: Icon(
|
|
||||||
Icons.info,
|
|
||||||
color: Colors.red[400],
|
|
||||||
),
|
|
||||||
elevation: 1,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
label: Text(
|
|
||||||
"backup_controller_page_failed",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red[400],
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
).tr(
|
|
||||||
args: [ref.watch(errorBackupListProvider).length.toString()],
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildAssetInfoTable() {
|
|
||||||
return Table(
|
|
||||||
border: TableBorder.all(
|
|
||||||
color: context.colorScheme.outlineVariant,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
TableRow(
|
Text(
|
||||||
children: [
|
"backup_controller_page_uploading_file_info",
|
||||||
TableCell(
|
style: context.textTheme.titleSmall,
|
||||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
).tr(),
|
||||||
child: Padding(
|
const BackupErrorChip(),
|
||||||
padding: const EdgeInsets.all(6.0),
|
],
|
||||||
child: Text(
|
),
|
||||||
'backup_controller_page_filename',
|
subtitle: Column(
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
if (Platform.isIOS) const IcloudDownloadProgressBar(),
|
||||||
fontWeight: FontWeight.bold,
|
const BackupUploadProgressBar(),
|
||||||
fontSize: 10.0,
|
const BackupUploadStats(),
|
||||||
),
|
const BackupAssetInfoTable(),
|
||||||
).tr(
|
|
||||||
args: [asset.fileName, asset.fileType.toLowerCase()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
TableCell(
|
|
||||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(6.0),
|
|
||||||
child: Text(
|
|
||||||
"backup_controller_page_created",
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 10.0,
|
|
||||||
),
|
|
||||||
).tr(
|
|
||||||
args: [getAssetCreationDate()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
TableCell(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(6.0),
|
|
||||||
child: Text(
|
|
||||||
"backup_controller_page_id",
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 10.0,
|
|
||||||
),
|
|
||||||
).tr(args: [asset.id]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildiCloudDownloadProgerssBar() {
|
|
||||||
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 110,
|
|
||||||
child: Text(
|
|
||||||
"iCloud Download",
|
|
||||||
style: context.textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
minHeight: 10.0,
|
|
||||||
value: uploadProgress / 100.0,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
" ${iCloudDownloadProgress.toStringAsFixed(0)}%",
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
|
|
||||||
buildUploadProgressBar() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (asset.iCloudAsset != null && asset.iCloudAsset!)
|
|
||||||
SizedBox(
|
|
||||||
width: 110,
|
|
||||||
child: Text(
|
|
||||||
"Immich Upload",
|
|
||||||
style: context.textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
minHeight: 10.0,
|
|
||||||
value: uploadProgress / 100.0,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
" ${uploadProgress.toStringAsFixed(0)}%",
|
|
||||||
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildUploadStats() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
uploadFileProgress,
|
|
||||||
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
formatUploadFileSpeed(uploadFileSpeed),
|
|
||||||
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return FutureBuilder<Asset?>(
|
|
||||||
future: ref.read(assetMediaRepositoryProvider).get(asset.id),
|
|
||||||
builder: (context, thumbnail) => ListTile(
|
|
||||||
isThreeLine: true,
|
|
||||||
leading: AnimatedCrossFade(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
firstChild: GestureDetector(
|
|
||||||
onTap: () => isShowThumbnail.value = false,
|
|
||||||
child: thumbnail.hasData
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
child: ImmichThumbnail(
|
|
||||||
asset: thumbnail.data,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(8.0),
|
|
||||||
child: CircularProgressIndicator.adaptive(
|
|
||||||
strokeWidth: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
secondChild: GestureDetector(
|
|
||||||
onTap: () => isShowThumbnail.value = true,
|
|
||||||
child: Icon(
|
|
||||||
Icons.image_outlined,
|
|
||||||
color: context.primaryColor,
|
|
||||||
size: 30,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
crossFadeState: isShowThumbnail.value
|
|
||||||
? CrossFadeState.showFirst
|
|
||||||
: CrossFadeState.showSecond,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
"backup_controller_page_uploading_file_info",
|
|
||||||
style: context.textTheme.titleSmall,
|
|
||||||
).tr(),
|
|
||||||
if (ref.watch(errorBackupListProvider).isNotEmpty) buildErrorChip(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
children: [
|
|
||||||
if (Platform.isIOS) buildiCloudDownloadProgerssBar(),
|
|
||||||
buildUploadProgressBar(),
|
|
||||||
buildUploadStats(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: buildAssetInfoTable(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/widgets/backup/error_chip_text.dart';
|
||||||
|
|
||||||
|
class BackupErrorChip extends ConsumerWidget {
|
||||||
|
const BackupErrorChip({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final hasErrors =
|
||||||
|
ref.watch(errorBackupListProvider.select((value) => value.isNotEmpty));
|
||||||
|
if (!hasErrors) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionChip(
|
||||||
|
avatar: const Icon(
|
||||||
|
Icons.info,
|
||||||
|
color: red400,
|
||||||
|
),
|
||||||
|
elevation: 1,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
label: const BackupErrorChipText(),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
|
|
||||||
|
class BackupErrorChipText extends ConsumerWidget {
|
||||||
|
const BackupErrorChipText({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final count = ref.watch(errorBackupListProvider).length;
|
||||||
|
if (count == 0) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Text(
|
||||||
|
"backup_controller_page_failed",
|
||||||
|
style: TextStyle(
|
||||||
|
color: red400,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
).tr(
|
||||||
|
args: [count.toString()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class IcloudDownloadProgressBar extends ConsumerWidget {
|
||||||
|
const IcloudDownloadProgressBar({super.key});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isIcloudAsset = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isIcloudAsset) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final iCloudDownloadProgress = ref
|
||||||
|
.watch(backupProvider.select((value) => value.iCloudDownloadProgress));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Text(
|
||||||
|
"iCloud Download",
|
||||||
|
style: context.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 10.0,
|
||||||
|
value: iCloudDownloadProgress / 100.0,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
" ${iCloudDownloadProgress ~/ 1}%",
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class BackupUploadProgressBar extends ConsumerWidget {
|
||||||
|
const BackupUploadProgressBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isIcloudAsset = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
);
|
||||||
|
|
||||||
|
final uploadProgress = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.progressInPercentage),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider.select((value) => value.progressInPercentage),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (isIcloudAsset)
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Text(
|
||||||
|
"Immich Upload",
|
||||||
|
style: context.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 10.0,
|
||||||
|
value: uploadProgress / 100.0,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
" ${uploadProgress.toStringAsFixed(0)}%",
|
||||||
|
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class BackupUploadStats extends ConsumerWidget {
|
||||||
|
const BackupUploadStats({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final uploadFileProgress = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.progressInFileSize),
|
||||||
|
)
|
||||||
|
: ref.watch(backupProvider.select((value) => value.progressInFileSize));
|
||||||
|
|
||||||
|
final uploadFileSpeed = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.progressInFileSpeed),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider.select((value) => value.progressInFileSpeed),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
uploadFileProgress,
|
||||||
|
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatUploadFileSpeed(uploadFileSpeed),
|
||||||
|
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
String _formatUploadFileSpeed(double uploadFileSpeed) {
|
||||||
|
if (uploadFileSpeed < 1024) {
|
||||||
|
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
|
||||||
|
} else if (uploadFileSpeed < 1024 * 1024) {
|
||||||
|
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
|
||||||
|
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
|
||||||
|
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
|
||||||
|
} else {
|
||||||
|
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
bool isHorizontal = !context.isMobile;
|
bool isHorizontal = !context.isMobile;
|
||||||
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isLoggingOut = useState(false);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -63,11 +64,16 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildActionButton(IconData icon, String text, Function() onTap) {
|
buildActionButton(
|
||||||
|
IconData icon,
|
||||||
|
String text,
|
||||||
|
Function() onTap, {
|
||||||
|
Widget? trailing,
|
||||||
|
}) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.standard,
|
visualDensity: VisualDensity.standard,
|
||||||
contentPadding: const EdgeInsets.only(left: 30),
|
contentPadding: const EdgeInsets.only(left: 30, right: 30),
|
||||||
minLeadingWidth: 40,
|
minLeadingWidth: 40,
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -83,6 +89,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
trailing: trailing,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +114,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
Icons.logout_rounded,
|
Icons.logout_rounded,
|
||||||
"profile_drawer_sign_out",
|
"profile_drawer_sign_out",
|
||||||
() async {
|
() async {
|
||||||
|
if (isLoggingOut.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
@@ -115,7 +126,11 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
content: "app_bar_signout_dialog_content",
|
content: "app_bar_signout_dialog_content",
|
||||||
ok: "app_bar_signout_dialog_ok",
|
ok: "app_bar_signout_dialog_ok",
|
||||||
onOk: () async {
|
onOk: () async {
|
||||||
await ref.read(authenticationProvider.notifier).logout();
|
isLoggingOut.value = true;
|
||||||
|
await ref
|
||||||
|
.read(authenticationProvider.notifier)
|
||||||
|
.logout()
|
||||||
|
.whenComplete(() => isLoggingOut.value = false);
|
||||||
|
|
||||||
ref.read(manualUploadProvider.notifier).cancelBackup();
|
ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||||
ref.read(backupProvider.notifier).cancelBackup();
|
ref.read(backupProvider.notifier).cancelBackup();
|
||||||
@@ -127,6 +142,12 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
trailing: isLoggingOut.value
|
||||||
|
? SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Dismissible(
|
return Dismissible(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
direction: DismissDirection.down,
|
direction: DismissDirection.down,
|
||||||
onDismissed: (_) => Navigator.of(context).pop(),
|
onDismissed: (_) => Navigator.of(context).pop(),
|
||||||
key: const Key('app_bar_dialog'),
|
key: const Key('app_bar_dialog'),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ImmichThumbnail extends HookWidget {
|
|||||||
static ImageProvider imageProvider({
|
static ImageProvider imageProvider({
|
||||||
Asset? asset,
|
Asset? asset,
|
||||||
String? assetId,
|
String? assetId,
|
||||||
int thumbnailSize = 256,
|
int thumbnailSize = 128,
|
||||||
}) {
|
}) {
|
||||||
if (asset == null && assetId == null) {
|
if (asset == null && assetId == null) {
|
||||||
throw Exception('Must supply either asset or assetId');
|
throw Exception('Must supply either asset or assetId');
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class ServerStatsResponseDto {
|
|||||||
this.photos = 0,
|
this.photos = 0,
|
||||||
this.usage = 0,
|
this.usage = 0,
|
||||||
this.usageByUser = const [],
|
this.usageByUser = const [],
|
||||||
|
this.usagePhotos = 0,
|
||||||
|
this.usageVideos = 0,
|
||||||
this.videos = 0,
|
this.videos = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,6 +27,10 @@ class ServerStatsResponseDto {
|
|||||||
|
|
||||||
List<UsageByUserDto> usageByUser;
|
List<UsageByUserDto> usageByUser;
|
||||||
|
|
||||||
|
int usagePhotos;
|
||||||
|
|
||||||
|
int usageVideos;
|
||||||
|
|
||||||
int videos;
|
int videos;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,6 +38,8 @@ class ServerStatsResponseDto {
|
|||||||
other.photos == photos &&
|
other.photos == photos &&
|
||||||
other.usage == usage &&
|
other.usage == usage &&
|
||||||
_deepEquality.equals(other.usageByUser, usageByUser) &&
|
_deepEquality.equals(other.usageByUser, usageByUser) &&
|
||||||
|
other.usagePhotos == usagePhotos &&
|
||||||
|
other.usageVideos == usageVideos &&
|
||||||
other.videos == videos;
|
other.videos == videos;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,16 +48,20 @@ class ServerStatsResponseDto {
|
|||||||
(photos.hashCode) +
|
(photos.hashCode) +
|
||||||
(usage.hashCode) +
|
(usage.hashCode) +
|
||||||
(usageByUser.hashCode) +
|
(usageByUser.hashCode) +
|
||||||
|
(usagePhotos.hashCode) +
|
||||||
|
(usageVideos.hashCode) +
|
||||||
(videos.hashCode);
|
(videos.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, videos=$videos]';
|
String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, usagePhotos=$usagePhotos, usageVideos=$usageVideos, videos=$videos]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'photos'] = this.photos;
|
json[r'photos'] = this.photos;
|
||||||
json[r'usage'] = this.usage;
|
json[r'usage'] = this.usage;
|
||||||
json[r'usageByUser'] = this.usageByUser;
|
json[r'usageByUser'] = this.usageByUser;
|
||||||
|
json[r'usagePhotos'] = this.usagePhotos;
|
||||||
|
json[r'usageVideos'] = this.usageVideos;
|
||||||
json[r'videos'] = this.videos;
|
json[r'videos'] = this.videos;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@@ -66,6 +78,8 @@ class ServerStatsResponseDto {
|
|||||||
photos: mapValueOfType<int>(json, r'photos')!,
|
photos: mapValueOfType<int>(json, r'photos')!,
|
||||||
usage: mapValueOfType<int>(json, r'usage')!,
|
usage: mapValueOfType<int>(json, r'usage')!,
|
||||||
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']),
|
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']),
|
||||||
|
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
|
||||||
|
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
|
||||||
videos: mapValueOfType<int>(json, r'videos')!,
|
videos: mapValueOfType<int>(json, r'videos')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,6 +131,8 @@ class ServerStatsResponseDto {
|
|||||||
'photos',
|
'photos',
|
||||||
'usage',
|
'usage',
|
||||||
'usageByUser',
|
'usageByUser',
|
||||||
|
'usagePhotos',
|
||||||
|
'usageVideos',
|
||||||
'videos',
|
'videos',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class UsageByUserDto {
|
|||||||
required this.photos,
|
required this.photos,
|
||||||
required this.quotaSizeInBytes,
|
required this.quotaSizeInBytes,
|
||||||
required this.usage,
|
required this.usage,
|
||||||
|
required this.usagePhotos,
|
||||||
|
required this.usageVideos,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.userName,
|
required this.userName,
|
||||||
required this.videos,
|
required this.videos,
|
||||||
@@ -27,6 +29,10 @@ class UsageByUserDto {
|
|||||||
|
|
||||||
int usage;
|
int usage;
|
||||||
|
|
||||||
|
int usagePhotos;
|
||||||
|
|
||||||
|
int usageVideos;
|
||||||
|
|
||||||
String userId;
|
String userId;
|
||||||
|
|
||||||
String userName;
|
String userName;
|
||||||
@@ -38,6 +44,8 @@ class UsageByUserDto {
|
|||||||
other.photos == photos &&
|
other.photos == photos &&
|
||||||
other.quotaSizeInBytes == quotaSizeInBytes &&
|
other.quotaSizeInBytes == quotaSizeInBytes &&
|
||||||
other.usage == usage &&
|
other.usage == usage &&
|
||||||
|
other.usagePhotos == usagePhotos &&
|
||||||
|
other.usageVideos == usageVideos &&
|
||||||
other.userId == userId &&
|
other.userId == userId &&
|
||||||
other.userName == userName &&
|
other.userName == userName &&
|
||||||
other.videos == videos;
|
other.videos == videos;
|
||||||
@@ -48,12 +56,14 @@ class UsageByUserDto {
|
|||||||
(photos.hashCode) +
|
(photos.hashCode) +
|
||||||
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
|
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
|
||||||
(usage.hashCode) +
|
(usage.hashCode) +
|
||||||
|
(usagePhotos.hashCode) +
|
||||||
|
(usageVideos.hashCode) +
|
||||||
(userId.hashCode) +
|
(userId.hashCode) +
|
||||||
(userName.hashCode) +
|
(userName.hashCode) +
|
||||||
(videos.hashCode);
|
(videos.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, userId=$userId, userName=$userName, videos=$videos]';
|
String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, usagePhotos=$usagePhotos, usageVideos=$usageVideos, userId=$userId, userName=$userName, videos=$videos]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -64,6 +74,8 @@ class UsageByUserDto {
|
|||||||
// json[r'quotaSizeInBytes'] = null;
|
// json[r'quotaSizeInBytes'] = null;
|
||||||
}
|
}
|
||||||
json[r'usage'] = this.usage;
|
json[r'usage'] = this.usage;
|
||||||
|
json[r'usagePhotos'] = this.usagePhotos;
|
||||||
|
json[r'usageVideos'] = this.usageVideos;
|
||||||
json[r'userId'] = this.userId;
|
json[r'userId'] = this.userId;
|
||||||
json[r'userName'] = this.userName;
|
json[r'userName'] = this.userName;
|
||||||
json[r'videos'] = this.videos;
|
json[r'videos'] = this.videos;
|
||||||
@@ -82,6 +94,8 @@ class UsageByUserDto {
|
|||||||
photos: mapValueOfType<int>(json, r'photos')!,
|
photos: mapValueOfType<int>(json, r'photos')!,
|
||||||
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
|
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
|
||||||
usage: mapValueOfType<int>(json, r'usage')!,
|
usage: mapValueOfType<int>(json, r'usage')!,
|
||||||
|
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
|
||||||
|
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
|
||||||
userId: mapValueOfType<String>(json, r'userId')!,
|
userId: mapValueOfType<String>(json, r'userId')!,
|
||||||
userName: mapValueOfType<String>(json, r'userName')!,
|
userName: mapValueOfType<String>(json, r'userName')!,
|
||||||
videos: mapValueOfType<int>(json, r'videos')!,
|
videos: mapValueOfType<int>(json, r'videos')!,
|
||||||
@@ -135,6 +149,8 @@ class UsageByUserDto {
|
|||||||
'photos',
|
'photos',
|
||||||
'quotaSizeInBytes',
|
'quotaSizeInBytes',
|
||||||
'usage',
|
'usage',
|
||||||
|
'usagePhotos',
|
||||||
|
'usageVideos',
|
||||||
'userId',
|
'userId',
|
||||||
'userName',
|
'userName',
|
||||||
'videos',
|
'videos',
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ void main() {
|
|||||||
.thenAnswer((_) async => []);
|
.thenAnswer((_) async => []);
|
||||||
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
|
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
|
||||||
.thenAnswer((_) async => []);
|
.thenAnswer((_) async => []);
|
||||||
|
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => []);
|
||||||
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
|
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
|
||||||
when(() => syncService.removeAllLocalAlbumsAndAssets())
|
when(() => syncService.removeAllLocalAlbumsAndAssets())
|
||||||
.thenAnswer((_) async => true);
|
.thenAnswer((_) async => true);
|
||||||
|
|||||||
@@ -10966,7 +10966,9 @@
|
|||||||
{
|
{
|
||||||
"photos": 1,
|
"photos": 1,
|
||||||
"videos": 1,
|
"videos": 1,
|
||||||
"diskUsageRaw": 1
|
"diskUsageRaw": 2,
|
||||||
|
"usagePhotos": 1,
|
||||||
|
"usageVideos": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"items": {
|
"items": {
|
||||||
@@ -10975,6 +10977,16 @@
|
|||||||
"title": "Array of usage for each user",
|
"title": "Array of usage for each user",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"usagePhotos": {
|
||||||
|
"default": 0,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"usageVideos": {
|
||||||
|
"default": 0,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"videos": {
|
"videos": {
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -10984,6 +10996,8 @@
|
|||||||
"photos",
|
"photos",
|
||||||
"usage",
|
"usage",
|
||||||
"usageByUser",
|
"usageByUser",
|
||||||
|
"usagePhotos",
|
||||||
|
"usageVideos",
|
||||||
"videos"
|
"videos"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -12503,6 +12517,14 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"usagePhotos": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"usageVideos": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -12517,6 +12539,8 @@
|
|||||||
"photos",
|
"photos",
|
||||||
"quotaSizeInBytes",
|
"quotaSizeInBytes",
|
||||||
"usage",
|
"usage",
|
||||||
|
"usagePhotos",
|
||||||
|
"usageVideos",
|
||||||
"userId",
|
"userId",
|
||||||
"userName",
|
"userName",
|
||||||
"videos"
|
"videos"
|
||||||
|
|||||||
@@ -969,6 +969,8 @@ export type UsageByUserDto = {
|
|||||||
photos: number;
|
photos: number;
|
||||||
quotaSizeInBytes: number | null;
|
quotaSizeInBytes: number | null;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
usagePhotos: number;
|
||||||
|
usageVideos: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
videos: number;
|
videos: number;
|
||||||
@@ -977,6 +979,8 @@ export type ServerStatsResponseDto = {
|
|||||||
photos: number;
|
photos: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
usageByUser: UsageByUserDto[];
|
usageByUser: UsageByUserDto[];
|
||||||
|
usagePhotos: number;
|
||||||
|
usageVideos: number;
|
||||||
videos: number;
|
videos: number;
|
||||||
};
|
};
|
||||||
export type ServerStorageResponseDto = {
|
export type ServerStorageResponseDto = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
|||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||||
|
import { getAssetDateTime } from 'src/utils/date-time';
|
||||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class AlbumInfoDto {
|
export class AlbumInfoDto {
|
||||||
@@ -164,8 +165,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
|
|||||||
const hasSharedLink = entity.sharedLinks?.length > 0;
|
const hasSharedLink = entity.sharedLinks?.length > 0;
|
||||||
const hasSharedUser = sharedUsers.length > 0;
|
const hasSharedUser = sharedUsers.length > 0;
|
||||||
|
|
||||||
let startDate = assets.at(0)?.fileCreatedAt || undefined;
|
let startDate = getAssetDateTime(assets.at(0));
|
||||||
let endDate = assets.at(-1)?.fileCreatedAt || undefined;
|
let endDate = getAssetDateTime(assets.at(-1));
|
||||||
// Swap dates if start date is greater than end date.
|
// Swap dates if start date is greater than end date.
|
||||||
if (startDate && endDate && startDate > endDate) {
|
if (startDate && endDate && startDate > endDate) {
|
||||||
[startDate, endDate] = [endDate, startDate];
|
[startDate, endDate] = [endDate, startDate];
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export class UsageByUserDto {
|
|||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
usage!: number;
|
usage!: number;
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usagePhotos!: number;
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usageVideos!: number;
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
quotaSizeInBytes!: number | null;
|
quotaSizeInBytes!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +103,12 @@ export class ServerStatsResponseDto {
|
|||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
usage = 0;
|
usage = 0;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usagePhotos = 0;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usageVideos = 0;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
isArray: true,
|
isArray: true,
|
||||||
type: UsageByUserDto,
|
type: UsageByUserDto,
|
||||||
@@ -107,7 +117,9 @@ export class ServerStatsResponseDto {
|
|||||||
{
|
{
|
||||||
photos: 1,
|
photos: 1,
|
||||||
videos: 1,
|
videos: 1,
|
||||||
diskUsageRaw: 1,
|
diskUsageRaw: 2,
|
||||||
|
usagePhotos: 1,
|
||||||
|
usageVideos: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -114,7 +114,12 @@ export interface ImageBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecSWConfig {
|
export interface VideoCodecSWConfig {
|
||||||
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
|
getCommand(
|
||||||
|
target: TranscodeTarget,
|
||||||
|
videoStream: VideoStreamInfo,
|
||||||
|
audioStream: AudioStreamInfo,
|
||||||
|
format?: VideoFormat,
|
||||||
|
): TranscodeCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface UserStatsQueryResponse {
|
|||||||
photos: number;
|
photos: number;
|
||||||
videos: number;
|
videos: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
usagePhotos: number;
|
||||||
|
usageVideos: number;
|
||||||
quotaSizeInBytes: number | null;
|
quotaSizeInBytes: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,23 @@ SELECT
|
|||||||
"assets"."libraryId" IS NULL
|
"assets"."libraryId" IS NULL
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
) AS "usage"
|
) AS "usage",
|
||||||
|
COALESCE(
|
||||||
|
SUM("exif"."fileSizeInByte") FILTER (
|
||||||
|
WHERE
|
||||||
|
"assets"."libraryId" IS NULL
|
||||||
|
AND "assets"."type" = 'IMAGE'
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS "usagePhotos",
|
||||||
|
COALESCE(
|
||||||
|
SUM("exif"."fileSizeInByte") FILTER (
|
||||||
|
WHERE
|
||||||
|
"assets"."libraryId" IS NULL
|
||||||
|
AND "assets"."type" = 'VIDEO'
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS "usageVideos"
|
||||||
FROM
|
FROM
|
||||||
"users" "users"
|
"users" "users"
|
||||||
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id"
|
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id"
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export class StackRepository implements IStackRepository {
|
|||||||
relations: {
|
relations: {
|
||||||
assets: {
|
assets: {
|
||||||
exifInfo: true,
|
exifInfo: true,
|
||||||
|
tags: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ export class UserRepository implements IUserRepository {
|
|||||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
||||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
||||||
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
|
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`,
|
||||||
|
'usagePhotos',
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`,
|
||||||
|
'usageVideos',
|
||||||
|
)
|
||||||
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
|
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
|
||||||
.leftJoin('users.assets', 'assets')
|
.leftJoin('users.assets', 'assets')
|
||||||
.leftJoin('assets.exifInfo', 'exif')
|
.leftJoin('assets.exifInfo', 'exif')
|
||||||
@@ -119,6 +127,8 @@ export class UserRepository implements IUserRepository {
|
|||||||
stat.photos = Number(stat.photos);
|
stat.photos = Number(stat.photos);
|
||||||
stat.videos = Number(stat.videos);
|
stat.videos = Number(stat.videos);
|
||||||
stat.usage = Number(stat.usage);
|
stat.usage = Number(stat.usage);
|
||||||
|
stat.usagePhotos = Number(stat.usagePhotos);
|
||||||
|
stat.usageVideos = Number(stat.usageVideos);
|
||||||
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
|
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -487,6 +487,22 @@ describe(MediaService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('should not skip intra frames for MTS file', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamMTS);
|
||||||
|
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||||
|
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||||
|
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||||
|
expect.objectContaining({
|
||||||
|
inputOptions: ['-sws_flags accurate_rnd+full_chroma_int'],
|
||||||
|
outputOptions: expect.any(Array),
|
||||||
|
progress: expect.any(Object),
|
||||||
|
twoPass: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export class MediaService extends BaseService {
|
|||||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||||
this.storageCore.ensureFolders(previewPath);
|
this.storageCore.ensureFolders(previewPath);
|
||||||
|
|
||||||
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||||
const mainVideoStream = this.getMainStream(videoStreams);
|
const mainVideoStream = this.getMainStream(videoStreams);
|
||||||
if (!mainVideoStream) {
|
if (!mainVideoStream) {
|
||||||
throw new Error(`No video streams found for asset ${asset.id}`);
|
throw new Error(`No video streams found for asset ${asset.id}`);
|
||||||
@@ -248,9 +248,14 @@ export class MediaService extends BaseService {
|
|||||||
|
|
||||||
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
||||||
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
||||||
|
const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream, format);
|
||||||
const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
|
const thumbnailOptions = thumbnailConfig.getCommand(
|
||||||
const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
|
TranscodeTarget.VIDEO,
|
||||||
|
mainVideoStream,
|
||||||
|
mainAudioStream,
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
this.logger.error(format.formatName);
|
||||||
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
||||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ describe(ServerService.name, () => {
|
|||||||
photos: 10,
|
photos: 10,
|
||||||
videos: 11,
|
videos: 11,
|
||||||
usage: 12_345,
|
usage: 12_345,
|
||||||
|
usagePhotos: 1,
|
||||||
|
usageVideos: 11_345,
|
||||||
quotaSizeInBytes: 0,
|
quotaSizeInBytes: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -193,6 +195,8 @@ describe(ServerService.name, () => {
|
|||||||
photos: 10,
|
photos: 10,
|
||||||
videos: 20,
|
videos: 20,
|
||||||
usage: 123_456,
|
usage: 123_456,
|
||||||
|
usagePhotos: 100,
|
||||||
|
usageVideos: 23_456,
|
||||||
quotaSizeInBytes: 0,
|
quotaSizeInBytes: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -201,6 +205,8 @@ describe(ServerService.name, () => {
|
|||||||
photos: 100,
|
photos: 100,
|
||||||
videos: 0,
|
videos: 0,
|
||||||
usage: 987_654,
|
usage: 987_654,
|
||||||
|
usagePhotos: 900,
|
||||||
|
usageVideos: 87_654,
|
||||||
quotaSizeInBytes: 0,
|
quotaSizeInBytes: 0,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -209,11 +215,15 @@ describe(ServerService.name, () => {
|
|||||||
photos: 120,
|
photos: 120,
|
||||||
videos: 31,
|
videos: 31,
|
||||||
usage: 1_123_455,
|
usage: 1_123_455,
|
||||||
|
usagePhotos: 1001,
|
||||||
|
usageVideos: 122_455,
|
||||||
usageByUser: [
|
usageByUser: [
|
||||||
{
|
{
|
||||||
photos: 10,
|
photos: 10,
|
||||||
quotaSizeInBytes: 0,
|
quotaSizeInBytes: 0,
|
||||||
usage: 12_345,
|
usage: 12_345,
|
||||||
|
usagePhotos: 1,
|
||||||
|
usageVideos: 11_345,
|
||||||
userName: '1 User',
|
userName: '1 User',
|
||||||
userId: 'user1',
|
userId: 'user1',
|
||||||
videos: 11,
|
videos: 11,
|
||||||
@@ -222,6 +232,8 @@ describe(ServerService.name, () => {
|
|||||||
photos: 10,
|
photos: 10,
|
||||||
quotaSizeInBytes: 0,
|
quotaSizeInBytes: 0,
|
||||||
usage: 123_456,
|
usage: 123_456,
|
||||||
|
usagePhotos: 100,
|
||||||
|
usageVideos: 23_456,
|
||||||
userName: '2 User',
|
userName: '2 User',
|
||||||
userId: 'user2',
|
userId: 'user2',
|
||||||
videos: 20,
|
videos: 20,
|
||||||
@@ -230,6 +242,8 @@ describe(ServerService.name, () => {
|
|||||||
photos: 100,
|
photos: 100,
|
||||||
quotaSizeInBytes: 0,
|
quotaSizeInBytes: 0,
|
||||||
usage: 987_654,
|
usage: 987_654,
|
||||||
|
usagePhotos: 900,
|
||||||
|
usageVideos: 87_654,
|
||||||
userName: '3 User',
|
userName: '3 User',
|
||||||
userId: 'user3',
|
userId: 'user3',
|
||||||
videos: 0,
|
videos: 0,
|
||||||
|
|||||||
@@ -126,11 +126,16 @@ export class ServerService extends BaseService {
|
|||||||
usage.photos = user.photos;
|
usage.photos = user.photos;
|
||||||
usage.videos = user.videos;
|
usage.videos = user.videos;
|
||||||
usage.usage = user.usage;
|
usage.usage = user.usage;
|
||||||
|
usage.usagePhotos = user.usagePhotos;
|
||||||
|
usage.usageVideos = user.usageVideos;
|
||||||
usage.quotaSizeInBytes = user.quotaSizeInBytes;
|
usage.quotaSizeInBytes = user.quotaSizeInBytes;
|
||||||
|
|
||||||
serverStats.photos += usage.photos;
|
serverStats.photos += usage.photos;
|
||||||
serverStats.videos += usage.videos;
|
serverStats.videos += usage.videos;
|
||||||
serverStats.usage += usage.usage;
|
serverStats.usage += usage.usage;
|
||||||
|
serverStats.usagePhotos += usage.usagePhotos;
|
||||||
|
serverStats.usageVideos += usage.usageVideos;
|
||||||
|
|
||||||
serverStats.usageByUser.push(usage);
|
serverStats.usageByUser.push(usage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
|
||||||
|
export const getAssetDateTime = (asset: AssetEntity | undefined) => {
|
||||||
|
return asset?.exifInfo?.dateTimeOriginal || asset?.fileCreatedAt;
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TranscodeCommand,
|
TranscodeCommand,
|
||||||
VideoCodecHWConfig,
|
VideoCodecHWConfig,
|
||||||
VideoCodecSWConfig,
|
VideoCodecSWConfig,
|
||||||
|
VideoFormat,
|
||||||
VideoStreamInfo,
|
VideoStreamInfo,
|
||||||
} from 'src/interfaces/media.interface';
|
} from 'src/interfaces/media.interface';
|
||||||
|
|
||||||
@@ -77,9 +78,14 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
getCommand(
|
||||||
|
target: TranscodeTarget,
|
||||||
|
videoStream: VideoStreamInfo,
|
||||||
|
audioStream?: AudioStreamInfo,
|
||||||
|
format?: VideoFormat,
|
||||||
|
) {
|
||||||
const options = {
|
const options = {
|
||||||
inputOptions: this.getBaseInputOptions(videoStream),
|
inputOptions: this.getBaseInputOptions(videoStream, format),
|
||||||
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
|
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
|
||||||
twoPass: this.eligibleForTwoPass(),
|
twoPass: this.eligibleForTwoPass(),
|
||||||
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
|
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
|
||||||
@@ -101,7 +107,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
getBaseInputOptions(videoStream: VideoStreamInfo): string[] {
|
getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
|
||||||
return this.getInputThreadOptions();
|
return this.getInputThreadOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,8 +383,11 @@ export class ThumbnailConfig extends BaseConfig {
|
|||||||
return new ThumbnailConfig(config);
|
return new ThumbnailConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseInputOptions(): string[] {
|
getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
|
||||||
return ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
|
// skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details.
|
||||||
|
return format?.formatName === 'mpegts'
|
||||||
|
? ['-sws_flags accurate_rnd+full_chroma_int']
|
||||||
|
: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseOutputOptions() {
|
getBaseOutputOptions() {
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ export const probeStub = {
|
|||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
|
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
|
||||||
}),
|
}),
|
||||||
|
videoStreamMTS: Object.freeze<VideoInfo>({
|
||||||
|
...probeStubDefault,
|
||||||
|
format: {
|
||||||
|
...probeStubDefaultFormat,
|
||||||
|
formatName: 'mpegts',
|
||||||
|
},
|
||||||
|
}),
|
||||||
videoStreamHDR: Object.freeze<VideoInfo>({
|
videoStreamHDR: Object.freeze<VideoInfo>({
|
||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
videoStreams: [
|
videoStreams: [
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"@faker-js/faker": "^9.0.0",
|
"@faker-js/faker": "^9.0.0",
|
||||||
"@socket.io/component-emitter": "^3.1.0",
|
"@socket.io/component-emitter": "^3.1.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
"@sveltejs/enhanced-img": "^0.3.0",
|
"@sveltejs/enhanced-img": "^0.3.9",
|
||||||
"@sveltejs/kit": "^2.7.2",
|
"@sveltejs/kit": "^2.7.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.43.0",
|
"eslint-plugin-svelte": "^2.45.1",
|
||||||
"eslint-plugin-unicorn": "^55.0.0",
|
"eslint-plugin-unicorn": "^55.0.0",
|
||||||
"factory.ts": "^1.4.1",
|
"factory.ts": "^1.4.1",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.4.4",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@faker-js/faker": "^9.0.0",
|
"@faker-js/faker": "^9.0.0",
|
||||||
"@socket.io/component-emitter": "^3.1.0",
|
"@socket.io/component-emitter": "^3.1.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
"@sveltejs/enhanced-img": "^0.3.0",
|
"@sveltejs/enhanced-img": "^0.3.9",
|
||||||
"@sveltejs/kit": "^2.7.2",
|
"@sveltejs/kit": "^2.7.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.43.0",
|
"eslint-plugin-svelte": "^2.45.1",
|
||||||
"eslint-plugin-unicorn": "^55.0.0",
|
"eslint-plugin-unicorn": "^55.0.0",
|
||||||
"factory.ts": "^1.4.1",
|
"factory.ts": "^1.4.1",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.4.4",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
|
|
||||||
export let show: boolean;
|
interface Props {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { show = $bindable() }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button type="button" on:click={() => (show = true)}>Open</button>
|
<button type="button" onclick={() => (show = true)}>Open</button>
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<div use:focusTrap>
|
<div use:focusTrap>
|
||||||
<div>
|
<div>
|
||||||
<span>text</span>
|
<span>text</span>
|
||||||
<button data-testid="one" type="button" on:click={() => (show = false)}>Close</button>
|
<button data-testid="one" type="button" onclick={() => (show = false)}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
<input data-testid="two" disabled />
|
<input data-testid="two" disabled />
|
||||||
<input data-testid="three" />
|
<input data-testid="three" />
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
|
import { tick } from 'svelte';
|
||||||
if (!textarea) {
|
import type { Action } from 'svelte/action';
|
||||||
return;
|
|
||||||
}
|
type Parameters = {
|
||||||
textarea.style.height = height;
|
height?: string;
|
||||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
value: string; // added to enable reactivity
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoGrowHeight: Action<HTMLTextAreaElement, Parameters> = (textarea, { height = 'auto' }) => {
|
||||||
|
const update = () => {
|
||||||
|
void tick().then(() => {
|
||||||
|
textarea.style.height = height;
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
return { update };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Options {
|
|||||||
/**
|
/**
|
||||||
* The container element that with direct children that should be navigated.
|
* The container element that with direct children that should be navigated.
|
||||||
*/
|
*/
|
||||||
container: HTMLElement;
|
container?: HTMLElement;
|
||||||
/**
|
/**
|
||||||
* Indicates if the dropdown is open.
|
* Indicates if the dropdown is open.
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +52,11 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
|
|||||||
await tick();
|
await tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = Array.from(container.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ import type { Action } from 'svelte/action';
|
|||||||
* @param node Element which listens for keyboard events
|
* @param node Element which listens for keyboard events
|
||||||
* @param container Element containing the list of elements
|
* @param container Element containing the list of elements
|
||||||
*/
|
*/
|
||||||
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
|
export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
|
||||||
|
node: HTMLElement,
|
||||||
|
container?: HTMLElement,
|
||||||
|
) => {
|
||||||
const moveFocus = (direction: 'up' | 'down') => {
|
const moveFocus = (direction: 'up' | 'down') => {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const children = Array.from(container?.children);
|
const children = Array.from(container?.children);
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
|
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
interface Props {
|
||||||
export let onSuccess: () => void;
|
user: UserResponseDto;
|
||||||
export let onFail: () => void;
|
onSuccess: () => void;
|
||||||
export let onCancel: () => void;
|
onFail: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
let forceDelete = false;
|
let { user, onSuccess, onFail, onCancel }: Props = $props();
|
||||||
let deleteButtonDisabled = false;
|
|
||||||
|
let forceDelete = $state(false);
|
||||||
|
let deleteButtonDisabled = $state(false);
|
||||||
let userIdInput: string = '';
|
let userIdInput: string = '';
|
||||||
|
|
||||||
const handleDeleteUser = async () => {
|
const handleDeleteUser = async () => {
|
||||||
@@ -47,12 +51,14 @@
|
|||||||
{onCancel}
|
{onCancel}
|
||||||
disabled={deleteButtonDisabled}
|
disabled={deleteButtonDisabled}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
{#snippet promptSnippet()}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#if forceDelete}
|
{#if forceDelete}
|
||||||
<p>
|
<p>
|
||||||
<FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }} let:message>
|
<FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }}>
|
||||||
<b>{message}</b>
|
{#snippet children({ message })}
|
||||||
|
<b>{message}</b>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -60,9 +66,10 @@
|
|||||||
<FormatMessage
|
<FormatMessage
|
||||||
key="admin.user_delete_delay"
|
key="admin.user_delete_delay"
|
||||||
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
|
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
|
||||||
let:message
|
|
||||||
>
|
>
|
||||||
<b>{message}</b>
|
{#snippet children({ message })}
|
||||||
|
<b>{message}</b>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -73,7 +80,7 @@
|
|||||||
label={$t('admin.user_delete_immediately_checkbox')}
|
label={$t('admin.user_delete_immediately_checkbox')}
|
||||||
labelClass="text-sm dark:text-immich-dark-fg"
|
labelClass="text-sm dark:text-immich-dark-fg"
|
||||||
bind:checked={forceDelete}
|
bind:checked={forceDelete}
|
||||||
on:change={() => {
|
onchange={() => {
|
||||||
deleteButtonDisabled = forceDelete;
|
deleteButtonDisabled = forceDelete;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -92,9 +99,9 @@
|
|||||||
aria-describedby="confirm-user-desc"
|
aria-describedby="confirm-user-desc"
|
||||||
name="confirm-user-id"
|
name="confirm-user-id"
|
||||||
type="text"
|
type="text"
|
||||||
on:input={handleConfirm}
|
oninput={handleConfirm}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<script lang="ts" context="module">
|
<script lang="ts" module>
|
||||||
export type Colors = 'light-gray' | 'gray' | 'dark-gray';
|
export type Colors = 'light-gray' | 'gray' | 'dark-gray';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let color: Colors;
|
import type { Snippet } from 'svelte';
|
||||||
export let disabled = false;
|
|
||||||
|
interface Props {
|
||||||
|
color: Colors;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: Snippet;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { color, disabled = false, onClick = () => {}, children }: Props = $props();
|
||||||
|
|
||||||
const colorClasses: Record<Colors, string> = {
|
const colorClasses: Record<Colors, string> = {
|
||||||
'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
|
'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
|
||||||
@@ -23,7 +31,7 @@
|
|||||||
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
|
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
|
||||||
color
|
color
|
||||||
]} {hoverClasses}"
|
]} {hoverClasses}"
|
||||||
on:click
|
onclick={onClick}
|
||||||
>
|
>
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<script lang="ts" context="module">
|
<script lang="ts" module>
|
||||||
export type Color = 'success' | 'warning';
|
export type Color = 'success' | 'warning';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let color: Color;
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
color: Color;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { color, children }: Props = $props();
|
||||||
|
|
||||||
const colorClasses: Record<Color, string> = {
|
const colorClasses: Record<Color, string> = {
|
||||||
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
|
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
|
||||||
@@ -12,5 +19,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full p-2 text-center text-sm {colorClasses[color]}">
|
<div class="w-full p-2 text-center text-sm {colorClasses[color]}">
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,22 +19,37 @@
|
|||||||
import JobTileButton from './job-tile-button.svelte';
|
import JobTileButton from './job-tile-button.svelte';
|
||||||
import JobTileStatus from './job-tile-status.svelte';
|
import JobTileStatus from './job-tile-status.svelte';
|
||||||
|
|
||||||
export let title: string;
|
interface Props {
|
||||||
export let subtitle: string | undefined;
|
title: string;
|
||||||
export let description: Component | undefined;
|
subtitle: string | undefined;
|
||||||
export let jobCounts: JobCountsDto;
|
description: Component | undefined;
|
||||||
export let queueStatus: QueueStatusDto;
|
jobCounts: JobCountsDto;
|
||||||
export let icon: string;
|
queueStatus: QueueStatusDto;
|
||||||
export let disabled = false;
|
icon: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
allText: string | undefined;
|
||||||
|
refreshText: string | undefined;
|
||||||
|
missingText: string;
|
||||||
|
onCommand: (command: JobCommandDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export let allText: string | undefined;
|
let {
|
||||||
export let refreshText: string | undefined;
|
title,
|
||||||
export let missingText: string;
|
subtitle,
|
||||||
export let onCommand: (command: JobCommandDto) => void;
|
description,
|
||||||
|
jobCounts,
|
||||||
|
queueStatus,
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
allText,
|
||||||
|
refreshText,
|
||||||
|
missingText,
|
||||||
|
onCommand,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
$: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
|
let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed);
|
||||||
$: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
|
let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
|
||||||
$: multipleButtons = allText || refreshText;
|
let multipleButtons = $derived(allText || refreshText);
|
||||||
|
|
||||||
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
|
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
|
||||||
</script>
|
</script>
|
||||||
@@ -67,7 +82,7 @@
|
|||||||
title={$t('clear_message')}
|
title={$t('clear_message')}
|
||||||
size="12"
|
size="12"
|
||||||
padding="1"
|
padding="1"
|
||||||
on:click={() => onCommand({ command: JobCommand.ClearFailed, force: false })}
|
onclick={() => onCommand({ command: JobCommand.ClearFailed, force: false })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -87,8 +102,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if description}
|
{#if description}
|
||||||
|
{@const SvelteComponent = description}
|
||||||
<div class="text-sm dark:text-white">
|
<div class="text-sm dark:text-white">
|
||||||
<svelte:component this={description} />
|
<SvelteComponent />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -118,7 +134,7 @@
|
|||||||
<JobTileButton
|
<JobTileButton
|
||||||
disabled={true}
|
disabled={true}
|
||||||
color="light-gray"
|
color="light-gray"
|
||||||
on:click={() => onCommand({ command: JobCommand.Start, force: false })}
|
onClick={() => onCommand({ command: JobCommand.Start, force: false })}
|
||||||
>
|
>
|
||||||
<Icon path={mdiAlertCircle} size="36" />
|
<Icon path={mdiAlertCircle} size="36" />
|
||||||
{$t('disabled').toUpperCase()}
|
{$t('disabled').toUpperCase()}
|
||||||
@@ -127,20 +143,20 @@
|
|||||||
|
|
||||||
{#if !disabled && !isIdle}
|
{#if !disabled && !isIdle}
|
||||||
{#if waitingCount > 0}
|
{#if waitingCount > 0}
|
||||||
<JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Empty, force: false })}>
|
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Empty, force: false })}>
|
||||||
<Icon path={mdiClose} size="24" />
|
<Icon path={mdiClose} size="24" />
|
||||||
{$t('clear').toUpperCase()}
|
{$t('clear').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
{#if queueStatus.isPaused}
|
{#if queueStatus.isPaused}
|
||||||
{@const size = waitingCount > 0 ? '24' : '48'}
|
{@const size = waitingCount > 0 ? '24' : '48'}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Resume, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Resume, force: false })}>
|
||||||
<!-- size property is not reactive, so have to use width and height -->
|
<!-- size property is not reactive, so have to use width and height -->
|
||||||
<Icon path={mdiFastForward} {size} />
|
<Icon path={mdiFastForward} {size} />
|
||||||
{$t('resume').toUpperCase()}
|
{$t('resume').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{:else}
|
{:else}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Pause, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Pause, force: false })}>
|
||||||
<Icon path={mdiPause} size="24" />
|
<Icon path={mdiPause} size="24" />
|
||||||
{$t('pause').toUpperCase()}
|
{$t('pause').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
@@ -149,25 +165,25 @@
|
|||||||
|
|
||||||
{#if !disabled && multipleButtons && isIdle}
|
{#if !disabled && multipleButtons && isIdle}
|
||||||
{#if allText}
|
{#if allText}
|
||||||
<JobTileButton color="dark-gray" on:click={() => onCommand({ command: JobCommand.Start, force: true })}>
|
<JobTileButton color="dark-gray" onClick={() => onCommand({ command: JobCommand.Start, force: true })}>
|
||||||
<Icon path={mdiAllInclusive} size="24" />
|
<Icon path={mdiAllInclusive} size="24" />
|
||||||
{allText}
|
{allText}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
{#if refreshText}
|
{#if refreshText}
|
||||||
<JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Start, force: undefined })}>
|
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Start, force: undefined })}>
|
||||||
<Icon path={mdiImageRefreshOutline} size="24" />
|
<Icon path={mdiImageRefreshOutline} size="24" />
|
||||||
{refreshText}
|
{refreshText}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
|
||||||
<Icon path={mdiSelectionSearch} size="24" />
|
<Icon path={mdiSelectionSearch} size="24" />
|
||||||
{missingText}
|
{missingText}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !disabled && !multipleButtons && isIdle}
|
{#if !disabled && !multipleButtons && isIdle}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
|
||||||
<Icon path={mdiPlay} size="48" />
|
<Icon path={mdiPlay} size="48" />
|
||||||
{$t('start').toUpperCase()}
|
{$t('start').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
|
|||||||
@@ -25,7 +25,11 @@
|
|||||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let jobs: AllJobStatusResponseDto;
|
interface Props {
|
||||||
|
jobs: AllJobStatusResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { jobs = $bindable() }: Props = $props();
|
||||||
|
|
||||||
interface JobDetails {
|
interface JobDetails {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -56,8 +60,7 @@
|
|||||||
await handleCommand(jobId, dto);
|
await handleCommand(jobId, dto);
|
||||||
};
|
};
|
||||||
|
|
||||||
// svelte-ignore reactive_declaration_non_reactive_property
|
let jobDetails: Partial<Record<JobName, JobDetails>> = {
|
||||||
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{
|
|
||||||
[JobName.ThumbnailGeneration]: {
|
[JobName.ThumbnailGeneration]: {
|
||||||
icon: mdiFileJpgBox,
|
icon: mdiFileJpgBox,
|
||||||
title: $getJobName(JobName.ThumbnailGeneration),
|
title: $getJobName(JobName.ThumbnailGeneration),
|
||||||
@@ -142,7 +145,8 @@
|
|||||||
missingText: $t('missing'),
|
missingText: $t('missing'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
$: jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
|
|
||||||
|
let jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
|
||||||
|
|
||||||
async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
|
async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
|
||||||
const title = jobDetails[jobId]?.title;
|
const title = jobDetails[jobId]?.title;
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
<FormatMessage
|
<FormatMessage
|
||||||
key="admin.storage_template_migration_description"
|
key="admin.storage_template_migration_description"
|
||||||
values={{ template: $t('admin.storage_template_settings') }}
|
values={{ template: $t('admin.storage_template_settings') }}
|
||||||
let:message
|
|
||||||
>
|
>
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
|
<a
|
||||||
class="text-immich-primary dark:text-immich-dark-primary"
|
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
|
||||||
>
|
class="text-immich-primary dark:text-immich-dark-primary"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
|
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
interface Props {
|
||||||
export let onSuccess: () => void;
|
user: UserResponseDto;
|
||||||
export let onFail: () => void;
|
onSuccess: () => void;
|
||||||
export let onCancel: () => void;
|
onFail: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { user, onSuccess, onFail, onCancel }: Props = $props();
|
||||||
|
|
||||||
const handleRestoreUser = async () => {
|
const handleRestoreUser = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,11 +36,13 @@
|
|||||||
onConfirm={handleRestoreUser}
|
onConfirm={handleRestoreUser}
|
||||||
{onCancel}
|
{onCancel}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
{#snippet promptSnippet()}
|
||||||
<p>
|
<p>
|
||||||
<FormatMessage key="admin.user_restore_description" values={{ user: user.name }} let:message>
|
<FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
|
||||||
<b>{message}</b>
|
{#snippet children({ message })}
|
||||||
|
<b>{message}</b>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|||||||
@@ -7,14 +7,22 @@
|
|||||||
import StatsCard from './stats-card.svelte';
|
import StatsCard from './stats-card.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let stats: ServerStatsResponseDto = {
|
interface Props {
|
||||||
photos: 0,
|
stats?: ServerStatsResponseDto;
|
||||||
videos: 0,
|
}
|
||||||
usage: 0,
|
|
||||||
usageByUser: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
$: zeros = (value: number) => {
|
let {
|
||||||
|
stats = {
|
||||||
|
photos: 0,
|
||||||
|
videos: 0,
|
||||||
|
usage: 0,
|
||||||
|
usagePhotos: 0,
|
||||||
|
usageVideos: 0,
|
||||||
|
usageByUser: [],
|
||||||
|
},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const zeros = (value: number) => {
|
||||||
const maxLength = 13;
|
const maxLength = 13;
|
||||||
const valueLength = value.toString().length;
|
const valueLength = value.toString().length;
|
||||||
const zeroLength = maxLength - valueLength;
|
const zeroLength = maxLength - valueLength;
|
||||||
@@ -23,7 +31,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TiB = 1024 ** 4;
|
const TiB = 1024 ** 4;
|
||||||
$: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0);
|
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
@@ -99,8 +107,12 @@
|
|||||||
class="flex h-[50px] w-full place-items-center text-center odd:bg-immich-gray even:bg-immich-bg odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50"
|
class="flex h-[50px] w-full place-items-center text-center odd:bg-immich-gray even:bg-immich-bg odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50"
|
||||||
>
|
>
|
||||||
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
|
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
|
||||||
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.photos.toLocaleString($locale)}</td>
|
<td class="w-1/4 text-ellipsis px-2 text-sm"
|
||||||
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.videos.toLocaleString($locale)}</td>
|
>{user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)})</td
|
||||||
|
>
|
||||||
|
<td class="w-1/4 text-ellipsis px-2 text-sm"
|
||||||
|
>{user.videos.toLocaleString($locale)} ({getByteUnitString(user.usageVideos, $locale, 0)})</td
|
||||||
|
>
|
||||||
<td class="w-1/4 text-ellipsis px-2 text-sm">
|
<td class="w-1/4 text-ellipsis px-2 text-sm">
|
||||||
{getByteUnitString(user.usage, $locale, 0)}
|
{getByteUnitString(user.usage, $locale, 0)}
|
||||||
{#if user.quotaSizeInBytes}
|
{#if user.quotaSizeInBytes}
|
||||||
|
|||||||
@@ -2,18 +2,22 @@
|
|||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { ByteUnit } from '$lib/utils/byte-units';
|
import { ByteUnit } from '$lib/utils/byte-units';
|
||||||
|
|
||||||
export let icon: string;
|
interface Props {
|
||||||
export let title: string;
|
icon: string;
|
||||||
export let value: number;
|
title: string;
|
||||||
export let unit: ByteUnit | undefined = undefined;
|
value: number;
|
||||||
|
unit?: ByteUnit | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
$: zeros = () => {
|
let { icon, title, value, unit = undefined }: Props = $props();
|
||||||
|
|
||||||
|
const zeros = $derived(() => {
|
||||||
const maxLength = 13;
|
const maxLength = 13;
|
||||||
const valueLength = value.toString().length;
|
const valueLength = value.toString().length;
|
||||||
const zeroLength = maxLength - valueLength;
|
const zeroLength = maxLength - valueLength;
|
||||||
|
|
||||||
return '0'.repeat(zeroLength);
|
return '0'.repeat(zeroLength);
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
|
<div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
<svelte:options accessors />
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@@ -13,12 +11,17 @@
|
|||||||
import type { SettingsResetOptions } from './admin-settings';
|
import type { SettingsResetOptions } from './admin-settings';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let config: SystemConfigDto;
|
interface Props {
|
||||||
|
config: SystemConfigDto;
|
||||||
|
children: import('svelte').Snippet<[{ savedConfig: SystemConfigDto; defaultConfig: SystemConfigDto }]>;
|
||||||
|
}
|
||||||
|
|
||||||
let savedConfig: SystemConfigDto;
|
let { config = $bindable(), children }: Props = $props();
|
||||||
let defaultConfig: SystemConfigDto;
|
|
||||||
|
|
||||||
const handleReset = async (options: SettingsResetOptions) => {
|
let savedConfig: SystemConfigDto | undefined = $state();
|
||||||
|
let defaultConfig: SystemConfigDto | undefined = $state();
|
||||||
|
|
||||||
|
export const handleReset = async (options: SettingsResetOptions) => {
|
||||||
await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
|
await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,7 +29,8 @@
|
|||||||
let systemConfigDto = {
|
let systemConfigDto = {
|
||||||
...savedConfig,
|
...savedConfig,
|
||||||
...update,
|
...update,
|
||||||
};
|
} as SystemConfigDto;
|
||||||
|
|
||||||
if (isEqual(systemConfigDto, savedConfig)) {
|
if (isEqual(systemConfigDto, savedConfig)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -59,6 +63,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
|
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
|
||||||
|
if (!defaultConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of configKeys) {
|
for (const key of configKeys) {
|
||||||
config = { ...config, [key]: defaultConfig[key] };
|
config = { ...config, [key]: defaultConfig[key] };
|
||||||
}
|
}
|
||||||
@@ -75,5 +83,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if savedConfig && defaultConfig}
|
{#if savedConfig && defaultConfig}
|
||||||
<slot {handleReset} {handleSave} {savedConfig} {defaultConfig} />
|
{@render children({ savedConfig, defaultConfig })}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
|
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { type SystemConfigDto } from '@immich/sdk';
|
import { type SystemConfigDto } from '@immich/sdk';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
@@ -12,15 +10,20 @@
|
|||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
let isConfirmOpen = false;
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
let isConfirmOpen = $state(false);
|
||||||
|
|
||||||
const handleToggleOverride = () => {
|
const handleToggleOverride = () => {
|
||||||
// click runs before bind
|
// click runs before bind
|
||||||
@@ -48,29 +51,31 @@
|
|||||||
onCancel={() => (isConfirmOpen = false)}
|
onCancel={() => (isConfirmOpen = false)}
|
||||||
onConfirm={() => handleSave(true)}
|
onConfirm={() => handleSave(true)}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
{#snippet promptSnippet()}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<p>{$t('admin.authentication_settings_disable_all')}</p>
|
<p>{$t('admin.authentication_settings_disable_all')}</p>
|
||||||
<p>
|
<p>
|
||||||
<FormatMessage key="admin.authentication_settings_reenable" let:message>
|
<FormatMessage key="admin.authentication_settings_reenable">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://immich.app/docs/administration/server-commands"
|
<a
|
||||||
rel="noreferrer"
|
href="https://immich.app/docs/administration/server-commands"
|
||||||
target="_blank"
|
rel="noreferrer"
|
||||||
class="underline"
|
target="_blank"
|
||||||
>
|
class="underline"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
|
||||||
<div class="ml-4 mt-4 flex flex-col">
|
<div class="ml-4 mt-4 flex flex-col">
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
key="oauth"
|
key="oauth"
|
||||||
@@ -79,15 +84,17 @@
|
|||||||
>
|
>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.oauth_settings_more_details" let:message>
|
<FormatMessage key="admin.oauth_settings_more_details">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://immich.app/docs/administration/oauth"
|
<a
|
||||||
class="underline"
|
href="https://immich.app/docs/administration/oauth"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -147,7 +154,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
|
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
|
||||||
desc={$t('admin.oauth_profile_signing_algorithm_description')}
|
description={$t('admin.oauth_profile_signing_algorithm_description')}
|
||||||
bind:value={config.oauth.profileSigningAlgorithm}
|
bind:value={config.oauth.profileSigningAlgorithm}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@@ -157,7 +164,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
|
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
|
||||||
desc={$t('admin.oauth_storage_label_claim_description')}
|
description={$t('admin.oauth_storage_label_claim_description')}
|
||||||
bind:value={config.oauth.storageLabelClaim}
|
bind:value={config.oauth.storageLabelClaim}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@@ -167,7 +174,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
|
label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
|
||||||
desc={$t('admin.oauth_storage_quota_claim_description')}
|
description={$t('admin.oauth_storage_quota_claim_description')}
|
||||||
bind:value={config.oauth.storageQuotaClaim}
|
bind:value={config.oauth.storageQuotaClaim}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@@ -177,7 +184,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
|
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
|
||||||
desc={$t('admin.oauth_storage_quota_default_description')}
|
description={$t('admin.oauth_storage_quota_default_description')}
|
||||||
bind:value={config.oauth.defaultStorageQuota}
|
bind:value={config.oauth.defaultStorageQuota}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@@ -213,7 +220,7 @@
|
|||||||
values: { callback: 'app.immich:///oauth-callback' },
|
values: { callback: 'app.immich:///oauth-callback' },
|
||||||
})}
|
})}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
on:click={() => handleToggleOverride()}
|
onToggle={() => handleToggleOverride()}
|
||||||
bind:checked={config.oauth.mobileOverrideEnabled}
|
bind:checked={config.oauth.mobileOverrideEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,33 +3,40 @@
|
|||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
$: cronExpressionOptions = [
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
let cronExpressionOptions = $derived([
|
||||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||||
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
|
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
|
||||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.backup_database_enable_description')}
|
title={$t('admin.backup_database_enable_description')}
|
||||||
@@ -53,21 +60,23 @@
|
|||||||
bind:value={config.backup.database.cronExpression}
|
bind:value={config.backup.database.cronExpression}
|
||||||
isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
|
isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="desc">
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.cron_expression_description" let:message>
|
<FormatMessage key="admin.cron_expression_description">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
|
<a
|
||||||
class="underline"
|
href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
<br />
|
{message}
|
||||||
</a>
|
<br />
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</SettingInputField>
|
</SettingInputField>
|
||||||
|
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
|
|||||||
@@ -15,44 +15,53 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
|
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<Icon path={mdiHelpCircleOutline} class="inline" size="15" />
|
<Icon path={mdiHelpCircleOutline} class="inline" size="15" />
|
||||||
<FormatMessage key="admin.transcoding_codecs_learn_more" let:tag let:message>
|
<FormatMessage key="admin.transcoding_codecs_learn_more">
|
||||||
{#if tag === 'h264-link'}
|
{#snippet children({ tag, message })}
|
||||||
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
|
{#if tag === 'h264-link'}
|
||||||
{message}
|
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
|
||||||
</a>
|
{message}
|
||||||
{:else if tag === 'hevc-link'}
|
</a>
|
||||||
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
|
{:else if tag === 'hevc-link'}
|
||||||
{message}
|
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
|
||||||
</a>
|
{message}
|
||||||
{:else if tag === 'vp9-link'}
|
</a>
|
||||||
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
|
{:else if tag === 'vp9-link'}
|
||||||
{message}
|
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
|
||||||
</a>
|
{message}
|
||||||
{/if}
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -60,7 +69,7 @@
|
|||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.transcoding_constant_rate_factor')}
|
label={$t('admin.transcoding_constant_rate_factor')}
|
||||||
desc={$t('admin.transcoding_constant_rate_factor_description')}
|
description={$t('admin.transcoding_constant_rate_factor_description')}
|
||||||
bind:value={config.ffmpeg.crf}
|
bind:value={config.ffmpeg.crf}
|
||||||
required={true}
|
required={true}
|
||||||
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
|
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
|
||||||
@@ -186,7 +195,7 @@
|
|||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.transcoding_max_bitrate')}
|
label={$t('admin.transcoding_max_bitrate')}
|
||||||
desc={$t('admin.transcoding_max_bitrate_description')}
|
description={$t('admin.transcoding_max_bitrate_description')}
|
||||||
bind:value={config.ffmpeg.maxBitrate}
|
bind:value={config.ffmpeg.maxBitrate}
|
||||||
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
|
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
|
||||||
/>
|
/>
|
||||||
@@ -195,7 +204,7 @@
|
|||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.transcoding_threads')}
|
label={$t('admin.transcoding_threads')}
|
||||||
desc={$t('admin.transcoding_threads_description')}
|
description={$t('admin.transcoding_threads_description')}
|
||||||
bind:value={config.ffmpeg.threads}
|
bind:value={config.ffmpeg.threads}
|
||||||
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
|
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
|
||||||
/>
|
/>
|
||||||
@@ -329,7 +338,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.transcoding_preferred_hardware_device')}
|
label={$t('admin.transcoding_preferred_hardware_device')}
|
||||||
desc={$t('admin.transcoding_preferred_hardware_device_description')}
|
description={$t('admin.transcoding_preferred_hardware_device_description')}
|
||||||
bind:value={config.ffmpeg.preferredHwDevice}
|
bind:value={config.ffmpeg.preferredHwDevice}
|
||||||
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
|
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -346,7 +355,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.transcoding_max_b_frames')}
|
label={$t('admin.transcoding_max_b_frames')}
|
||||||
desc={$t('admin.transcoding_max_b_frames_description')}
|
description={$t('admin.transcoding_max_b_frames_description')}
|
||||||
bind:value={config.ffmpeg.bframes}
|
bind:value={config.ffmpeg.bframes}
|
||||||
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
|
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -355,7 +364,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.transcoding_reference_frames')}
|
label={$t('admin.transcoding_reference_frames')}
|
||||||
desc={$t('admin.transcoding_reference_frames_description')}
|
description={$t('admin.transcoding_reference_frames_description')}
|
||||||
bind:value={config.ffmpeg.refs}
|
bind:value={config.ffmpeg.refs}
|
||||||
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
|
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -364,7 +373,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.transcoding_max_keyframe_interval')}
|
label={$t('admin.transcoding_max_keyframe_interval')}
|
||||||
desc={$t('admin.transcoding_max_keyframe_interval_description')}
|
description={$t('admin.transcoding_max_keyframe_interval_description')}
|
||||||
bind:value={config.ffmpeg.gopSize}
|
bind:value={config.ffmpeg.gopSize}
|
||||||
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
|
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
|||||||
@@ -7,24 +7,39 @@
|
|||||||
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
export let openByDefault = false;
|
onSave: SettingsSaveEvent;
|
||||||
|
openByDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
savedConfig,
|
||||||
|
defaultConfig,
|
||||||
|
config = $bindable(),
|
||||||
|
disabled = false,
|
||||||
|
onReset,
|
||||||
|
onSave,
|
||||||
|
openByDefault = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
key="thumbnail-settings"
|
key="thumbnail-settings"
|
||||||
@@ -65,7 +80,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.image_quality')}
|
label={$t('admin.image_quality')}
|
||||||
desc={$t('admin.image_thumbnail_quality_description')}
|
description={$t('admin.image_thumbnail_quality_description')}
|
||||||
bind:value={config.image.thumbnail.quality}
|
bind:value={config.image.thumbnail.quality}
|
||||||
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
|
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -110,7 +125,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.image_quality')}
|
label={$t('admin.image_quality')}
|
||||||
desc={$t('admin.image_preview_quality_description')}
|
description={$t('admin.image_preview_quality_description')}
|
||||||
bind:value={config.image.preview.quality}
|
bind:value={config.image.preview.quality}
|
||||||
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
|
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
|||||||
@@ -5,17 +5,20 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
const jobNames = [
|
const jobNames = [
|
||||||
JobName.ThumbnailGeneration,
|
JobName.ThumbnailGeneration,
|
||||||
@@ -34,11 +37,15 @@
|
|||||||
function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
|
function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
|
||||||
return jobName in config.job;
|
return jobName in config.job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
{#each jobNames as jobName}
|
{#each jobNames as jobName}
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
{#if isSystemConfigJobDto(jobName)}
|
{#if isSystemConfigJobDto(jobName)}
|
||||||
@@ -46,7 +53,7 @@
|
|||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
||||||
desc=""
|
description=""
|
||||||
bind:value={config.job[jobName].concurrency}
|
bind:value={config.job[jobName].concurrency}
|
||||||
required={true}
|
required={true}
|
||||||
isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
|
isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
|
||||||
@@ -55,7 +62,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
||||||
desc=""
|
description=""
|
||||||
value="1"
|
value="1"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
title={$t('admin.job_not_concurrency_safe')}
|
title={$t('admin.job_not_concurrency_safe')}
|
||||||
|
|||||||
@@ -4,34 +4,49 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
export let openByDefault = false;
|
onSave: SettingsSaveEvent;
|
||||||
|
openByDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
$: cronExpressionOptions = [
|
let {
|
||||||
|
savedConfig,
|
||||||
|
defaultConfig,
|
||||||
|
config = $bindable(),
|
||||||
|
disabled = false,
|
||||||
|
onReset,
|
||||||
|
onSave,
|
||||||
|
openByDefault = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let cronExpressionOptions = $derived([
|
||||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||||
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
|
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
|
||||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
key="library-watching"
|
key="library-watching"
|
||||||
@@ -77,20 +92,22 @@
|
|||||||
bind:value={config.library.scan.cronExpression}
|
bind:value={config.library.scan.cronExpression}
|
||||||
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
|
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="desc">
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.cron_expression_description" let:message>
|
<FormatMessage key="admin.cron_expression_description">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
|
<a
|
||||||
class="underline"
|
href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</SettingInputField>
|
</SettingInputField>
|
||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|||||||
@@ -8,17 +8,25 @@
|
|||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.logging_enable_description')}
|
title={$t('admin.logging_enable_description')}
|
||||||
|
|||||||
@@ -5,26 +5,33 @@
|
|||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
|
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.machine_learning_enabled')}
|
title={$t('admin.machine_learning_enabled')}
|
||||||
@@ -38,7 +45,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('url')}
|
label={$t('url')}
|
||||||
desc={$t('admin.machine_learning_url_description')}
|
description={$t('admin.machine_learning_url_description')}
|
||||||
bind:value={config.machineLearning.url}
|
bind:value={config.machineLearning.url}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.machineLearning.enabled}
|
disabled={disabled || !config.machineLearning.enabled}
|
||||||
@@ -69,11 +76,15 @@
|
|||||||
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
|
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
|
||||||
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
|
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
|
||||||
>
|
>
|
||||||
<p slot="desc" class="immich-form-label pb-2 text-sm">
|
{#snippet descriptionSnippet()}
|
||||||
<FormatMessage key="admin.machine_learning_clip_model_description" let:message>
|
<p class="immich-form-label pb-2 text-sm">
|
||||||
<a href="https://huggingface.co/immich-app"><u>{message}</u></a>
|
<FormatMessage key="admin.machine_learning_clip_model_description">
|
||||||
</FormatMessage>
|
{#snippet children({ message })}
|
||||||
</p>
|
<a href="https://huggingface.co/immich-app"><u>{message}</u></a>
|
||||||
|
{/snippet}
|
||||||
|
</FormatMessage>
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
</SettingInputField>
|
</SettingInputField>
|
||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
@@ -100,7 +111,7 @@
|
|||||||
step="0.0005"
|
step="0.0005"
|
||||||
min={0.001}
|
min={0.001}
|
||||||
max={0.1}
|
max={0.1}
|
||||||
desc={$t('admin.machine_learning_max_detection_distance_description')}
|
description={$t('admin.machine_learning_max_detection_distance_description')}
|
||||||
disabled={disabled || !$featureFlags.duplicateDetection}
|
disabled={disabled || !$featureFlags.duplicateDetection}
|
||||||
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
|
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
|
||||||
savedConfig.machineLearning.duplicateDetection.maxDistance}
|
savedConfig.machineLearning.duplicateDetection.maxDistance}
|
||||||
@@ -142,7 +153,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.machine_learning_min_detection_score')}
|
label={$t('admin.machine_learning_min_detection_score')}
|
||||||
desc={$t('admin.machine_learning_min_detection_score_description')}
|
description={$t('admin.machine_learning_min_detection_score_description')}
|
||||||
bind:value={config.machineLearning.facialRecognition.minScore}
|
bind:value={config.machineLearning.facialRecognition.minScore}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min={0.1}
|
min={0.1}
|
||||||
@@ -155,7 +166,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.machine_learning_max_recognition_distance')}
|
label={$t('admin.machine_learning_max_recognition_distance')}
|
||||||
desc={$t('admin.machine_learning_max_recognition_distance_description')}
|
description={$t('admin.machine_learning_max_recognition_distance_description')}
|
||||||
bind:value={config.machineLearning.facialRecognition.maxDistance}
|
bind:value={config.machineLearning.facialRecognition.maxDistance}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min={0.1}
|
min={0.1}
|
||||||
@@ -168,7 +179,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.machine_learning_min_recognized_faces')}
|
label={$t('admin.machine_learning_min_recognized_faces')}
|
||||||
desc={$t('admin.machine_learning_min_recognized_faces_description')}
|
description={$t('admin.machine_learning_min_recognized_faces_description')}
|
||||||
bind:value={config.machineLearning.facialRecognition.minFaces}
|
bind:value={config.machineLearning.facialRecognition.minFaces}
|
||||||
step="1"
|
step="1"
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -6,23 +6,30 @@
|
|||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
|
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
@@ -38,7 +45,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.map_light_style')}
|
label={$t('admin.map_light_style')}
|
||||||
desc={$t('admin.map_style_description')}
|
description={$t('admin.map_style_description')}
|
||||||
bind:value={config.map.lightStyle}
|
bind:value={config.map.lightStyle}
|
||||||
disabled={disabled || !config.map.enabled}
|
disabled={disabled || !config.map.enabled}
|
||||||
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
|
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
|
||||||
@@ -46,7 +53,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.map_dark_style')}
|
label={$t('admin.map_dark_style')}
|
||||||
desc={$t('admin.map_style_description')}
|
description={$t('admin.map_style_description')}
|
||||||
bind:value={config.map.darkStyle}
|
bind:value={config.map.darkStyle}
|
||||||
disabled={disabled || !config.map.enabled}
|
disabled={disabled || !config.map.enabled}
|
||||||
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
|
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
|
||||||
@@ -55,20 +62,22 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
|
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
|
||||||
<svelte:fragment slot="subtitle">
|
{#snippet subtitleSnippet()}
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.map_manage_reverse_geocoding_settings" let:message>
|
<FormatMessage key="admin.map_manage_reverse_geocoding_settings">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://immich.app/docs/features/reverse-geocoding"
|
<a
|
||||||
class="underline"
|
href="https://immich.app/docs/features/reverse-geocoding"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.map_reverse_geocoding_enable_description')}
|
title={$t('admin.map_reverse_geocoding_enable_description')}
|
||||||
|
|||||||