Compare commits

..

68 Commits

Author SHA1 Message Date
Alex
fe4c6c6365 default thumbnail size 2024-11-19 11:49:08 -06:00
Alex
63ed6283fc feat(mobile): smaller default local thumbnail size 2024-11-18 17:41:56 -06:00
John Stef
41f138d3c8 fix(mobile): Dismissible menus (#14192)
* chore(mobile): make all search filters dismissible

* chore(mobile): make ImmichAppBarDialog dismissible

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-11-18 10:06:07 -06:00
Mert
6b5defc27b fix(mobile): use sets in album refresh, concurrent futures (#14193)
* use sets in album sync, concurrent futures

* batch excluded asset IDs

* update test

* take advantage of sets in Recents check

* move log statement

* smaller diff
2024-11-18 09:26:23 -06:00
renovate[bot]
2604940f09 chore(deps): pin mcr.microsoft.com/devcontainers/typescript-node docker tag to dc2c365 (#14124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 14:57:43 +01:00
Michel Heusschen
32f908baf1 fix: show tags when viewing stacked assets (#14199)
fix: refresh tags when viewing stacked assets
2024-11-18 07:50:04 -05:00
Mert
944ea7dbcd fix(mobile): unnecessary rebuilds from partner share notifier (#14170)
* fix unnecessary notifications

* move equality function

* sort by id

* use same comparison for initial and later queries
2024-11-17 12:04:55 -05:00
Matthew Momjian
4b5657c21e docs: get asset owner by ID from database (#14174)
asset owner
2024-11-16 14:43:32 -05:00
weathondev
f5c4af73aa feat: adding photo & video storage space to server stats (#14125)
* expose detailed user storage stats + display them in the storage per user table

* chore: openapi & sql

* fix: fix test stubs

* fix: formatting errors, e2e test and server test

* fix: upper lower case typo in spec file

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-11-15 16:38:57 -06:00
Michel Heusschen
24ae4ecff1 fix(web): don't refresh panorama viewer when modifying asset (#14163) 2024-11-15 16:30:38 -06:00
Michel Heusschen
64a7baec8c refactor(web): remove reactivity triggers (#14164) 2024-11-15 16:28:28 -06:00
Mert
caf6c0996d refactor(mobile): backup info box (#14171)
split up backup info box into separate widgets
2024-11-15 17:16:52 -05:00
Lena Tauchner
6729782c3f fix(cli): Concurrency not fully using queue potential (#11828) 2024-11-15 13:09:56 -05:00
Michel Heusschen
a60209db3e fix(web): prevent infinite loop when modifying stacked asset (#14162) 2024-11-15 07:16:56 -05:00
renovate[bot]
d1169e3b2f chore(deps): update prom/prometheus docker digest to 3b9b2a1 (#14154)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-14 18:56:37 -05:00
Ben
df972ef711 feat(web): Added tag button to the context menu in the favorites page (#14156)
Added tag action to the context menu in the favorites page when selection is active
2024-11-14 16:06:36 -06:00
Alex
33263cf9f3 fix(mobile): Android local notification failes to invoke (#14155)
* fix(mobile): local notification failed to invoke

* add proguard rules
2024-11-14 16:05:32 -06:00
Michel Heusschen
1b5811d992 fix(web): allow selecting people after clearing search options (#14146) 2024-11-14 09:59:50 -06:00
Michel Heusschen
1fa0122eda fix(web): update description height when navigating between assets (#14145) 2024-11-14 09:59:30 -06:00
Alex
d1085e8a02 chore(web): move enum out of .svelte file (#14144)
* chore(web): clean up todo task

* chore(web): move enums out of .svelte file
2024-11-14 15:41:11 +00:00
Michel Heusschen
d6a70bc7e5 fix(web): saving pasted coordinates (#14143) 2024-11-14 15:21:40 +00:00
Michel Heusschen
d3fe238eef fix(web): ensure current asset index stays within bounds (#14013) 2024-11-14 09:05:36 -06:00
Pranay Pandey
35f24270fe fix: Routing back button in sharedLinks page (#13703)
* fix: go back to last page from shared links page. Handle albums page from shared links page routing

* add default route for sharing

* chore: remove redundant import

* remove unnecessary comment

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-11-14 15:00:33 +00:00
Đức
1f1a4ab1a3 fix(web): textarea autogrow height (#13983)
fix(web): remove album assetGridWidth & globalWidth

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-11-14 15:00:18 +00:00
Alex
0b3742cf13 chore(web): migration svelte 5 syntax (#13883) 2024-11-14 08:43:25 -06:00
Lukas
9203a61709 fix(server): Some MTS videos fail to generate thumbnail (#14134)
* Stop skipping of all frames in MTS video

* Only skip flag for mts videos

* Fix lint checks

* Adds test

* Add comment for why flag is removed
2024-11-14 07:07:04 +00:00
Mert
11403abfbc feat(mobile): new video slider ui (#14126) 2024-11-13 19:49:25 -05:00
mcarbonne
5a2af558fb feat: add minimal devcontainer setup (#14038)
* add minimal devcontainer setup

* fix Makefile & update doc

* fix Makefile

* add warning regarding devcontainer + add newline at EOF
2024-11-13 11:28:07 -06:00
John Stef
de993289ad fix(mobile): fix logout timeout (#14104)
* fix(mobile): add timeout to logout

* chore(mobile): refactor timeout durations

* feat(mobile): add loading state to logout button

* chore(mobile): format authentication.provider.dart

* chore: format

* chore: revert settings.json change

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-11-13 17:27:49 +00:00
Nicholas Flamy
c58bd307ce docs: Update TrueNAS docs for TrueNAS SCALE 24.10 (#14067)
* initial-docs-update

* add-info-about-external-libraries
2024-11-13 11:26:23 -06:00
Pablo Molina
333ca8827e feat: use dateTimeOriginal to calculate album date (#14119) 2024-11-13 11:17:14 -06:00
Alex
3dad19883d fix(mobile): duration ui overflow (#14120)
* fix(mobile): duration ui overflow

* pr feedback
2024-11-13 09:39:21 -06:00
renovate[bot]
4ca27a3e7f chore(deps): update redis:6.2-alpine docker digest to eaba718 (#14114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 07:12:30 -05:00
renovate[bot]
b0bb11f9e0 chore(deps): update docker.io/redis:6.2-alpine docker digest to eaba718 (#14113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 07:12:06 -05:00
Xuesong
ecb8349085 chore(docs): encode db dump in UTF-8 without BOM for Windows (#13775) 2024-11-13 10:05:53 +00:00
Mert
e1feba2198 refactor(mobile): video controls (#14086)
* refactor video controls

* inline

* make mute icon const

* move placeholder to private widget

* adjust text width, move volume button slightly right
2024-11-13 01:13:21 -05:00
renovate[bot]
53a7ac3868 chore(deps): update prom/prometheus docker digest to 2659f4c (#13928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 23:30:06 -05:00
dependabot[bot]
f2e950d89c chore(deps): bump ytanikin/PRConventionalCommits from 1.2.0 to 1.3.0 (#13051)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 01:27:37 +00:00
renovate[bot]
8ba2c99b08 chore(deps): update docker.io/redis:6.2-alpine docker digest to 77c6e37 (#14097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 01:15:45 +00:00
renovate[bot]
93346496fc chore(deps): update redis:6.2-alpine docker digest to 77c6e37 (#14098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 01:15:30 +00:00
Alex
a9525de356 chore(mobile): post release tasks (#14105) 2024-11-12 14:34:33 -05:00
github-actions
31a1e64b58 chore: version v1.120.2 2024-11-12 17:30:29 +00:00
Zack Pollard
e17bd8efc6 fix(server): backup version checks not handling database versions correctly (#14102) 2024-11-12 10:57:05 -06:00
Alex
2f9019c0e1 fix(server): correct rotation for common files (#14092)
* fix(server): correct rotation for common files

* fix: test:

* pr feedback
2024-11-12 15:07:56 +00:00
Zack Pollard
dfa8a8a6e1 feat(server): use pg_dumpall version that matches the database version (#14083) 2024-11-12 14:58:29 +00:00
renovate[bot]
b9a0c3c79f chore(deps): update base-image to v20241112 (major) (#14088)
chore(deps): update base-image to v20241112

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 12:49:31 +00:00
renovate[bot]
bda97c4e0e chore(deps): update node (#14090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 07:32:52 -05:00
Robert Schütz
e3426c880f chore(ml): replace fastapi-slim with fastapi (#14091)
The two have been identical since version 0.112.0:
https://github.com/fastapi/fastapi/discussions/11525#discussioncomment-10219861
2024-11-11 23:08:29 -05:00
Zack Pollard
d4ca7d0075 fix: config updates not applying for job and storage template service (#14074) 2024-11-11 12:50:09 +00:00
Zack Pollard
f1c9b763cf docs: backup folder name is backups (#14073) 2024-11-11 12:28:53 +00:00
Zack Pollard
5097c92494 fix(server): attempt to delete failed backups immediately after failure (#13995) 2024-11-11 12:08:52 +00:00
gamescom15
7aacc92699 docs: clarify file size impact in hardware-transcoding.md (#14049) 2024-11-11 03:51:00 +00:00
Daniel Dietzler
00d6cc86ad chore: add weblate requests (#14051) 2024-11-10 15:49:23 -05:00
Joren Guillaume
54d881e5c6 docs: Fix DCM docs link (#14059)
Fix DCM docs link
2024-11-10 13:33:51 -05:00
Snowknight26
edce096680 chore(web): Update the new version announcement text (#14001)
* Update en.json

* Update en.json

* Update en.json
2024-11-09 12:15:25 -06:00
mcarbonne
5c31acbcf0 feat(web): stable json settings export (#14036)
* recursively sort json output (settings)

* fix format/lint/...g
2024-11-09 12:11:20 -06:00
Alex
6b49104d59 fix(mobile): make sure date locale is inititialized for some languages (#14035) 2024-11-09 10:40:13 -05:00
Ben
97dbe3236b chore(docs): roadmap SEO (#14024) 2024-11-08 19:48:23 -05:00
Michel Heusschen
586393f178 fix(web): use locale for scrubber label when scrolling (#14012) 2024-11-08 15:36:26 -05:00
bo0tzz
f3e88ea2fa docs: make IGNORE_MOUNT_CHECK warning stronger (#14011) 2024-11-08 13:29:10 +00:00
Terry Zhao
c8b46802d6 fix(server): thumbnail rotation when using embedded previews (#13948) 2024-11-08 06:30:59 +00:00
Jason Rasmussen
7534098596 fix(server): support non-default postgres port when taking a backup (#13992) 2024-11-07 20:06:16 +00:00
Zack Pollard
ec5b7c266b chore: backups custom location and config file docs (#13996) 2024-11-07 18:08:02 +00:00
Jason Rasmussen
e84ad084d5 refactor(server): cron validation (#13990) 2024-11-07 17:27:52 +00:00
Jason Rasmussen
dc2de47204 refactor(server): cron repository (#13988) 2024-11-07 12:15:54 -05:00
Alex
2fe6607aea chore(mobile): post release tasks (#13989) 2024-11-07 10:27:28 -06:00
Jason Rasmussen
64831e2328 refactor: remove smart info table (#13985) 2024-11-07 11:25:10 -05:00
Alex
6053214e75 chore(mobile): update isar build (#13987) 2024-11-07 09:54:19 -06:00
463 changed files with 8302 additions and 5599 deletions

2
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,2 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:dc2c3654370fe92a55daeefe9d2d95839d85bdc1f68f7fd4ab86621f49e5818a
FROM ${BASEIMAGE}

View File

@@ -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"
}

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: PR Conventional Commit Validation
uses: ytanikin/PRConventionalCommits@1.2.0
uses: ytanikin/PRConventionalCommits@1.3.0
with:
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
add_label: 'false'

View File

@@ -41,4 +41,4 @@
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
}
}
}

View File

@@ -39,7 +39,7 @@ attach-server:
renovate:
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
MODULES = e2e server web cli sdk
MODULES = e2e server web cli sdk docs
audit-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
@@ -48,11 +48,9 @@ install-%:
build-cli: build-sdk
build-web: build-sdk
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 || true
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
format-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'format:fix' >/dev/null \
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run format:fix || true
npm --prefix $* run format:fix
lint-%:
npm --prefix $* run lint:fix
check-%:
@@ -79,14 +77,14 @@ test-medium:
test-medium-dev:
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) ;
check-all: $(foreach M,$(MODULES),check-$M) ;
lint-all: $(foreach M,$(MODULES),lint-$M) ;
format-all: $(foreach M,$(MODULES),format-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
audit-all: $(foreach M,$(MODULES),audit-$M) ;
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:
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +

View File

@@ -1,4 +1,4 @@
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS core
FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82 AS core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

10
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.30",
"version": "2.2.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.30",
"version": "2.2.31",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -24,7 +24,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -52,14 +52,14 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.120.1",
"version": "1.120.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"typescript": "^5.3.3"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.30",
"version": "2.2.31",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",

View File

@@ -1,5 +1,6 @@
import {
Action,
AssetBulkUploadCheckItem,
AssetBulkUploadCheckResult,
AssetMediaResponseDto,
AssetMediaStatus,
@@ -11,7 +12,7 @@ import {
getSupportedMediaTypes,
} from '@immich/sdk';
import byteSize from 'byte-size';
import { Presets, SingleBar } from 'cli-progress';
import { MultiBar, Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es';
import { Stats, createReadStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
@@ -90,23 +91,23 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
return { newFiles: files, duplicates: [] };
}
const progressBar = new SingleBar(
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
const multiBar = new MultiBar(
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
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 duplicates: Asset[] = [];
const queue = new Queue<string[], AssetBulkUploadCheckResults>(
async (filepaths: string[]) => {
const dto = await Promise.all(
filepaths.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })),
);
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
const checkBulkUploadQueue = new Queue<AssetBulkUploadCheckItem[], void>(
async (assets: AssetBulkUploadCheckItem[]) => {
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
const results = response.results as AssetBulkUploadCheckResults;
for (const { id: filepath, assetId, action } of results) {
if (action === Action.Accept) {
newFiles.push(filepath);
@@ -115,19 +116,46 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
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;
},
{ concurrency, retry: 3 },
);
for (const items of chunk(files, concurrency)) {
await queue.push(items);
for (const item of files) {
void queue.push(item);
}
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)}`);
@@ -201,8 +229,8 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo
{ concurrency, retry: 3 },
);
for (const filepath of files) {
await queue.push(filepath);
for (const item of files) {
void queue.push(item);
}
await queue.drained();

View File

@@ -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.
* This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
async drained(): Promise<void> {
await this.queue.drain();
drained(): Promise<void> {
return this.queue.drained();
}
/**

View File

@@ -103,7 +103,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -47,7 +47,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -94,7 +94,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:378f4e03703557d1c6419e6caccf922f96e6d88a530f7431d66a4c4f4b1000fe
image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -48,7 +48,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -58,7 +58,7 @@ docker compose up -d # Start remainder of Immich apps
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
```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'

View File

@@ -40,7 +40,9 @@ The above error messages show that the server has previously (successfully) writ
### Ignoring the checks
The checks are designed to catch common problems that we have seen users have in the past, but if you want to disable them you can set the following environment variable:
:::warning
The checks are designed to catch common problems that we have seen users have in the past, and often indicate there's something wrong that you should solve. If you know what you're doing and you want to disable them you can set the following environment variable:
:::
```
IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true

View File

@@ -1,5 +1,9 @@
# 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:
## Web Checks

View File

@@ -76,7 +76,7 @@ Setting these in the IDE give a better developer experience, auto-formatting cod
### Dart Code Metrics
The mobile app uses DCM (Dart Code Metrics) for linting and metrics calculation. Please refer to the [Getting Started](https://dcm.dev/docs/getting-started/#installation) page for more information on setting up DCM
The mobile app uses DCM (Dart Code Metrics) for linting and metrics calculation. Please refer to the [Getting Started](https://dcm.dev/docs/) page for more information on setting up DCM
Note: Activating the license is not required.

View File

@@ -1,7 +1,7 @@
# Hardware Transcoding [Experimental]
This feature allows you to use a GPU to accelerate transcoding and reduce CPU load.
Note that hardware transcoding is much less efficient for file sizes.
Note that hardware transcoding produces significantly larger videos than software transcoding with similar settings, typically with lower quality. Using slow presets and preferring more efficient codecs can narrow this gap.
As this is a new feature, it is still experimental and may not work on all systems.
:::info

View File

@@ -17,6 +17,7 @@ In our `.env` file, we will define variables that will help us in the future whe
+ THUMB_LOCATION=/custom/path/immich/thumbs
+ ENCODED_VIDEO_LOCATION=/custom/path/immich/encoded-video
+ PROFILE_LOCATION=/custom/path/immich/profile
+ BACKUP_LOCATION=/custom/path/immich/backups
...
```
@@ -30,6 +31,7 @@ services:
+ - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video
+ - ${PROFILE_LOCATION}:/usr/src/app/upload/profile
+ - ${BACKUP_LOCATION}:/usr/src/app/upload/backups
- /etc/localtime:/etc/localtime:ro
```

View File

@@ -98,6 +98,10 @@ SELECT * FROM "move_history";
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
```sql title="Custom settings"

View File

@@ -35,6 +35,13 @@ The default configuration looks like this:
"accel": "disabled",
"accelDecode": false
},
"backup": {
"database": {
"enabled": true,
"cronExpression": "0 02 * * *",
"keepLastAmount": 14
}
},
"job": {
"backgroundTask": {
"concurrency": 5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -7,7 +7,9 @@ sidebar_position: 80
:::note
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.
@@ -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.
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.
You can configure environment variables at any time after deploying the application.
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 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.
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**.
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
### Setting up Storage Datasets
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`, `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
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
@@ -47,6 +57,8 @@ className="border rounded-xl"
Click on the widget to open the **Immich** application details screen.
<br/><br/>
<img
src={require('./img/truenas02.png').default}
width="100%"
@@ -56,9 +68,13 @@ className="border rounded-xl"
Click **Install** to open the Immich application configuration screen.
<br/><br/>
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.
### Application Name and Version
<img
src={require('./img/truenas03.png').default}
width="100%"
@@ -66,21 +82,123 @@ alt="Install Immich Screen"
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.
**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.
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.
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.
:::
## Editing Environment Variables
## Edit App Settings
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.
The settings on the edit screen are the same as on the install screen.
You cannot edit **Storage Configuration** paths after the initial app install.
- 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.
- Change any settings you would like to change.
- 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.
TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated environment variables.
## 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
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.
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.
## Understanding Immich Settings in TrueNAS SCALE
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 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.
- 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.
- This opens an update window with some options
- You may select an Image update too.
- You may view the Changelog.
- 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.

View File

@@ -74,7 +74,6 @@ import {
mdiFaceRecognition,
mdiVideo,
mdiWeb,
mdiDatabase,
mdiDatabaseOutline,
} from '@mdi/js';
import Layout from '@theme/Layout';
@@ -154,6 +153,9 @@ const weirdTags = {
'v1.2.0': 'v0.2-dev ',
};
const title = 'Roadmap';
const description = 'A list of future plans and goals, as well as past achievements and milestones.';
const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language);
type Base = { icon: string; iconColor?: React.CSSProperties['color']; title: string; description: string };
@@ -870,14 +872,12 @@ const milestones: Item[] = [
export default function MilestonePage(): JSX.Element {
return (
<Layout title="Milestones" description="History of Immich">
<Layout title={title} description={description}>
<section className="my-8">
<h1 className="md:text-6xl text-center mb-10 text-immich-primary dark:text-immich-dark-primary px-2">
Roadmap
{title}
</h1>
<p className="text-center text-xl px-2">
A list of future plans and goals, as well as past achievements and milestones.
</p>
<p className="text-center text-xl px-2">{description}</p>
<div className="flex justify-around mt-8 w-full max-w-full">
<Timeline items={[...roadmap, ...milestones]} />
</div>

View File

@@ -1,4 +1,8 @@
[
{
"label": "v1.120.2",
"url": "https://v1.120.2.archive.immich.app"
},
{
"label": "v1.120.1",
"url": "https://v1.120.1.archive.immich.app"

View File

@@ -34,7 +34,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0

14
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.120.1",
"version": "1.120.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.120.1",
"version": "1.120.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.30",
"version": "2.2.31",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -64,7 +64,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -92,14 +92,14 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.120.1",
"version": "1.120.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"typescript": "^5.3.3"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.120.1",
"version": "1.120.2",
"description": "",
"main": "index.js",
"type": "module",
@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",

View File

@@ -473,10 +473,7 @@ describe('/search', () => {
.get('/search/explore')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{ fieldName: 'exifInfo.city', items: [] },
{ fieldName: 'smartInfo.tags', items: [] },
]);
expect(body).toEqual([{ fieldName: 'exifInfo.city', items: [] }]);
});
});

View File

@@ -163,11 +163,15 @@ describe('/server', () => {
expect(body).toEqual({
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [
{
quotaSizeInBytes: null,
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'Immich Admin',
userId: admin.userId,
videos: 0,
@@ -176,6 +180,8 @@ describe('/server', () => {
quotaSizeInBytes: null,
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'User 1',
userId: nonAdmin.userId,
videos: 0,

View File

@@ -103,7 +103,7 @@ describe(`immich upload`, () => {
describe(`immich upload /path/to/file.jpg`, () => {
it('should upload a single file', async () => {
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.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 { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
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]);
const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
);
@@ -169,7 +169,7 @@ describe(`immich upload`, () => {
it('should skip a duplicate file', async () => {
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.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
@@ -179,7 +179,7 @@ describe(`immich upload`, () => {
expect(assets.total).toBe(1);
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.arrayContaining([
expect.stringContaining('Found 0 new files and 1 duplicate'),
@@ -205,7 +205,7 @@ describe(`immich upload`, () => {
`${testAssetDir}/albums/nature/silver_fir.jpg`,
'--dry-run',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]),
);
@@ -217,7 +217,7 @@ describe(`immich upload`, () => {
it('dry run should handle duplicates', async () => {
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.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
@@ -227,7 +227,7 @@ describe(`immich upload`, () => {
expect(assets.total).toBe(1);
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.arrayContaining([
expect.stringContaining('Found 8 new files and 1 duplicate'),
@@ -241,7 +241,7 @@ describe(`immich upload`, () => {
describe('immich upload --recursive', () => {
it('should upload a folder recursively', async () => {
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
);
@@ -267,7 +267,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully updated 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -283,7 +283,7 @@ describe(`immich upload`, () => {
expect(response1.stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
);
expect(response1.stderr).toBe('');
expect(response1.stderr).toContain('{message}');
expect(response1.exitCode).toBe(0);
const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -299,7 +299,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully updated 9 assets'),
]),
);
expect(response2.stderr).toBe('');
expect(response2.stderr).toContain('{message}');
expect(response2.exitCode).toBe(0);
const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -325,7 +325,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Would have updated albums of 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -351,7 +351,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully updated 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -377,7 +377,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Would have updated albums of 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -408,7 +408,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Deleting assets that have been uploaded'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -434,7 +434,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Would have deleted 9 local assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -493,7 +493,7 @@ describe(`immich upload`, () => {
'2',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 9 new files and 0 duplicates',
@@ -534,7 +534,7 @@ describe(`immich upload`, () => {
'silver_fir.jpg',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 8 new files and 0 duplicates',
@@ -555,7 +555,7 @@ describe(`immich upload`, () => {
'!(*_*_*).jpg',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 1 new files and 0 duplicates',
@@ -577,7 +577,7 @@ describe(`immich upload`, () => {
'--dry-run',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 8 new files and 0 duplicates',

View File

@@ -11,6 +11,7 @@ import {
PersonCreateDto,
SharedLinkCreateDto,
UserAdminCreateDto,
UserPreferencesUpdateDto,
ValidateLibraryDto,
checkExistingAssets,
createAlbum,
@@ -19,6 +20,7 @@ import {
createPartner,
createPerson,
createSharedLink,
createStack,
createUserAdmin,
deleteAssets,
getAllJobsStatus,
@@ -28,10 +30,13 @@ import {
searchMetadata,
setBaseUrl,
signUpAdmin,
tagAssets,
updateAdminOnboarding,
updateAlbumUser,
updateAssets,
updateConfig,
updateMyPreferences,
upsertTags,
validate,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
@@ -444,6 +449,18 @@ export const utils = {
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') =>
await context.addCookies([
{

View File

@@ -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');
});
});

View File

@@ -1283,7 +1283,7 @@
"variables": "Variables",
"version": "Version",
"version_announcement_closing": "Your friend, Alex",
"version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.",
"version_announcement_message": "Hi there! A new version of Immich is available. Please take some time to read the <link>release notes</link> to ensure your setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your Immich instance automatically.",
"version_history": "Version History",
"version_history_item": "Installed {version} on {date}",
"video": "Video",

1
i18n/fil.json Normal file
View File

@@ -0,0 +1 @@
{}

1
i18n/nn.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -747,14 +747,14 @@ files = [
test = ["pytest (>=6)"]
[[package]]
name = "fastapi-slim"
name = "fastapi"
version = "0.115.4"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi_slim-0.115.4-py3-none-any.whl", hash = "sha256:8947515618c21665590a1673a0bfe4c721db4267999c149d5301c3c0f7b3d9ce"},
{file = "fastapi_slim-0.115.4.tar.gz", hash = "sha256:6d37987e4d1f6adefb8c7119c9b804e59c9b3f1a488be5425994d52308e2f958"},
{file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"},
{file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"},
]
[package.dependencies]
@@ -3778,4 +3778,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "f95dddfd343a4b2f4d19ffee71ce6b2f5137e5514a60765424164259c4dc1044"
content-hash = "b690d5fbd141da3947f4f1dc029aba1b95e7faafd723166f2c4bdc47a66c095e"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.120.1"
version = "1.120.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@@ -11,7 +11,7 @@ python = ">=3.10,<4.0"
insightface = ">=0.7.3,<1.0"
opencv-python-headless = ">=4.7.0.72,<5.0"
pillow = ">=9.5.0,<11.0"
fastapi-slim = ">=0.95.2,<1.0"
fastapi = ">=0.95.2,<1.0"
uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
pydantic = "^2.0.0"
pydantic-settings = "^2.5.2"

32
mobile/android/app/proguard-rules.pro vendored Normal file
View File

@@ -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 ----------

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 166,
"android.injected.version.name" => "1.120.1",
"android.injected.version.code" => 167,
"android.injected.version.name" => "1.120.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096M
android.useAndroidX=true
android.enableJetifier=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.nonFinalResIds=false

View File

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

View File

@@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.120.0</string>
<string>1.120.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>182</string>
<string>184</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

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

View File

@@ -19,6 +19,9 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
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 = {
ImmichColorPreset.indigo: ImmichTheme(

View File

@@ -11,6 +11,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/services/background.service.dart';
@@ -56,6 +57,7 @@ void main() async {
Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
await initializeDateFormatting();
if (kReleaseMode && Platform.isAndroid) {
try {

View File

@@ -18,6 +18,9 @@ class CurrentUploadAsset {
this.iCloudAsset,
});
@pragma('vm:prefer-inline')
bool get isIcloudAsset => iCloudAsset != null && iCloudAsset!;
CurrentUploadAsset copyWith({
String? id,
DateTime? fileCreatedAt,

View File

@@ -187,7 +187,7 @@ class SearchPage extends HookConsumerWidget {
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.tr(),
onSearch: search,
@@ -238,7 +238,7 @@ class SearchPage extends HookConsumerWidget {
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.tr(),
onSearch: search,

View File

@@ -41,6 +41,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
_ref;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
Future<bool> login(
String email,
String password,
@@ -102,12 +104,15 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
await _apiService.authenticationApi
.logout()
.timeout(_timeoutDuration)
.then((_) => log.info("Logout was successful for $userEmail"))
.onError(
(error, stackTrace) =>
log.severe("Logout failed for $userEmail", error, stackTrace),
);
} catch (e, stack) {
log.severe('Logout failed', e, stack);
} finally {
await Future.wait([
clearAssetsAndAlbums(_db),
Store.delete(StoreKey.currentUser),
@@ -125,8 +130,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
shouldChangePassword: false,
isAuthenticated: false,
);
} catch (e, stack) {
log.severe('Logout failed', e, stack);
}
}
@@ -168,10 +171,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
UserPreferencesResponseDto? userPreferences;
try {
final responses = await Future.wait([
_apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
_apiService.usersApi
.getMyPreferences()
.timeout(const Duration(seconds: 7)),
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
]);
userResponse = responses[0] as UserAdminResponseDto;
userPreferences = responses[1] as UserPreferencesResponseDto;

View File

@@ -18,8 +18,8 @@ class ImmichLocalThumbnailProvider
ImmichLocalThumbnailProvider({
required this.asset,
this.height = 256,
this.width = 256,
this.height = 128,
this.width = 128,
}) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/services/partner.service.dart';
@@ -9,9 +10,19 @@ import 'package:isar/isar.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
final query = db.users.filter().isPartnerSharedWithEqualTo(true);
query.findAll().then((partners) => state = partners);
query.watch().listen((partners) => state = partners);
Function eq = const ListEquality<User>().equals;
final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById();
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}) {
@@ -31,9 +42,19 @@ final partnerSharedWithProvider =
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
PartnerSharedByNotifier(Isar db) : super([]) {
final query = db.users.filter().isPartnerSharedByEqualTo(true);
query.findAll().then((partners) => state = partners);
streamSub = query.watch().listen((partners) => state = partners);
Function eq = const ListEquality<User>().equals;
final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById();
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;

View File

@@ -76,10 +76,16 @@ class AlbumService {
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<String> excludedIds = await _backupAlbumRepository
.getIdsBySelection(BackupSelection.exclude);
final List<String> selectedIds = await _backupAlbumRepository
.getIdsBySelection(BackupSelection.select);
final (selectedIds, excludedIds, onDevice) = await (
_backupAlbumRepository
.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) {
final numLocal = await _albumRepository.count(local: true);
if (numLocal > 0) {
@@ -87,8 +93,6 @@ class AlbumService {
}
return false;
}
final List<Album> onDevice = await _albumMediaRepository.getAll();
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
if (Platform.isIOS) {
@@ -108,22 +112,19 @@ class AlbumService {
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map(
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
)
.whereNotNull()
.any((a) => a.isAll);
final allAlbum = onDevice.firstWhereOrNull((album) => album.isAll);
final hasAll = allAlbum != null && selectedIds.contains(allAlbum.localId);
if (hasAll) {
if (Platform.isAndroid) {
// remove the virtual "Recent" album and keep and individual albums
// 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");
}
} else {
// 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");
}
changes =
@@ -138,15 +139,19 @@ class AlbumService {
Future<Set<String>> _loadExcludedAssetIds(
List<Album> albums,
List<String> excludedAlbumIds,
Set<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (Album album in albums) {
if (excludedAlbumIds.contains(album.localId)) {
final assetIds =
await _albumMediaRepository.getAssetIds(album.localId!);
result.addAll(assetIds);
}
for (final batchAlbums in albums
.where((album) => excludedAlbumIds.contains(album.localId))
.slices(5)) {
await batchAlbums
.map(
(album) => _albumMediaRepository
.getAssetIds(album.localId!)
.then((assetIds) => result.addAll(assetIds)),
)
.wait;
}
return result;
}
@@ -163,11 +168,10 @@ class AlbumService {
bool changes = false;
try {
await _userService.refreshUsers();
final List<Album> sharedAlbum =
await _albumApiRepository.getAll(shared: true);
final List<Album> ownedAlbum =
await _albumApiRepository.getAll(shared: null);
final (sharedAlbum, ownedAlbum) = await (
_albumApiRepository.getAll(shared: true),
_albumApiRepository.getAll(shared: null)
).wait;
final albums = HashSet<Album>(
equals: (a, b) => a.remoteId == b.remoteId,

View File

@@ -7,10 +7,10 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class ImmichTheme {
ColorScheme light;
ColorScheme dark;
final ColorScheme light;
final ColorScheme dark;
ImmichTheme({required this.light, required this.dark});
const ImmichTheme({required this.light, required this.dark});
}
ImmichTheme? _immichDynamicTheme;
@@ -151,7 +151,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) {
return ThemeData(
useMaterial3: true,
brightness: isDark ? Brightness.dark : Brightness.light,
brightness: colorScheme.brightness,
colorScheme: colorScheme,
primaryColor: primaryColor,
hintColor: colorScheme.onSurfaceSecondary,

View File

@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
@@ -327,39 +328,51 @@ class BottomGalleryBar extends ConsumerWidget {
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Column(
children: [
Visibility(
visible: showVideoPlayerControls,
child: const VideoControls(),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [blackOpacity90, Colors.transparent],
),
BottomNavigationBar(
backgroundColor: Colors.black.withOpacity(0.4),
unselectedIconTheme: const IconThemeData(color: Colors.white),
selectedIconTheme: const IconThemeData(color: Colors.white),
unselectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
selectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
unselectedFontSize: 14,
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);
},
),
position: DecorationPosition.background,
child: Padding(
padding: EdgeInsets.only(top: 40.0),
child: Column(
children: [
if (showVideoPlayerControls) const VideoControls(),
BottomNavigationBar(
elevation: 0.0,
backgroundColor: Colors.transparent,
unselectedIconTheme: const IconThemeData(color: Colors.white),
selectedIconTheme: const IconThemeData(color: Colors.white),
unselectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
selectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
unselectedFontSize: 14,
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);
},
),
],
),
],
),
),
),
);

View File

@@ -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,
),
);
}
}

View File

@@ -1,125 +1,20 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.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/video_position.dart';
/// The video controls for the [videPlayerControlsProvider]
/// The video controls for the [videoPlayerControlsProvider]
class VideoControls extends ConsumerWidget {
const VideoControls({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final duration =
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
final position =
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
return AnimatedOpacity(
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
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;
final isPortrait =
MediaQuery.orientationOf(context) == Orientation.portrait;
return isPortrait
? const VideoPosition()
: const Padding(
padding: EdgeInsets.symmetric(horizontal: 60.0),
child: VideoPosition(),
);
}
}

View File

@@ -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,
),
),
],
),
],
);
}
}

View File

@@ -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());
}
}

View File

@@ -1,296 +1,43 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.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/theme_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/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.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';
import 'package:immich_mobile/widgets/backup/asset_info_table.dart';
import 'package:immich_mobile/widgets/backup/error_chip.dart';
import 'package:immich_mobile/widgets/backup/icloud_download_progress_bar.dart';
import 'package:immich_mobile/widgets/backup/upload_progress_bar.dart';
import 'package:immich_mobile/widgets/backup/upload_stats.dart';
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
class CurrentUploadingAssetInfoBox extends StatelessWidget {
const CurrentUploadingAssetInfoBox({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var isManualUpload = ref.watch(backupProvider).backupProgress ==
BackUpProgressEnum.manualInProgress;
var asset = !isManualUpload
? ref.watch(backupProvider).currentUploadAsset
: ref.watch(manualUploadProvider).currentUploadAsset;
var uploadProgress = !isManualUpload
? ref.watch(backupProvider).progressInPercentage
: ref.watch(manualUploadProvider).progressInPercentage;
var uploadFileProgress = !isManualUpload
? 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,
),
Widget build(BuildContext context) {
return ListTile(
isThreeLine: true,
leading: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 30,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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()],
),
),
),
],
),
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]),
),
),
],
),
Text(
"backup_controller_page_uploading_file_info",
style: context.textTheme.titleSmall,
).tr(),
const BackupErrorChip(),
],
),
subtitle: Column(
children: [
if (Platform.isIOS) const IcloudDownloadProgressBar(),
const BackupUploadProgressBar(),
const BackupUploadStats(),
const BackupAssetInfoTable(),
],
);
}
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(),
),
],
),
),
);
}

View File

@@ -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()),
);
}
}

View File

@@ -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()],
);
}
}

View File

@@ -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),
),
],
),
);
}
}

View File

@@ -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"),
),
],
),
);
}
}

View File

@@ -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';
}
}
}

View File

@@ -28,6 +28,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
bool isHorizontal = !context.isMobile;
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
final user = ref.watch(currentUserProvider);
final isLoggingOut = useState(false);
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(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30),
contentPadding: const EdgeInsets.only(left: 30, right: 30),
minLeadingWidth: 40,
leading: SizedBox(
child: Icon(
@@ -83,6 +89,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
),
).tr(),
onTap: onTap,
trailing: trailing,
);
}
@@ -107,6 +114,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
Icons.logout_rounded,
"profile_drawer_sign_out",
() async {
if (isLoggingOut.value) {
return;
}
showDialog(
context: context,
builder: (BuildContext ctx) {
@@ -115,7 +126,11 @@ class ImmichAppBarDialog extends HookConsumerWidget {
content: "app_bar_signout_dialog_content",
ok: "app_bar_signout_dialog_ok",
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(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(
behavior: HitTestBehavior.translucent,
direction: DismissDirection.down,
onDismissed: (_) => Navigator.of(context).pop(),
key: const Key('app_bar_dialog'),

View File

@@ -31,7 +31,7 @@ class ImmichThumbnail extends HookWidget {
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
int thumbnailSize = 256,
int thumbnailSize = 128,
}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.120.1
- API version: 1.120.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -408,7 +408,6 @@ Class | Method | HTTP request | Description
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
- [SharedLinkType](doc//SharedLinkType.md)
- [SignUpDto](doc//SignUpDto.md)
- [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
- [SmartSearchDto](doc//SmartSearchDto.md)
- [SourceType](doc//SourceType.md)
- [StackCreateDto](doc//StackCreateDto.md)

View File

@@ -222,7 +222,6 @@ part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_dto.dart';
part 'model/shared_link_type.dart';
part 'model/sign_up_dto.dart';
part 'model/smart_info_response_dto.dart';
part 'model/smart_search_dto.dart';
part 'model/source_type.dart';
part 'model/stack_create_dto.dart';

View File

@@ -498,8 +498,6 @@ class ApiClient {
return SharedLinkTypeTypeTransformer().decode(value);
case 'SignUpDto':
return SignUpDto.fromJson(value);
case 'SmartInfoResponseDto':
return SmartInfoResponseDto.fromJson(value);
case 'SmartSearchDto':
return SmartSearchDto.fromJson(value);
case 'SourceType':

View File

@@ -37,7 +37,6 @@ class AssetResponseDto {
required this.ownerId,
this.people = const [],
this.resized,
this.smartInfo,
this.stack,
this.tags = const [],
required this.thumbhash,
@@ -121,14 +120,6 @@ class AssetResponseDto {
///
bool? resized;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
SmartInfoResponseDto? smartInfo;
AssetStackResponseDto? stack;
List<TagResponseDto> tags;
@@ -167,7 +158,6 @@ class AssetResponseDto {
other.ownerId == ownerId &&
_deepEquality.equals(other.people, people) &&
other.resized == resized &&
other.smartInfo == smartInfo &&
other.stack == stack &&
_deepEquality.equals(other.tags, tags) &&
other.thumbhash == thumbhash &&
@@ -202,7 +192,6 @@ class AssetResponseDto {
(ownerId.hashCode) +
(people.hashCode) +
(resized == null ? 0 : resized!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
(stack == null ? 0 : stack!.hashCode) +
(tags.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
@@ -211,7 +200,7 @@ class AssetResponseDto {
(updatedAt.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]';
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -267,11 +256,6 @@ class AssetResponseDto {
} else {
// json[r'resized'] = null;
}
if (this.smartInfo != null) {
json[r'smartInfo'] = this.smartInfo;
} else {
// json[r'smartInfo'] = null;
}
if (this.stack != null) {
json[r'stack'] = this.stack;
} else {
@@ -322,7 +306,6 @@ class AssetResponseDto {
ownerId: mapValueOfType<String>(json, r'ownerId')!,
people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
resized: mapValueOfType<bool>(json, r'resized'),
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
stack: AssetStackResponseDto.fromJson(json[r'stack']),
tags: TagResponseDto.listFromJson(json[r'tags']),
thumbhash: mapValueOfType<String>(json, r'thumbhash'),

View File

@@ -16,6 +16,8 @@ class ServerStatsResponseDto {
this.photos = 0,
this.usage = 0,
this.usageByUser = const [],
this.usagePhotos = 0,
this.usageVideos = 0,
this.videos = 0,
});
@@ -25,6 +27,10 @@ class ServerStatsResponseDto {
List<UsageByUserDto> usageByUser;
int usagePhotos;
int usageVideos;
int videos;
@override
@@ -32,6 +38,8 @@ class ServerStatsResponseDto {
other.photos == photos &&
other.usage == usage &&
_deepEquality.equals(other.usageByUser, usageByUser) &&
other.usagePhotos == usagePhotos &&
other.usageVideos == usageVideos &&
other.videos == videos;
@override
@@ -40,16 +48,20 @@ class ServerStatsResponseDto {
(photos.hashCode) +
(usage.hashCode) +
(usageByUser.hashCode) +
(usagePhotos.hashCode) +
(usageVideos.hashCode) +
(videos.hashCode);
@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() {
final json = <String, dynamic>{};
json[r'photos'] = this.photos;
json[r'usage'] = this.usage;
json[r'usageByUser'] = this.usageByUser;
json[r'usagePhotos'] = this.usagePhotos;
json[r'usageVideos'] = this.usageVideos;
json[r'videos'] = this.videos;
return json;
}
@@ -66,6 +78,8 @@ class ServerStatsResponseDto {
photos: mapValueOfType<int>(json, r'photos')!,
usage: mapValueOfType<int>(json, r'usage')!,
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']),
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
videos: mapValueOfType<int>(json, r'videos')!,
);
}
@@ -117,6 +131,8 @@ class ServerStatsResponseDto {
'photos',
'usage',
'usageByUser',
'usagePhotos',
'usageVideos',
'videos',
};
}

View File

@@ -1,117 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SmartInfoResponseDto {
/// Returns a new [SmartInfoResponseDto] instance.
SmartInfoResponseDto({
this.objects = const [],
this.tags = const [],
});
List<String>? objects;
List<String>? tags;
@override
bool operator ==(Object other) => identical(this, other) || other is SmartInfoResponseDto &&
_deepEquality.equals(other.objects, objects) &&
_deepEquality.equals(other.tags, tags);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(objects == null ? 0 : objects!.hashCode) +
(tags == null ? 0 : tags!.hashCode);
@override
String toString() => 'SmartInfoResponseDto[objects=$objects, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.objects != null) {
json[r'objects'] = this.objects;
} else {
// json[r'objects'] = null;
}
if (this.tags != null) {
json[r'tags'] = this.tags;
} else {
// json[r'tags'] = null;
}
return json;
}
/// Returns a new [SmartInfoResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SmartInfoResponseDto? fromJson(dynamic value) {
upgradeDto(value, "SmartInfoResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SmartInfoResponseDto(
objects: json[r'objects'] is Iterable
? (json[r'objects'] as Iterable).cast<String>().toList(growable: false)
: const [],
tags: json[r'tags'] is Iterable
? (json[r'tags'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<SmartInfoResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SmartInfoResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SmartInfoResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SmartInfoResponseDto> mapFromJson(dynamic json) {
final map = <String, SmartInfoResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SmartInfoResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SmartInfoResponseDto-objects as value to a dart map
static Map<String, List<SmartInfoResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SmartInfoResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SmartInfoResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@@ -16,6 +16,8 @@ class UsageByUserDto {
required this.photos,
required this.quotaSizeInBytes,
required this.usage,
required this.usagePhotos,
required this.usageVideos,
required this.userId,
required this.userName,
required this.videos,
@@ -27,6 +29,10 @@ class UsageByUserDto {
int usage;
int usagePhotos;
int usageVideos;
String userId;
String userName;
@@ -38,6 +44,8 @@ class UsageByUserDto {
other.photos == photos &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.usage == usage &&
other.usagePhotos == usagePhotos &&
other.usageVideos == usageVideos &&
other.userId == userId &&
other.userName == userName &&
other.videos == videos;
@@ -48,12 +56,14 @@ class UsageByUserDto {
(photos.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(usage.hashCode) +
(usagePhotos.hashCode) +
(usageVideos.hashCode) +
(userId.hashCode) +
(userName.hashCode) +
(videos.hashCode);
@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() {
final json = <String, dynamic>{};
@@ -64,6 +74,8 @@ class UsageByUserDto {
// json[r'quotaSizeInBytes'] = null;
}
json[r'usage'] = this.usage;
json[r'usagePhotos'] = this.usagePhotos;
json[r'usageVideos'] = this.usageVideos;
json[r'userId'] = this.userId;
json[r'userName'] = this.userName;
json[r'videos'] = this.videos;
@@ -82,6 +94,8 @@ class UsageByUserDto {
photos: mapValueOfType<int>(json, r'photos')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
usage: mapValueOfType<int>(json, r'usage')!,
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
userId: mapValueOfType<String>(json, r'userId')!,
userName: mapValueOfType<String>(json, r'userName')!,
videos: mapValueOfType<int>(json, r'videos')!,
@@ -135,6 +149,8 @@ class UsageByUserDto {
'photos',
'quotaSizeInBytes',
'usage',
'usagePhotos',
'usageVideos',
'userId',
'userName',
'videos',

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.120.1+166
version: 1.120.2+167
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@@ -8,11 +8,11 @@ bash tool/build_android.sh x64
bash tool/build_android.sh armv7
bash tool/build_android.sh arm64
mv libisar_android_arm64.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/
mv libisar_android_armv7.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/
mv libisar_android_x64.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/
mv libisar_android_x86.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/
)

View File

@@ -54,6 +54,7 @@ void main() {
.thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
.thenAnswer((_) async => []);
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => []);
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
when(() => syncService.removeAllLocalAlbumsAndAssets())
.thenAnswer((_) async => true);

View File

@@ -7385,7 +7385,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.120.1",
"version": "1.120.2",
"contact": {}
},
"tags": [],
@@ -8402,9 +8402,6 @@
"description": "This property was deprecated in v1.113.0",
"type": "boolean"
},
"smartInfo": {
"$ref": "#/components/schemas/SmartInfoResponseDto"
},
"stack": {
"allOf": [
{
@@ -10969,7 +10966,9 @@
{
"photos": 1,
"videos": 1,
"diskUsageRaw": 1
"diskUsageRaw": 2,
"usagePhotos": 1,
"usageVideos": 1
}
],
"items": {
@@ -10978,6 +10977,16 @@
"title": "Array of usage for each user",
"type": "array"
},
"usagePhotos": {
"default": 0,
"format": "int64",
"type": "integer"
},
"usageVideos": {
"default": 0,
"format": "int64",
"type": "integer"
},
"videos": {
"default": 0,
"type": "integer"
@@ -10987,6 +10996,8 @@
"photos",
"usage",
"usageByUser",
"usagePhotos",
"usageVideos",
"videos"
],
"type": "object"
@@ -11284,25 +11295,6 @@
],
"type": "object"
},
"SmartInfoResponseDto": {
"properties": {
"objects": {
"items": {
"type": "string"
},
"nullable": true,
"type": "array"
},
"tags": {
"items": {
"type": "string"
},
"nullable": true,
"type": "array"
}
},
"type": "object"
},
"SmartSearchDto": {
"properties": {
"city": {
@@ -12525,6 +12517,14 @@
"format": "int64",
"type": "integer"
},
"usagePhotos": {
"format": "int64",
"type": "integer"
},
"usageVideos": {
"format": "int64",
"type": "integer"
},
"userId": {
"type": "string"
},
@@ -12539,6 +12539,8 @@
"photos",
"quotaSizeInBytes",
"usage",
"usagePhotos",
"usageVideos",
"userId",
"userName",
"videos"

View File

@@ -1,18 +1,18 @@
{
"name": "@immich/sdk",
"version": "1.120.1",
"version": "1.120.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.120.1",
"version": "1.120.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"typescript": "^5.3.3"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.120.1",
"version": "1.120.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"typescript": "^5.3.3"
},
"repository": {

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.120.1
* 1.120.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -221,10 +221,6 @@ export type PersonWithFacesResponseDto = {
/** This property was added in v1.107.0 */
updatedAt?: string;
};
export type SmartInfoResponseDto = {
objects?: string[] | null;
tags?: string[] | null;
};
export type AssetStackResponseDto = {
assetCount: number;
id: string;
@@ -267,7 +263,6 @@ export type AssetResponseDto = {
people?: PersonWithFacesResponseDto[];
/** This property was deprecated in v1.113.0 */
resized?: boolean;
smartInfo?: SmartInfoResponseDto;
stack?: (AssetStackResponseDto) | null;
tags?: TagResponseDto[];
thumbhash: string | null;
@@ -974,6 +969,8 @@ export type UsageByUserDto = {
photos: number;
quotaSizeInBytes: number | null;
usage: number;
usagePhotos: number;
usageVideos: number;
userId: string;
userName: string;
videos: number;
@@ -982,6 +979,8 @@ export type ServerStatsResponseDto = {
photos: number;
usage: number;
usageByUser: UsageByUserDto[];
usagePhotos: number;
usageVideos: number;
videos: number;
};
export type ServerStorageResponseDto = {

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20241105@sha256:99eec44db9e281e30eb9c50161cfb8e810f06e4338896b900fb5cafd09e82cd5 AS dev
FROM ghcr.io/immich-app/base-server-dev:20241112@sha256:889647c747b3f999b05e387eff414bcec5e42477958b267930e58ac58dadcfc7 AS dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS web
FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82 AS web
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
@@ -42,7 +42,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20241105@sha256:dbe566f5c53f36640da910ca86a7c5575a26e9b9f6bc8d90ae0a53b8bc3a1f73
FROM ghcr.io/immich-app/base-server-prod:20241112@sha256:26a209563689f52b9a63feeedde9a16a8e0e558483cd3feb5c936423e55c7eea
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.120.1",
"version": "1.120.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.120.1",
"version": "1.120.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",
@@ -83,7 +83,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.120.1",
"version": "1.120.2",
"description": "",
"author": "",
"private": true,
@@ -108,7 +108,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -1,6 +1,7 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { SemVer } from 'semver';
import { ExifOrientation } from 'src/enum';
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
@@ -81,3 +82,19 @@ export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
'nllb-clip-large-siglip__mrl': { dimSize: 1152 },
'nllb-clip-large-siglip__v1': { dimSize: 1152 },
};
type SharpRotationData = {
angle?: number;
flip?: boolean;
flop?: boolean;
};
export const ORIENTATION_TO_SHARP_ROTATION: Record<ExifOrientation, SharpRotationData> = {
[ExifOrientation.Horizontal]: { angle: 0 },
[ExifOrientation.MirrorHorizontal]: { angle: 0, flop: true },
[ExifOrientation.Rotate180]: { angle: 180 },
[ExifOrientation.MirrorVertical]: { angle: 180, flop: true },
[ExifOrientation.MirrorHorizontalRotate270CW]: { angle: 270, flip: true },
[ExifOrientation.Rotate90CW]: { angle: 90 },
[ExifOrientation.MirrorHorizontalRotate90CW]: { angle: 90, flip: true },
[ExifOrientation.Rotate270CW]: { angle: 270 },
} as const;

View File

@@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { getAssetDateTime } from 'src/utils/date-time';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
export class AlbumInfoDto {
@@ -164,8 +165,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
const hasSharedLink = entity.sharedLinks?.length > 0;
const hasSharedUser = sharedUsers.length > 0;
let startDate = assets.at(0)?.fileCreatedAt || undefined;
let endDate = assets.at(-1)?.fileCreatedAt || undefined;
let startDate = getAssetDateTime(assets.at(0));
let endDate = getAssetDateTime(assets.at(-1));
// Swap dates if start date is greater than end date.
if (startDate && endDate && startDate > endDate) {
[startDate, endDate] = [endDate, startDate];

View File

@@ -12,7 +12,6 @@ import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { AssetType } from 'src/enum';
import { mimeTypes } from 'src/utils/mime-types';
@@ -45,7 +44,6 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
isTrashed!: boolean;
isOffline!: boolean;
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
tags?: TagResponseDto[];
people?: PersonWithFacesResponseDto[];
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
@@ -141,7 +139,6 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
isTrashed: !!entity.deletedAt,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces),
@@ -161,15 +158,3 @@ export class MemoryLaneResponseDto {
assets!: AssetResponseDto[];
}
export class SmartInfoResponseDto {
tags?: string[] | null;
objects?: string[] | null;
}
export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
return {
tags: entity.tags,
objects: entity.objects,
};
}

View File

@@ -86,6 +86,10 @@ export class UsageByUserDto {
@ApiProperty({ type: 'integer', format: 'int64' })
usage!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usagePhotos!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usageVideos!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null;
}
@@ -99,6 +103,12 @@ export class ServerStatsResponseDto {
@ApiProperty({ type: 'integer', format: 'int64' })
usage = 0;
@ApiProperty({ type: 'integer', format: 'int64' })
usagePhotos = 0;
@ApiProperty({ type: 'integer', format: 'int64' })
usageVideos = 0;
@ApiProperty({
isArray: true,
type: UsageByUserDto,
@@ -107,7 +117,9 @@ export class ServerStatsResponseDto {
{
photos: 1,
videos: 1,
diskUsageRaw: 1,
diskUsageRaw: 2,
usagePhotos: 1,
usageVideos: 1,
},
],
})

View File

@@ -12,11 +12,8 @@ import {
IsUrl,
Max,
Min,
Validate,
ValidateIf,
ValidateNested,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { SystemConfig } from 'src/config';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
@@ -33,14 +30,7 @@ import {
VideoContainer,
} from 'src/enum';
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
import { ValidateBoolean, validateCronExpression } from 'src/validation';
@ValidatorConstraint({ name: 'cronValidator' })
class CronValidator implements ValidatorConstraintInterface {
validate(expression: string): boolean {
return validateCronExpression(expression);
}
}
import { IsCronExpression, ValidateBoolean } from 'src/validation';
const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
@@ -54,7 +44,7 @@ export class DatabaseBackupConfig {
@ValidateIf(isDatabaseBackupEnabled)
@IsNotEmpty()
@Validate(CronValidator, { message: 'Invalid cron expression' })
@IsCronExpression()
@IsString()
cronExpression!: string;
@@ -244,7 +234,7 @@ class SystemConfigLibraryScanDto {
@ValidateIf(isLibraryScanEnabled)
@IsNotEmpty()
@Validate(CronValidator, { message: 'Invalid cron expression' })
@IsCronExpression()
@IsString()
cronExpression!: string;
}

View File

@@ -5,7 +5,6 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
@@ -143,9 +142,6 @@ export class AssetEntity {
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo?: ExifEntity;
@OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
smartInfo?: SmartInfoEntity;
@OneToOne(() => SmartSearchEntity, (smartSearchEntity) => smartSearchEntity.asset)
smartSearch?: SmartSearchEntity;

Some files were not shown because too many files have changed in this diff Show More