Compare commits

...

151 Commits

Author SHA1 Message Date
Alex The Bot
219f99e516 Version v1.82.0 2023-10-17 01:24:08 +00:00
Jason Rasmussen
1890c0ab6b fix(server): time buckets (#4498) 2023-10-16 13:11:50 -05:00
shenlong
a78e08bac1 fix(mobile): handle asset trash, restore and delete ws events (#4482)
* server: add ASSET_RESTORE ws event

* mobile: handle ASSET_TRASH, ASSET_RESTORE and ASSET_DELETE ws events

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-10-16 13:01:38 -05:00
shenlong
634169235a fix(mobile): reset trashed state for local assets on remote asset deletion (#4501) 2023-10-16 12:26:35 -05:00
shenlong
45ffa65173 feat(web): show trash days info in trash page (#4484) 2023-10-16 11:04:22 -05:00
shenlong
62cb14e4b6 fix(server): include trashed assets in existing assets list (#4483) 2023-10-16 08:47:17 -05:00
cfitzw
3d7e9b7184 chore: ignore default UPLOAD_LOCATION (#4486) 2023-10-15 09:44:20 -04:00
Jason Rasmussen
d2807b8d6a feat(web,server): offline/untracked files admin tool (#4447)
* feat: admin repair orphans tool

* chore: open api

* fix: include upload folder

* fix: bugs

* feat: empty placeholder

* fix: checks

* feat: move buttons to top of page

* feat: styling and clipboard

* styling

* better clicking hitbox

* fix: show title on hover

* feat: download report

* restrict file access to immich related files

* Add description

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2023-10-14 19:12:59 +02:00
GenericGuy
ed386dd12a fix(server): always show people with name, ignore count (#4414) 2023-10-13 20:50:18 -05:00
Jonathan Jogenfors
dadcf49eca fix(server,web): correctly remove metadata from shared links (#4464)
* wip: strip metadata

* fix: authenticate time buckets

* hide detail panel

* fix tests

* fix lint

* add e2e tests

* chore: open api

* fix web compilation error

* feat: test with asset with gps position

* fix: only import fs.promises.cp

* fix: cleanup mapasset

* fix: format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-14 01:46:30 +00:00
Jason Rasmussen
4a9f58bf9b fix(web): empty placeholders (#4470) 2023-10-13 14:47:31 -04:00
Jason Rasmussen
9d225d3d06 refactor(web): admin layout (#4461) 2023-10-13 15:02:28 +00:00
Jason Rasmussen
268a9c4803 chore(web): move trash to alphabetical order (#4462) 2023-10-13 09:40:53 -05:00
Jason Rasmussen
bddeb03fd5 docs: immich title (#4463) 2023-10-13 09:15:29 -05:00
Jonathan Jogenfors
f0bb50b61a fix(server,cli): don't float promises (#4433)
* fix: don't allow floating promises

* fix: await all promises

* fix: download archives

* fix cli tests

* fix: skip web
2023-10-13 01:22:40 -04:00
Alex
7e9fc4aa97 fix(mobile): remove debug description text 2023-10-12 13:23:41 -05:00
Alexander Groß
e57c926676 feat(mobile): offer the same album sorting options on mobile as on web (#3804)
* Add translations for new album sort options

* Support additional album sort options like on web

* Update generated code

* Fix lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-10-12 13:18:54 -05:00
shenlong
f3b17d8f73 fix(server): include trashed asset during face re-index and cleanup (#4450) 2023-10-12 11:35:49 -05:00
martin
41af76bbe2 fix(web): previous previous route when hiding person (#4452) 2023-10-12 10:31:34 -05:00
Alex
9af5e7838f fix(mobile): description not render on first opening (#4451) 2023-10-12 10:30:56 -05:00
shenlong
5dacea6f74 fix(mobile): asset state change not updated in gallery app bar (#4441) 2023-10-11 21:10:59 -05:00
shenlong
18fcca2884 style(mobile): update video indicator (#4443) 2023-10-11 14:03:04 -05:00
martin
8222327299 fix: people initialization on the merge face selector (#4435) 2023-10-11 06:21:02 -04:00
Alex
eebe9bcd5f fix(docs): production build (#4431) 2023-10-10 21:49:24 -05:00
Jonathan Jogenfors
41befc0948 fix(server): don't publicly reveal user count (#4409)
* fix: don't reveal user count publicly

* fix: mobile and user controller

* fix: update other frontend endpoints

* fix: revert openapi change

* chore: open api

* fix: initialize

* openapi

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-11 02:37:13 +00:00
Daniel Dietzler
09bf1c9175 feat(server): harden move file (#4361)
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-10 21:14:44 -05:00
Alex
332a8d80f2 chore: update flutter build version (#4429) 2023-10-10 20:12:01 -05:00
Jonathan Jogenfors
56eb7bf0fc fix(server): improve library scan queuing performance (#4418)
* fix: inline mark asset as offline

* fix: improve log message

* chore: lint

* fix: offline asset algorithm

* fix: use set comparison to check what to import

* fix: only mark new offline files as offline

* fix: compare the correct array

* fix: set default library concurrency to 5

* fix: remove one db call when scanning new files

* chore: remove unused import

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-10 18:59:13 -04:00
Jason Rasmussen
99e9c2ada6 fix(server): schema generation (#4424) 2023-10-10 13:16:57 -05:00
Mert
d8ecefaea5 chore(ml): removed vit-b check and st warning (#4422) 2023-10-10 12:26:30 -05:00
martin
b8d6cc1e09 feat(server,web): improve performances in person page (1) (#4387)
* feat: improve performances in people page

* feat: add loadingspinner when searching

* fix: reset people on error

* fix: case insensitive

* feat: better sql query

* fix: reset people list before api request

* fix: format
2023-10-10 09:34:25 -05:00
Nassos Kat
f36c40bc6b feat(web) add hover background to image toolbar icons (#4402)
* feat: add hover background to image toolbar icons

* not use background color when not set

---------

Co-authored-by: katsadim <athanasios.katsadimas@refurbed.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-10 14:34:17 +00:00
Jonathan Jogenfors
83b63ca12e fix: only check external path once (#4419) 2023-10-10 13:44:43 +00:00
Jonathan Jogenfors
f57acc0802 fix(server): add original path and library id index to asset (#4410)
* fix: add original path index

* fix: use libraryId for index, too

* fix: revert openapi change
2023-10-10 09:38:26 -04:00
Alex
29981b1088 chore(mobile): pump photo_manager version (#4412) 2023-10-10 08:20:52 -05:00
debricked[bot]
43f4dac3ad chore(server,web,docs) bulk bump of dependencies with vulnerabilities (#4312)
* chore: bump node version to 20.8

* chore(server,web) upgrade vulnerable dependencies

* fix: revert node change

* fix: commander version

* fix: set country to null if undefined

* fix: set docs module resolution

* fix(web): correct return type of interval

* fix(docs): set node in tsconfig

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
Co-authored-by: debricked[bot] <47180885+debricked[bot]@users.noreply.github.com>
2023-10-10 08:31:21 -04:00
Jonas Mayer
2370c9ef41 feat(web): save album sort direction (#4401) 2023-10-09 21:29:04 -05:00
Jason Rasmussen
ebb50476ac fix(server): timeline bucket access for shared links (#4404) 2023-10-09 15:57:36 +00:00
Jason Rasmussen
2ea080cacd refactor: domain repositories (#4403) 2023-10-09 14:25:03 +00:00
Jonathan Jogenfors
b56f22aac3 docs: add troubleshooting info to libraries + minor docs tweaks (#4377)
* docs: add troubleshooting info to libraries

* fix: revert docker-compose
2023-10-09 09:13:47 -05:00
Jason Rasmussen
9033e7f179 refactor(server): filesystem crawl (#4395)
Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2023-10-09 08:25:26 -04:00
Jason Rasmussen
9070a361bc chore: organize library imports (#4396) 2023-10-08 22:52:12 -05:00
Jason Rasmussen
d8e66acd02 chore: use force instead of forceRefresh (#4394) 2023-10-08 23:16:13 -04:00
Jason Rasmussen
687d896c63 fix(server): deletable motion assets (#4393) 2023-10-08 20:36:02 -05:00
Daniel Dietzler
0243570c0b fix(server): make system config core singleton (#4392)
* make system config core singleton

* refactor

* fix tests

* chore: fix tests

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-09 00:51:03 +00:00
Daniel Dietzler
66ccf298ba fix library deletion actually deleting assets (#4386) 2023-10-07 16:42:08 -04:00
Jason Rasmussen
982dcd7b8d refactor(server): library asset deletion (#4366) 2023-10-07 13:44:10 -04:00
martin
c68702c0a7 fix(web): merge faces (#4383)
* fix(web): merge faces

* pr feedback
2023-10-07 11:04:08 +00:00
Mert
98a7412855 fix(web): dev mode repeatedly optimizing dependencies (#4379)
* added glob for checking imports

* prettier
2023-10-07 05:49:34 -05:00
shenlong
104880a729 feat(web): ws - on_config_update (#4378) 2023-10-07 05:21:05 -05:00
shenlong
c48d4f01dc Fix/mobile remove upload soft limit (#4380)
* fix(mobile): remove soft limit for manual upload

* chore(mobile): remove translation text
2023-10-07 05:17:50 -05:00
Jason Rasmussen
8d5bf93360 test(server): full backend end-to-end testing with microservices (#4225)
* feat: asset e2e with job option

* feat: checkout test assets

* feat: library e2e tests

* fix: use node 21 in e2e

* fix: tests

* fix: use normalized external path

* feat: more external path tests

* chore: use parametrized tests

* chore: remove unused test code

* chore: refactor test asset path

* feat: centralize test app creation

* fix: correct error message for missing assets

* feat: test file formats

* fix: don't compare checksum

* feat: build libvips

* fix: install meson

* fix: use immich test asset repo

* feat: test nikon raw files

* fix: set Z timezone

* feat: test offline library files

* feat: richer metadata tests

* feat: e2e tests in docker

* feat: e2e test with arm64 docker

* fix: manual docker compose run

* fix: remove metadata processor import

* fix: run e2e tests in test.yml

* fix: checkout e2e assets

* fix: typo

* fix: checkout files in app directory

* fix: increase e2e memory

* fix: rm submodules

* fix: revert action name

* test: mark file offline when external path changes

* feat: rename env var to TEST_ENV

* docs: new test procedures

* feat: can run docker e2e tests manually if needed

* chore: use new node 20.8 for e2e

* chore: bump exiftool-vendored

* feat: simplify test launching

* fix: rename env vars to use immich_ prefix

* feat: asset folder is submodule

* chore: cleanup after 20.8 upgrade

* fix: don't log postgres in e2e

* fix: better warning about not running all tests

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2023-10-06 23:32:28 +02:00
Jason Rasmussen
2f9d0a2404 feat: jpe extension (#4374) 2023-10-06 15:55:20 -05:00
Alex
36b21948bf feat(web): enable websocket (#3765)
* send store event to page

* fix format

* add new asset to existing bucket

* format

* debouncing

* format

* load bucket

* feedback

* feat: listen to deletes and auto-subscribe on all asset grid pages

* feat: auto refresh on person thumbnail

* chore: skip upload event for now

* fix: person thumbnail event

* fix merge

* update handleAssetDeletion with websocket communication

* update info box on mount

* fix test

* fix test

* feat: event for trash asset

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-06 15:48:11 -05:00
Jonathan Jogenfors
4dffae3f39 fix(server): normalize external path (#4239)
* fix: use normalized external path

* fix: move normalization to user core
2023-10-06 20:47:38 +00:00
Jason Rasmussen
35fa6397ea fix: time buckets (#4358)
* fix: time buckets

* chore: update entity metadata

* fix: set correct localDateTime

* fix: display without timezone shifting

* fix: handle non-utc databases

* fix: scrollbar

* docs: comment how buckets are sorted

* chore: remove test/log

* chore: lint

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2023-10-06 12:12:09 +00:00
shenlong
4a8887f37b feat(server): trash asset (#4015)
* refactor(server): delete assets endpoint

* fix: formatting

* chore: cleanup

* chore: open api

* chore(mobile): replace DeleteAssetDTO with BulkIdsDTOs

* feat: trash an asset

* chore(server): formatting

* chore: open api

* chore: wording

* chore: open-api

* feat(server): add withDeleted to getAssets queries

* WIP: mobile-recycle-bin

* feat(server): recycle-bin to system config

* feat(web): use recycle-bin system config

* chore(server): domain assetcore removed

* chore(server): rename recycle-bin to trash

* chore(web): rename recycle-bin to trash

* chore(server): always send soft deleted assets for getAllByUserId

* chore(web): formatting

* feat(server): permanent delete assets older than trashed period

* feat(web): trash empty placeholder image

* feat(server): empty trash

* feat(web): empty trash

* WIP: mobile-recycle-bin

* refactor(server): empty / restore trash to separate endpoint

* test(server): handle failures

* test(server): fix e2e server-info test

* test(server): deletion test refactor

* feat(mobile): use map settings from server-config to enable / disable map

* feat(mobile): trash asset

* fix(server): operations on assets in trash

* feat(web): show trash statistics

* fix(web): handle trash enabled

* fix(mobile): restore updates from trash

* fix(server): ignore trashed assets for person

* fix(server): add / remove search index when trashed / restored

* chore(web): format

* fix(server): asset service test

* fix(server): include trashed assts for duplicates from uploads

* feat(mobile): no dialog for trash, always dialog for permanent delete

* refactor(mobile): use isar where instead of dart filter

* refactor(mobile): asset provide - handle deletes in single db txn

* chore(mobile): review changes

* feat(web): confirmation before empty trash

* server: review changes

* fix(server): handle library changes

* fix: filter external assets from getting trashed / deleted

* fix(server): empty-bin

* feat: broadcast config update events through ws

* change order of trash button on mobile

* styling

* fix(mobile): do not show trashed toast for local only assets

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-06 02:01:14 -05:00
Jason Rasmussen
fc93762230 fix: migration efficiency (#4359)
* fix: migration efficiency

* fix: workflow
2023-10-05 12:35:58 -04:00
Jason Rasmussen
ebd3f7f125 fix: dev compose (#4357) 2023-10-05 09:16:23 -05:00
Jonathan Jogenfors
81009c17bf fix(server): use fileCreatedAt in buckets (#4354) 2023-10-05 08:26:42 -05:00
Daniele Ricci
beb92e8ffb fix: open external link in new tab (#4353) 2023-10-05 12:22:14 +02:00
Oliver Wipfli
7b4e36e990 Update nginx config docs (#4346) 2023-10-04 21:06:20 -04:00
Jason Rasmussen
192e950567 fix: use local time for time buckets and improve memories (#4072)
* fix: timezone bucket timezones

* chore: open api

* fix: interpret local time in utc

* fix: tests

* fix: refactor memory lane

* fix(web): use local date in memory viewer

* chore: set localDateTime non-null

* fix: filter out memories from the current year

* wip: move localDateTime to asset

* fix: correct sorting from db

* fix: migration

* fix: web typo

* fix: formatting

* fix: e2e

* chore: localDateTime is non-null

* chore: more non-nulliness

* fix: asset stub

* fix: tests

* fix: use extract and index for day of year

* fix: don't show memories before today

* fix: cleanup

* fix: tests

* fix: only use localtime for tz

* fix: display memories in client timezone

* fix: tests

* fix: svelte tests

* fix: bugs

* chore: open api

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2023-10-04 22:11:11 +00:00
Alex The Bot
126dd45751 Version v1.81.1 2023-10-04 17:53:42 +00:00
Daniel Dietzler
ff331ffad9 fix(server): Offset of random endpoint could be higher than user's asset count (#4342)
* fix offset of all assets with correct ownerId

* (e2e): test if user does not have all assets
2023-10-04 12:51:44 -05:00
Daniel Dietzler
e571880c16 feat(web, mobile): Options to show archived assets in map (#4293)
* Add include archive setting to map on web

* open api

* better naming for web isArchived variable

* add withArchived setting to mobile

* (e2e): tests for mapMarker endpoint and isArchived

* isArchived to mobile

* chore: cleanup test

* chore: optimize e2e

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-04 09:51:07 -04:00
markeeisner
e5b4d09827 Remove /etc/timezone volume mount from compose (#4336) 2023-10-04 04:01:00 -05:00
Alex The Bot
81d51fbd7e Version v1.81.0 2023-10-03 20:48:23 +00:00
Alex
02f9b40d67 fix(server): library control doesn't apply to new library from the third row (#4331) 2023-10-03 14:05:14 -05:00
Jason Rasmussen
260a600bbc chore(server): dev compose changes (#4316) 2023-10-03 13:06:08 -05:00
Jason Rasmussen
818005fcb5 fix(server): fallback to local timezone when rendering storage template (#4317) 2023-10-03 13:05:44 -05:00
Daniel Dietzler
e5f704cf3b fix asset upload permissions for shared links (#4325) 2023-10-03 12:36:51 -04:00
Jonathan Jogenfors
e2f1e38472 chore(server,web): bump node version to 20.8 (#4311)
* chore: bump node version to 20.8

* fix: remove node hash
2023-10-03 09:34:35 -05:00
Alex
b3c82d5ba2 fix(server): incorrect video creation date EXIF extraction (#4309)
* fix(server): incorrect video creation date EXIF extraction

* update dependency

* update dependency

* revert

* remove unused code
2023-10-03 08:51:40 -05:00
Jonathan Jogenfors
6d1868a6e0 feat: server containers use host timezone (#4313) 2023-10-02 20:50:27 -05:00
Daniel Dietzler
98db9331d8 fix(server): delete face thumbnails when merging people (#4310)
* new job for person deletion, including face thumbnail deletion

* fix tests, delete files directly instead queueing jobs
2023-10-02 21:15:11 -04:00
Alex The Bot
66e860a08e Version v1.80.0 2023-10-02 14:47:21 +00:00
Jonathan Jogenfors
3172c341e0 chore(server): bump exiftool-vendored to 23.1.0 (#4302)
* chore: bump exiftool-vendored

* fix: correct version

* chore: bump exiftool-vendored.pl

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-02 08:54:43 -04:00
Ikko Eltociear Ashimine
8234234c48 docs: add Japanese translated README (#4268)
* docs: add Japanese translated README

* docs: update README_ja_JP.md
2023-10-01 12:55:30 -05:00
Daniel Dietzler
8d5e782fc4 version links to releases page (#4288) 2023-10-01 02:09:25 +07:00
Daniel Dietzler
10d10d9021 chore(server): unit tests for metadata service (#4280)
* unit tests for metadata service

* better test descriptions
2023-09-29 17:25:45 -04:00
Jason Rasmussen
68d6d89a3b feat(web): better context menu position (#4271)
* feat(web): better context menu position

* fix: album context menu

* fix: add middle variant

* fix: rest of context menus

* fix: linting error
2023-09-29 17:41:58 +00:00
Jason Rasmussen
3e73cfb71a fix(server): handle number lists in metadata extraction (#4273) 2023-09-29 11:42:33 -04:00
shenlong
d7e970dcea fix(mobile): reduce server version api calls (#4265) 2023-09-29 21:51:54 +07:00
Daniel Dietzler
fb7249d1f6 fix(web): Context menu underflowing on people page (#4270) 2023-09-29 09:21:51 -04:00
Daniel Dietzler
521436dd21 feat(server): Add week numbers for templating (#4263)
* add week numbers as template option

* generate api

* fix tests

* change example date to show week padding

* change example date to immich birthday
2023-09-28 13:47:31 -04:00
Mert
c145963b02 fix(server): always disable two-pass mode for video thumbnails (#4258)
* always disable two-pass mode for thumbnails

* add regression test

* added bitrate constraint to config mock
2023-09-28 08:29:31 -04:00
Fynn Petersen-Frey
098ab9eae5 fix(mobile): speed up RenderList creation for timeline (#4103)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-28 10:43:55 +07:00
shenlong
a937efe719 feat(mobile): use map settings from server-config (#4045)
* feat(mobile): use map settings from server-config to enable / disable map

* refactor(mobile): remove async await for server info update
2023-09-28 10:26:48 +07:00
Jason Rasmussen
b7fcec7ce3 feat(web): people sidebar link (#4257) 2023-09-28 10:09:54 +07:00
Russell Tan
69c23aa3ec fix(server): Exclude archived assets from search-explore #4041 (#4122)
* Exclude archived assets from search-explore  #4041

* Update test to properly expect an empty array with archived items

* typesense changes wip

* Add isArchived filter to default search filters

* Bump assets typesense schema version

* fix(server): sync bug for bulk asset update

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-27 21:38:55 -04:00
Daniel Dietzler
0a22e64799 refactor(server): merge facial-recognition and person (#4237)
* move facial recognition service into person service

* merge face repository and person repository

* fix imports
2023-09-27 16:46:46 -04:00
Jason Rasmussen
c3d6d69262 fix(server): live photo linking (#4253) 2023-09-27 20:32:58 +00:00
Jason Rasmussen
7cb78ed972 fix(server): android motion photo (#4254) 2023-09-27 20:27:08 +00:00
Daniel Dietzler
cc70f5f6a0 fix(server): Thumbnail migration creating unnecessary directories (#4251)
* fix: during migration folders will be created before checking if needed

* refactor
2023-09-27 15:29:07 -04:00
David Johnson
85efbc6984 fix(server): handle NaN in metadata extraction (#4221)
Fallback to null in event of invalid number.
2023-09-27 15:17:18 -04:00
Daniel Dietzler
3a44e8f8d3 refactor(server): Move metadata extraction to domain (#4243)
* use storageRepository in metadata extraction

* move metadata extraction processor to domain

* cleanup infra/domain

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-27 18:44:51 +00:00
Daniel Dietzler
9bada51d56 feat(server, web)!: Move reverse geocoding settings to the UI (#4222)
* feat: reverse geocoding settings

* chore: open api

* re-init geocoder if precision has been updated

* update docs

* chore: update verbiage

* fix: re-init logic

* fix: reset to default

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-26 14:03:57 +07:00
Jason Rasmussen
7bc6e9ef64 refactor(server): person thumbnail job (#4233)
* refactor(server): person thumbnail job

* fix(server): set feature photo
2023-09-26 14:03:22 +07:00
Jason Rasmussen
ea797c1723 chore: use non-conflicting port to serve docs (#4230) 2023-09-25 23:00:56 -04:00
martin
f63d6d5b67 fix(web): escape shortcut (#3753)
* fix: escape shortcut

* feat: more escape scenarios

* feat: more escape shortcuts

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-26 02:53:26 +00:00
Jonathan Jogenfors
8873c9a02f docs: deprecate read only assets (#4226)
* chore: add deprecation notice in docs

* feat: link to library documentation

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-26 02:26:11 +00:00
Jason Rasmussen
ee0e131efa fix(server): tests (#4229) 2023-09-25 22:17:53 -04:00
Thomas
af5a9d9108 chore(web): use axios isCancel function (#4227)
Use of this function should be more idiomatic and reliable - it's much less
likely for changes to Axios to cause regressions.
2023-09-25 23:20:01 +00:00
Mert
56cf9464af fix(server): use srgb pipeline for srgb images (#4101)
* added color-related exif fields

* remove metadata check, conditional pipe colorspace

* check exif metadata for srgb

* added migration

* updated e2e fixture

* uncased srgb check, search substrings

* extracted exif logic into separate function

* handle images with no bit depth or color metadata

* added unit tests
2023-09-25 19:18:47 -04:00
Jonathan Jogenfors
9676412875 fix: cli import (#4224)
* fix: allow import of assets

* fix: allow deletion of read only assets
2023-09-25 19:04:30 -04:00
Jason Rasmussen
54bea23485 chore(web): remove upload preview (#4219) 2023-09-25 22:28:01 +07:00
Daniel Dietzler
3053cbd4c8 chore(server): Store generated files (thumbnails, encoded video) in subdirectories (#4112)
* save thumbnails in subdirectories

* migration job, migrate assets and face thumbnails

* fix tests

* directory depth of two instead of three

* cleanup empty dirs after migration

* clean up empty dirs after migration, migrate people without assetId

* add job card for new migration job

* fix removeEmptyDirs race condition because of missing await

* cleanup empty directories after asset deletion

* move ensurePath to storage core

* rename jobs

* remove unnecessary property of IEntityJob

* use updated person getById, minor refactoring

* ensure that directory cleanup doesn't interfere with migration

* better description for job in ui

* fix remove directories when migration is done

* cleanup empty folders at start of migration

* fix: actually persist concurrency setting

* add comment explaining regex

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-25 11:07:21 -04:00
Jason Rasmussen
07069c3b1e fix(server): android live motion (#4218) 2023-09-25 09:42:23 -04:00
fujie
c0ce81ca0e fix(mobile): assetList is empty (#4213)
* fix(mobile): assetList is empty

* add comments

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-25 08:09:09 +00:00
Alex
3bef456923 fix(server): metadata extraction error (#4210) 2023-09-25 14:45:26 +07:00
ochen1
91e2348381 fix(server): search for terms separated by hyphens and/or underscores in asset search (#4156)
* Use hyphen and underscore as token separators in search

* Bump typesense asset schema version

* Bump typesense asset schema version
2023-09-25 11:30:16 +07:00
Andreas
1564ed3256 feat(server): add support for .psd files (#4192) 2023-09-24 22:03:14 +07:00
martin
b8fec26115 refactor(web): album listing page (#4146)
* feat: add more options to album page

* pr feedback

* pr feedback

* feat: add quick actions on the list mode

* feat: responsive design

* feat: remove dropdown for display mode

* pr feedback
2023-09-24 20:22:46 +07:00
Daniel Dietzler
dd86aa9259 fix(server): require library.write to upload assets to library (#4200)
* require library.write to upload assets to library

* fix tests
2023-09-24 20:19:36 +07:00
Daniel Dietzler
84e4c15ed5 fix(server): random returning less than count assets (#4201) 2023-09-24 20:18:31 +07:00
Daniele Ricci
25d1b3e1b1 fix(server): use mtime instead of ctime for fileCreatedAt (#4191) 2023-09-24 20:14:25 +07:00
Eric Helgeson
0e63efb490 Correct admin commands (#4177)
* Correct admin commands

Also use text for examples so they can be easily copied and updated.

* Add missing line

* remove leading # per feedback
2023-09-24 02:06:59 +00:00
Daniele Ricci
014d164d99 feat(server): random assets API (#4184)
* feat(server): get random assets API

* Fix tests

* Use correct validation annotation

* Fix offset use in query

* Update API specs

* Fix typo

* Random assets e2e tests

* Improve e2e tests
2023-09-23 22:28:55 +07:00
martin
fc64be6603 feat(web): suggest people when typing a name (#4126)
* feat(web): suggest people when entering a name

* fix: border size from 2 to 1 pixel

* pr feedback

* fix: web unit test

* pr feedback

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-23 11:58:51 +07:00
Alex
9a7e48eaa6 chore(web): remove flowbite (#4178)
* chore(web): remove flowbite

* Added confirmation prompt for deletion
2023-09-23 11:50:21 +07:00
Alex The Bot
e050121dbf Version v1.79.1 2023-09-22 01:37:20 +00:00
Jason Rasmussen
f0a5d39625 fix: live photo uploads (#4167)
* fix: live photo uploads

* fix: format

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-22 08:35:25 +07:00
Alex The Bot
86f5ceb80e Version v1.79.0 2023-09-21 14:17:00 +00:00
martin
06959a9ea5 Revert "fix(web): Assigns description text area with asset description if it exists #4073 (#4125)" (#4160)
This reverts commit b6c6a7e403.
2023-09-21 17:57:59 +07:00
Jonathan Jogenfors
acdc66413c feat(server,web): libraries (#3124)
* feat: libraries

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-20 13:16:33 +02:00
Louis-Marie Michelin
816db700e1 feat(web): cancel pending requests on image change (#4145) 2023-09-19 23:16:53 -04:00
Mert
9030b1f89f chore(deps): upgrade sharp and libvips (#4140)
* upgrade libvips

* upgrade sharp
2023-09-19 15:11:25 -04:00
Alex
2e0c7abd65 fix(mobile): fix background upload regression on Android (#4139) 2023-09-19 16:39:54 +07:00
Daniel Dietzler
1a633f3fca chore(server): Use access core for person permissions (#4138)
* use access core for all person methods

* minor fixes, feedback

* reorder assignments

* remove unnecessary permission requirement

* unify naming of tests

* reorder variables
2023-09-18 21:22:44 +00:00
Jason Rasmussen
dda735ec51 feat(server): add m4v format (#4135) 2023-09-18 19:39:42 +02:00
Daniel Dietzler
f1c98ac9e6 fix(server): Error when loading album with deleted owner (#4086)
* soft delete albums when user gets soft deleted

* fix wrong intl openapi version

* fix tests

* ability to restore albums, automatically restore when user restored

* (e2e) tests for shared albums via link and with user

* (e2e) test deletion of users and linked albums

* (e2e) fix share album with owner test

* fix: deletedAt

* chore: fix restore order

* fix: use timezone date column

* chore: cleanup e2e tests

* (e2e) fix user delete test

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-18 11:56:50 -04:00
Daniel Dietzler
7d07aaeba3 chore(docs): Some SQL queries to copy paste for advanced debugging (#4074)
* enable sql code highlighting, first queries

* remove tabs

* something with moons...

* feat: more queries

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-18 14:25:15 +00:00
martin
a0163d8df0 feat(web): swap between people when merging faces (#4089)
* feat: swap between people when merging faces

* rename

* fix: remove url parameter when closing

* chore: handler naming

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-18 13:24:31 +00:00
Thomas
49ef86173f fix(server): use exiftool decoded values and unify metadata extraction (#2908)
* refactor(server): metadata extraction

* chore: upgrade exiftool

* chore: remove log

* fix: other rotation cases

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-18 09:06:03 -04:00
Russell Tan
b6c6a7e403 fix(web): Assigns description text area with asset description if it exists #4073 (#4125) 2023-09-18 14:21:28 +07:00
Ploonet
672560f55b fix(web): accordion arrow was not anchored to the right (#4129)
Co-authored-by: Antony Mota <amota.confluenceconseil@boiron.fr>
2023-09-18 11:06:11 +07:00
GenericGuy
94cbbf3c4b feat(web): add setting for minimum face count for face detection (#4128)
* feat: add setting for minimum face count for face detection

Adds the minimum face count setting to the web interface to
circumvent detection of strangers and random background people
if desired.

* fix: codestyle, remove max for face count
2023-09-18 11:05:35 +07:00
Daniel Dietzler
40b802a5a9 fix(web): context menu underflowing (#4127) 2023-09-18 11:03:18 +07:00
bo0tzz
a63f027bf7 chore(docs): Fix incorrect header (#4123) 2023-09-18 11:02:28 +07:00
Fynn Petersen-Frey
1c02e1dadf feat(mobile): image caching & viewer improvements (#4095) 2023-09-18 10:57:05 +07:00
Ploonet
63b6a71ebd feat(web): make settings accordion click area larger (#4119)
* feat: make settings accordion click area larger

* fix: svelte check & prettier

---------

Co-authored-by: Antony Mota <amota.confluenceconseil@boiron.fr>
2023-09-17 20:36:34 +07:00
Mert
0a9b632e48 fix(server): use libopus for transcoding (#4102)
* updated audio codec enum

* added migration

* updated api

* fixed enum

* formatting

* simplified migration
2023-09-16 00:52:45 +00:00
martin
7fcc5a5417 feat(web): hide face from detail page (#4098) 2023-09-15 22:58:14 +07:00
Alex
9cec6aaf46 chore: post release tasks 2023-09-14 22:16:15 +07:00
Alex The Bot
a3206bf950 Version v1.78.1 2023-09-14 13:56:33 +00:00
Alex
9c627920dd fix(server): sanitize import path (#4094) 2023-09-14 20:53:28 +07:00
Azsde
8a421eb778 fix (mobile): Fix slow album thumbnail generation for album picker (#3905)
* [BUGFIX] Fix slow album thumbnail generation

When generating the thumbnail of an album, all of the pictures of the
album are retrieved but only the first picture of the album is used.

Retrieving all of the pictures of the album at once can cause huge performance
issues on large albums.

Since only the first picture is used to generate the thumbnail, this commit
uses the getAssetListPaged method instead of getAssetListRange, effectively
retrieving only the first picture of the album.

* [DEVMINOR] Remove unecessary check

As we already check for the number of assets in the album, when
fetching assets using `album.getAssetListPaged` the returned result
won't be empty.

---------

Co-authored-by: Azsde <aelkaim@pixium-vision.com>
2023-09-13 22:32:06 +07:00
Joran Vancoillie
14e681c954 chore: Fix typos in Dutch readme.md translation (#4082) 2023-09-13 22:21:27 +07:00
Jordy
095a5e0ffb chore: Add NL readme (#4080)
* Addedd-NL-Readme

* Changed NA to NVT

* Update README_ca_ES.md

* Update README_fr_FR.md

* Update README_tr_TR.md

* Update README_zh_CN.md

* Update README.md

* Translated features grid

* Translated headers
2023-09-13 09:22:09 -04:00
Alex
b1d31a4567 chore: post release 2023-09-13 17:27:31 +07:00
570 changed files with 42412 additions and 17721 deletions

View File

@@ -45,7 +45,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.13.3"
flutter-version: "3.13.6"
cache: true
- name: Create the Keystore

View File

@@ -23,7 +23,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.13.3"
flutter-version: "3.13.6"
- name: Install dependencies
run: dart pub get

View File

@@ -13,20 +13,15 @@ jobs:
e2e-tests:
name: Run end-to-end test suites
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./server
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
with:
submodules: "recursive"
- name: Run e2e tests
run: npm run test:e2e
if: ${{ !cancelled() }}
run: docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
doc-tests:
name: Run documentation checks
@@ -149,7 +144,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.13.3"
flutter-version: "3.13.6"
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1
@@ -223,15 +218,27 @@ jobs:
--health-retries 5
ports:
- 5432:5432
defaults:
run:
working-directory: ./server
steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Install server dependencies
run: npm --prefix server ci
run: npm ci
- name: Build the
run: npm run build
- name: Run existing migrations
run: npm --prefix server run typeorm:migrations:run
run: npm run typeorm:migrations:run
- name: Generate new migrations
continue-on-error: true
run: npm --prefix server run typeorm:migrations:generate ./src/infra/migrations/TestMigration
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@v13.1
id: verify-changed-files

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
.idea
docker/upload
docker/library
uploads
coverage

3
.gitmodules vendored
View File

@@ -1,3 +1,6 @@
[submodule "mobile/.isar"]
path = mobile/.isar
url = https://github.com/isar/isar
[submodule "server/test/assets"]
path = server/test/assets
url = https://github.com/immich-app/test-assets

View File

@@ -20,7 +20,7 @@ pull-stage:
docker-compose -f ./docker/docker-compose.staging.yml pull
test-e2e:
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
prod:
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans

View File

@@ -23,6 +23,8 @@
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Disclaimer

View File

@@ -23,6 +23,8 @@
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Avís legal

View File

@@ -23,6 +23,7 @@
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Descargo de responsabilidad

View File

@@ -23,6 +23,8 @@
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Clause de non-responsabilité

111
README_ja_JP.md Normal file
View File

@@ -0,0 +1,111 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - 高性能なセルフホスト 写真/ビデオバックアップソリューション</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
</p>
## 免責事項
- ⚠️ このプロジェクトは **非常に活発に** 開発中です。
- ⚠️ バグの存在や変更が入ることも予想されます。
- ⚠️ **写真やビデオを保存する唯一の方法としてこのアプリを使用しないでください。**
- ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
## コンテンツ
- [公式ドキュメント](https://immich.app/docs)
- [ロードマップ](https://github.com/orgs/immich-app/projects/1)
- [デモ](#デモ)
- [機能](#機能)
- [紹介](https://immich.app/docs/overview/introduction)
- [インストール](https://immich.app/docs/install/requirements)
- [コントリビューションガイド](https://immich.app/docs/overview/support-the-project)
- [プロジェクトのサポート](#プロジェクトのサポート)
## ドキュメント
インストールガイドを含む主なドキュメントは、https://immich.app/ です。
## デモ
web デモは https://demo.immich.app からアクセスできます
モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app/api` を使用することができます
```bash title="Demo Credential"
The credential
email: demo@immich.app
password: demo
```
```
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
# 機能
| 機能 | モバイル | Web |
| ------------------------------------------- | ------ | --- |
| ビデオや写真のアップロードと表示 | はい | はい |
| アプリを開いたとき自動バックアップ | はい | N/A |
| バックアップ用アルバム選択 | はい | N/A |
| 写真やビデオをローカルデバイスにダウンロード | はい | はい |
| マルチユーザー対応 | はい | はい |
| アルバムと共有アルバム | はい | はい |
| スクラブ可能/ドラッグ可能スクロールバ | はい | はい |
| 生のフォーマットに対応 | はい | はい |
| メタデータ表示EXIF、地図 | はい | はい |
| メタデータ、オブジェクト、フェース、CLIPによる検索 | はい | はい |
| 管理機能(ユーザー管理) | いいえ | はい |
| バックグラウンドバックアップ | はい | N/A |
| 仮想スクロール | はい | はい |
| OAuth サポート | はい | はい |
| API キー | N/A | はい |
| LivePhoto のバックアップと再生 | iOS | はい |
| ユーザー定義のストレージ構造 | はい | はい |
| 公開シェアリング | いいえ | はい |
| アーカイブとお気に入り | はい | はい |
| グローバルマップ | はい | はい |
| パートナー共有 | はい | はい |
| 思い出x 年前)顔認識とクラスタリング | はい | はい |
| 思い出x 年前) | はい | はい |
| オフラインサポート | はい | いいえ |
| 読み取り専用ギャラリー | はい | はい |
# プロジェクトのサポート
私はこのプロジェクトにコミットしてきました。ドキュメントを更新し、新しい機能を追加し、バグを修正し続けるつもりですが、私ひとりではできません。だから、続けるためのモチベーションをさらに高めてくれる皆さんの助けが必要なのです。
[selfhosted.show - In the episode 'The-organization-must-いいえt-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
もし、あなたがこのプロジェクトに賛同し、このアプリを長く使い続けたいと思われるのであれば、以下のオプションから支援をご検討ください。
## 寄付
- GitHub スポンサー経由の[毎月の寄付](https://github.com/sponsors/alextran1502)
- GitHub スポンサー経由の[一回寄付](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

112
README_nl_NL.md Normal file
View File

@@ -0,0 +1,112 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login met aangepaste URL">
</p>
<h3 align="center">Immich - Hoogwaardige, self-hosted back-up oplossing voor foto's en video's</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Disclaimer
- ⚠️ Het project wordt momenteel **zeer actief** ontwikkeld.
- ⚠️ Verwacht bugs en ingrijpende wijzigingen.
- ⚠️ **Gebruik de app niet als de enige manier om uw foto's en video's op te slaan.**
- ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's!
## Inhoud
- [Officiële documentatie](https://immich.app/docs)
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Functies](#functies)
- [Introductie](https://immich.app/docs/overview/introduction)
- [Installatie](https://immich.app/docs/install/requirements)
- [Richtlijnen voor bijdragen](https://immich.app/docs/overview/support-the-project)
- [Steun het project](#steun-het-project)
## Documentatie
De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/.
## Demo
De demo is te bekijken op https://demo.immich.app.
Voor de mobiele app kunt u gebruik maken van `https://demo.immich.app/api` voor de `Server Endpoint URL`
```bash title="Demo Credential"
De inloggegevens
email: demo@immich.app
wachtwoord: demo
```
```
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
# Functies
| Functies | Mobiel | Web |
|-----------------------------------------------------|--------|-----|
| Upload en bekijk video's en foto's | Ja | Ja |
| Automatische back-up wanneer de app wordt geopend | Ja | NVT |
| Selectieve album(s) voor back-up | Ja | NVT |
| Download foto's en video's naar een lokaal apparaat | Ja | Ja |
| Ondersteuning voor meerdere gebruikers | Ja | Ja |
| Album en gedeelde albums | Ja | Ja |
| Versleepbare scroll balk | Ja | Ja |
| Ondersteuning voor het RAW formaat | Ja | Ja |
| Metagegevensweergave (EXIF, kaart) | Ja | Ja |
| Zoek op metagegevens, objecten, gezichten en CLIP | Ja | Ja |
| Administratieve functies (gebruikersbeheer) | Nee | Ja |
| Back-up op de achtergrond | Ja | NVT |
| Virtueel scrollen | Ja | Ja |
| OAuth-ondersteuning | Ja | Ja |
| API-sleutels | NVT | Ja |
| LivePhoto-back-up en weergave | iOS | Ja |
| Door de gebruiker gedefinieerde opslagstructuur | Ja | Ja |
| Openbaar delen | Nee | Ja |
| Archief en Favorieten | Ja | Ja |
| Wereldkaart | Ja | Ja |
| Delen met partner | Ja | Ja |
| Gezichtsherkenning en groepering | Ja | Ja |
| Herinneringen (x jaar geleden) | Ja | Ja |
| Offline-ondersteuning | Ja | Nee |
| Alleen-lezen galerij | Ja | Ja |
# Steun het project
Ik ben trouw aan dit project en ik zal niet stoppen. Ik zal de documenten blijven bijwerken, nieuwe functies toevoegen en bugs oplossen. Maar ik kan het niet alleen. Ik heb dus jouw hulp nodig om mij extra motivatie te geven om door te gaan.
Als onze gastheren in de [selfhosted.show - In de aflevering 'The-organization-must-Neet-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) zeiden, dit is een eNeerme onderneming van wat het team en ik doen. En ik zou dit graag fulltime willen doen, ik vraag jouw hulp om dat mogelijk te maken.
Als je denkt dat dit het juiste doel is en de app iets is dat je jezelf al heel lang ziet gebruiken, overweeg dan om het project te steunen met de onderstaande optie.
## Doneren
- [Maandelijkse donatie](https://github.com/sponsors/alextran1502) via GitHub Sponsors
- [Eenmalige donatie](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

View File

@@ -23,6 +23,8 @@
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Feragatname

View File

@@ -27,6 +27,8 @@
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>

View File

@@ -18,6 +18,7 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'prettier/prettier': 0,
},
};

6808
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.78.0
* The version of the OpenAPI document: 1.82.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.78.0
* The version of the OpenAPI document: 1.82.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.78.0
* The version of the OpenAPI document: 1.82.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.78.0
* The version of the OpenAPI document: 1.82.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -34,6 +34,7 @@ const other = [
'orf',
'ori',
'pef',
'psd',
'raf',
'raw',
'rwl',

View File

@@ -25,7 +25,7 @@ export class CrawledAsset {
async process() {
const stats = await fs.promises.stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
this.fileCreatedAt = stats.ctime.toISOString();
this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size;

View File

@@ -19,9 +19,9 @@ program
)
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.action((paths, options) => {
.action(async (paths, options) => {
options.excludePatterns = options.ignore;
new Upload().run(paths, options);
await new Upload().run(paths, options);
});
program
@@ -37,18 +37,18 @@ program
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
.argument('[paths...]', 'One or more paths to assets to be imported')
.action((paths, options) => {
.action(async (paths, options) => {
options.import = true;
options.excludePatterns = options.ignore;
new Upload().run(paths, options);
await new Upload().run(paths, options);
});
program
.command('server-info')
.description('Display server information')
.action(() => {
new ServerInfo().run();
.action(async () => {
await new ServerInfo().run();
});
program
@@ -56,8 +56,8 @@ program
.description('Login using an API key')
.argument('[instanceUrl]')
.argument('[apiKey]')
.action((paths, options) => {
new LoginKey().run(paths, options);
.action(async (paths, options) => {
await new LoginKey().run(paths, options);
});
program.parse(process.argv);

View File

@@ -67,7 +67,7 @@ describe('SessionService', () => {
});
});
it('should create auth file when logged in', async () => {
it.skip('should create auth file when logged in', async () => {
mockfs();
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');

View File

@@ -53,7 +53,14 @@ export class SessionService {
if (!fs.existsSync(this.configDir)) {
// Create config folder if it doesn't exist
fs.mkdirSync(this.configDir, { recursive: true });
const created = await fs.promises.mkdir(this.configDir, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${this.configDir}`);
}
}
if (!fs.existsSync(this.configDir)) {
console.error('waah');
}
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));

View File

@@ -1,35 +1,24 @@
import { UploadService } from './upload.service';
import mockfs from 'mock-fs';
import axios from 'axios';
import mockAxios from 'jest-mock-axios';
import FormData from 'form-data';
import { ApiConfiguration } from '../cores/api-configuration';
jest.mock('axios', () => jest.fn());
describe('UploadService', () => {
let uploadService: UploadService;
beforeAll(() => {
// Write a dummy output before mock-fs to prevent some annoying errors
console.log();
});
beforeEach(() => {
const apiConfiguration = new ApiConfiguration('https://example.com/api', 'key');
uploadService = new UploadService(apiConfiguration);
});
it('should upload a single file', async () => {
it('should call axios', async () => {
const data = new FormData();
uploadService.upload(data);
await uploadService.upload(data);
mockAxios.mockResponse();
expect(axios).toHaveBeenCalled();
});
afterEach(() => {
mockfs.restore();
mockAxios.reset();
});
});

View File

@@ -42,21 +42,21 @@ export class UploadService {
};
}
public checkIfAssetAlreadyExists(path: string, checksum: string): Promise<any> {
public checkIfAssetAlreadyExists(path: string, checksum: string) {
this.checkAssetExistenceConfig.data = JSON.stringify({ assets: [{ id: path, checksum: checksum }] });
// TODO: retry on 500 errors?
return axios(this.checkAssetExistenceConfig);
}
public upload(data: FormData): Promise<any> {
public upload(data: FormData) {
this.uploadConfig.data = data;
// TODO: retry on 500 errors?
return axios(this.uploadConfig);
}
public import(data: any): Promise<any> {
public import(data: any) {
this.importConfig.data = data;
// TODO: retry on 500 errors?

View File

@@ -1,16 +0,0 @@
# Database
DB_HOSTNAME=immich-database-test
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=e2e_test
# Redis
REDIS_HOSTNAME=immich-redis-test
# Upload File Config
UPLOAD_LOCATION=./upload
# WEB
VITE_SERVER_ENDPOINT=http://localhost:2283/api
TYPESENSE_ENABLED=false

View File

@@ -11,8 +11,9 @@ services:
command: npm run start:debug immich
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
ports:
- 3001:3001
- 9230:9230
@@ -25,25 +26,6 @@ services:
- database
- typesense
immich-machine-learning:
container_name: immich_machine_learning
image: immich-machine-learning-dev:latest
build:
context: ../machine-learning
dockerfile: Dockerfile
ports:
- 3003:3003
volumes:
- ../machine-learning:/usr/src/app
- model-cache:/cache
env_file:
- .env
environment:
- NODE_ENV=development
depends_on:
- database
restart: unless-stopped
immich-microservices:
container_name: immich_microservices
image: immich-microservices:latest
@@ -57,8 +39,9 @@ services:
command: npm run start:debug microservices
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
@@ -94,6 +77,25 @@ services:
depends_on:
- immich-server
immich-machine-learning:
container_name: immich_machine_learning
image: immich-machine-learning-dev:latest
build:
context: ../machine-learning
dockerfile: Dockerfile
ports:
- 3003:3003
volumes:
- ../machine-learning:/usr/src/app
- model-cache:/cache
env_file:
- .env
environment:
- NODE_ENV=development
depends_on:
- database
restart: unless-stopped
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
@@ -103,7 +105,7 @@ services:
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- tsdata:/data
- ${UPLOAD_LOCATION}/typesense:/data
redis:
container_name: immich_redis
@@ -119,7 +121,7 @@ services:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
@@ -141,6 +143,4 @@ services:
restart: unless-stopped
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -10,6 +10,7 @@ services:
command: ["./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:
@@ -29,7 +30,7 @@ services:
env_file:
- .env
restart: always
immich-microservices:
container_name: immich_microservices
image: immich-microservices:latest
@@ -42,6 +43,7 @@ services:
command: ["./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:

View File

@@ -1,5 +1,7 @@
version: "3.8"
# Compose file for dockerized end-to-end testing of the backend
services:
immich-server-test:
image: immich-server-test
@@ -8,39 +10,31 @@ services:
dockerfile: Dockerfile
target: builder
command: npm run test:e2e
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
env_file:
- .env.test
environment:
- NODE_ENV=development
- TYPESENSE_ENABLED=false
- DB_HOSTNAME=immich-database-test
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=e2e_test
- IMMICH_RUN_ALL_TESTS=true
depends_on:
- immich-redis-test
- immich-database-test
networks:
- immich-test-network
immich-redis-test:
container_name: immich-redis-test
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
networks:
- immich-test-network
immich-database-test:
container_name: immich-database-test
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env.test
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
volumes:
- /var/lib/postgresql/data
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: e2e_test
networks:
- immich-test-network
logging:
driver: none
networks:
immich-test-network:

View File

@@ -4,9 +4,10 @@ services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "immich" ]
command: ["start.sh", "immich"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:
@@ -21,9 +22,10 @@ services:
# extends:
# file: hwaccel.yml
# service: hwaccel
command: [ "start.sh", "microservices" ]
command: ["start.sh", "microservices"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:

View File

@@ -20,22 +20,9 @@ Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
### Why does my uploaded photo show up with the wrong date or time in Immich?
When a photo is initially uploaded Immich uses the create date of the file to determine where it belongs in the timeline. After that, background jobs will run that extract [exif metadata](https://en.wikipedia.org/wiki/Exif), including the CreateDate, to provide a more accurate date for the photo. If that is not available it will fallback to the modified date. If you want to ensure your photo has the right date, check the exif metadata before uploading.
If the timezone is incorrect in an uploaded photo, check the `DateTimeOriginal` exif field of the uploaded file. Immich uses the very competent library [exiftool-vendored.js](https://github.com/photostructure/exiftool-vendored.js#dates) to handle timezone parsing, but in some cases (like photos taken with DSLR cameras) it has to fallback on the local timezone. If you are using docker, this fallback will be UTC. (Note that even the photo backup app that can't be named [has the same bug!](https://photo.stackexchange.com/a/126978)) In Immich, it is possible to change this assumed fallback timezone system-wide by setting the timezone in the microservices docker container. You might need to run the "Extract Metadata" job after to effect the change.
As an example, the following modification of `docker-compose.yml` will set the timezone of the microservices container to be `Europe/Stockholm`
```
environment:
- TZ=Europe/Stockholm # <---- Add this line in the microservices config
```
### Why are only photos and not videos being uploaded to Immich?
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes.
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes. Also check the disk space of your reverse proxy, in some cases proxies caches requests to disk before passing them on, and if disk space runs out the request fails.
### Why is Immich slow on low-memory systems like the Raspberry Pi?

View File

@@ -19,7 +19,7 @@ Users can deploy a custom reverse proxy that forwards requests to Immich's rever
### Nginx example config
Below is an example config for nginx:
Below is an example config for nginx. Make sure to include `client_max_body_size 50000M;` also in a `http` block in `/etc/nginx/nginx.conf`.
```nginx
server {

View File

@@ -16,20 +16,53 @@ To run a command, [connect](/docs/guides/docker-help.md#attach-to-a-container) t
## Examples
Note that the commands below should begin with `immich-admin`.
Reset Admin Password
![Reset Admin Password](./img/reset-admin-password.png)
```
immich-admin reset-admin-password
Found Admin:
- ID=e65e6f88-2a30-4dbe-8dd9-1885f4889b53
- OAuth ID=
- Email=admin@example.com
- Name=Immich Admin
? Please choose a new password (optional) immich-is-cool
The admin password has been updated.
```
Disable Password Login
![Disable Password Login](./img/disable-password-login.png)
```
immich-admin disable-password-login
Password login has been disabled.
```
Enabled Password Login
![Enable Password Login](./img/enable-password-login.png)
```
immich-admin enable-password-login
Password login has been enabled.
```
List Users
![List Users](./img/list-users.png)
```
immich-admin list-users
[
{
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
email: 'immich@example.com.com',
firstName: 'Immich',
lastName: 'Admin',
storageLabel: 'admin',
externalPath: null,
profileImagePath: 'upload/profile/e65e6f88-2a30-4dbe-8dd9-1885f4889b53/e65e6f88-2a30-4dbe-8dd9-1885f4889b53.jpg',
shouldChangePassword: true,
isAdmin: true,
createdAt: 2023-07-11T20:12:20.602Z,
deletedAt: null,
updatedAt: 2023-09-21T15:42:28.129Z,
oauthId: '',
memoriesEnabled: true
}
]
```

View File

@@ -0,0 +1,17 @@
# Testing
## Server
### Unit tests
Unit are run by calling `npm run test` from the `server` directory.
### End to end tests
The backend has an end-to-end test suite that can be called with `npm run test:e2e` from the `server` directory. This will set up a dummy database inside a temporary container and run the tests against it. Setup and teardown is automatically taken care of. That test, however, can not set up all prerequisites to parse file formats, as that is very complex and error-prone. As such, this test excludes some test cases like HEIC file imports. The test suite will also print a friendly warning to remind you that not all tests are being run.
Note that there is a bug in nodejs <20.8 that causes segmentation faults when running these tests. If you run into segfaults, ensure you are using at least version 20.8.
To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perfom the tests and exit.
If you manually install the dependencies (see the DOCKERFILE) on your development machine, you can also run the full e2e tests manually by setting the `IMMICH_RUN_ALL_TESTS` environment value to true, i.e. `IMMICH_RUN_ALL_TESTS=true npm run test:e2e`.

View File

@@ -56,8 +56,6 @@ The API key can be obtained in the user setting panel on the web interface.
---
## Uploading existing libraries
### Run via Docker
You can run the CLI inside of a docker container to avoid needing to install anything.

View File

@@ -0,0 +1,170 @@
# Libraries
## Overview
Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries.
## The Upload Library
Immich comes preconfigured with an upload library for each user. All assets uploaded to Immich are added to this library. This library can be renamed, but not deleted. The upload library is the only library that can be synced with a mobile device. No items in an upload library is allowed to have the same sha1 hash as another item in the same library in order to prevent duplicates.
## External Libraries
External libraries tracks assets stored outside of immich, i.e. in the file system. Immich will only read data from the files, and will not modify them in any way. Therefore, the delete button is disabled for external assets. When the external library is scanned, immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc.
If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case:
- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets
- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files.
- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk.
:::caution
Due to aggressive caching it can take some time for a refreshed asset to appear correctly in the web view. You need to clear the cache in your browser to see the changes. This is a known issue and will be fixed in a future release. In Chrome, you need to open the developer console with F12, then reload the page with F5, and finally right click on the reload button and select "Empty Cache and Hard Reload".
:::
In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries.
:::caution
If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release.
:::
### Deleted External Assets
In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored.
Finally, files can be deleted from Immich via the `Remove Offline Files` job. Any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. Note that a library scan must be performed first to mark the assets as offline.
### Import Paths
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. If the import paths are edited in a way that an external file is no longer in any import path, it will be removed from the library in the same way a deleted file would. If the file is moved back to an import path, it will be added again as if it was a new file.
### Troubleshooting
Sometimes, an external library will not scan correctly. This can happen if the immich_server or immich_microservices can't access the files. Here are some things to check:
- Is the external path set correctly?
- In the docker-compose file, are the volumes mounted correctly?
- Are the volumes identical between the `server` and `microservices` container?
- Are the import paths set correctly, and do they match the path set in docker-compose file?
- Are the permissions set correctly?
If all else fails, you can always start a shell inside the container and check if the path is accessible. For example, `docker exec -it immich_microservices /bin/bash` will start a bash shell. If your import path, for instance, is `/data/import/photos`, you can check if the files are accessible by running `ls /data/import/photos`. Also check the `immich_server` container in the same way.
### Security Considerations
:::caution
Please read and understand this section before setting external paths, as there are important security considerations.
:::
For security purposes, each Immich user is disallowed to add external files by default. This is to prevent devastating [path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal). An admin can allow individual users to use external path feature via the `external path` setting found in the admin panel. Without the external path restriction, a user can add any image or video file on the Immich host filesystem to be imported into Immich, potentially allowing sensitive data to be accessed. If you are running Immich as root in your Docker setup (which is the default), all external file reads are done with root privileges. This is particularly dangerous if the Immich host is a shared server.
With the `external path` set, a user is restricted to accessing external files to files or directories within that path. The Immich admin should still be careful not set the external path too generously. For example, `user1` wants to read their photos in to `/home/user1`. A lazy admin sets that user's external path to `/home/` since it "gets the job done". However, that user will then be able to read all photos in `/home/user2/private-photos`, too! Please set the external path as specific as possible. If multiple folders must be added, do this using the docker volume mount feature described below.
### Exclusion Patterns and Scan Settings
By default, all files in the import paths will be added to the library. If there are files that should not be added, exclusion patterns can be used to exclude them. Exclusion patterns are glob patterns are matched against the full file path. If a file matches an exclusion pattern, it will not be added to the library. Exclusion patterns can be added in the Scan Settings page for each library. Under the hood, Immich uses the [glob](https://www.npmjs.com/package/glob) package to match patterns, so please refer to [their documentation](https://github.com/isaacs/node-glob#glob-primer) to see what patterns are supported.
Some basic examples:
- `*.tif` will exclude all files with the extension `.tif`
- `hidden.jpg` will exclude all files named `hidden.jpg`
- `**/Raw/**` will exclude all files in any directory named `Raw`
- `*.(tif,jpg)` will exclude all files with the extension `.tif` or `.jpg`
### Nightly job
There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion.
## Usage
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
- `/home/user/old-pics`: a folder contining childhood photos.
- `/mnt/nas/christmas-trip`: photos from a christmas trip. The subfolder `/mnt/nas/christmas-trip/Raw` contains the raw files directly from the DSLR. We don't want to import the raw files to Immich
- `/mnt/media/videos`: Videos from the same christmas trip.
First, we need to plan how we want to organize the libraries. The christmas trip photos should belong to its own library since we want to exclude the raw files. The videos and old photos can be in the same library since we want to import all files. We could also add all three folders to the same library if there are no files matching the Raw exclusion pattern in the other folders.
### Mount Docker Volumes
`immich-server` and `immich-microservices` containers will need access to the gallery. Modify your docker compose file as follows
```diff title="docker-compose.yml"
immich-server:
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos:ro
immich-microservices:
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos:ro
```
:::tip
The `ro` flag at the end only gives read-only access to the volumes. While Immich does not modify files, it's a good practice to mount read-only.
:::
_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._
### Set External Path
Only an admin can do this.
- Navigate to `Administration > Users` page on the web.
- Click on the user edit button.
- Set `/mnt/media` to be the external path. This folder will only contain the three folders that we want to import, so nothing else can be accessed.
### Create External Libraries
- Click on your user name in the top right corner -> Account Settings
- Click on Libraries
- Click on Create External Library
- Click the drop-down menu on the newly created library
- Click on Rename Library and rename it to "Christmas Trip"
- Click Edit Import Paths
- Click on Add Path
- Enter `/mnt/media/christmas-trip` then click Add
NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/christmas-trip` path since all paths have to be what the Docker containers see.
Next, we'll add an exclusion pattern to filter out raw files.
- Click the drop-down menu on the newly christmas library
- Click on Manage
- Click on Scan Settings
- Click on Add Exclusion Pattern
- Enter `**/Raw/**` and click save.
- Click save
- Click the drop-down menu on the newly created library
- Click on Scan Library Files
The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library.
- Click on Create External Library.
:::info Note
If you get an error here, please rename the other external library to something else. This is a bug that will be fixed in a future release.
:::
- Click the drop-down menu on the newly created library
- Click Edit Import Paths
- Click on Add Path
- Enter `/mnt/media/old-pics` then click Add
- Click on Add Path
- Enter `/mnt/media/videos` then click Add
- Click Save
- Click on Scan Library Files
Within seconds, the assets from the old-pics and videos folders should show up in the main timeline.

View File

@@ -1,4 +1,10 @@
# Read-only Gallery [Experimental]
# Read-only Gallery [Deprecated]
:::caution
This feature is being deprecated in favor of [Libraries](/docs/features/libraries.md).
:::
## Overview
@@ -6,18 +12,6 @@ This feature enables users to use an existing gallery without uploading the asse
Upon syncing the file information, it will be read by Immich to generate supported files.
:::caution
This feature is still in an experimental stage. And this is an initial implementation and will receive improvements in the future.
The current limitations of this feature are:
- Assets are not automatically synced and must instead be manually synced with the CLI tool.
- Only new files that are added to the gallery will be detected.
- Deleted and moved files will not be detected.
:::
## Usage
:::tip Example scenario

View File

@@ -0,0 +1,85 @@
# Database Queries
:::danger
Keep in mind that mucking around in the database might set the moon on fire. Avoid modifying the database directly when possible, and always have current backups.
:::
:::tip
Run `docker exec -it immich_postgres psql immich <DB_USERNAME>` to connect to the database via the container directly.
(Replace `<DB_USERNAME>` wit the value from your [`.env` file](/docs/install/environment-variables#database)).
:::
## Assets
:::note
The `"originalFileName"` column is the name of the uploaded file _without_ the extension.
:::
```sql title="Find by original filename"
SELECT * FROM "assets" WHERE "originalFileName" = 'PXL_20230903_232542848';
SELECT * FROM "assets" WHERE "originalFileName" LIKE 'PXL_%'; -- all files starting with PXL_
SELECT * FROM "assets" WHERE "originalFileName" LIKE '%_2023_%'; -- all files with _2023_ in the middle
```
```sql title="Find by path"
SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_20230903_232542848.jpg';
SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
```
```sql title="Find by checksum" (sha1)
SELECT encode("checksum", 'hex') FROM "assets";
SELECT * FROM "assets" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e033bf74dd1', 'hex');
```
```sql title="Live photos"
SELECT * FROM "assets" where "livePhotoVideoId" IS NOT NULL;
```
```sql title="Without metadata"
SELECT "assets".* FROM "exif" LEFT JOIN "assets" ON "assets"."id" = "exif"."assetId" WHERE "exif"."assetId" IS NULL;
```
```sql title="Without thumbnails"
SELECT * FROM "assets" WHERE "assets"."resizePath" IS NULL OR "assets"."webpPath" IS NULL;
```
```sql title="By type"
SELECT * FROM "assets" WHERE "assets"."type" = 'VIDEO';
SELECT * FROM "assets" WHERE "assets"."type" = 'IMAGE';
```
```sql title="Count by type"
SELECT "assets"."type", count(*) FROM "assets" GROUP BY "assets"."type";
```
```sql title="Count by type (per user)"
SELECT
"users"."email", "assets"."type", COUNT(*)
FROM
"assets"
JOIN
"users" ON "assets"."ownerId" = "users"."id"
GROUP BY
"assets"."type", "users"."email"
ORDER BY
"users"."email";
```
```sql title="Failed file movements"
SELECT * FROM "move_history";
```
## Users
```sql title="List"
SELECT * FROM "users";
```
## System Config
```sql title="Custom settings"
SELECT "key", "value" FROM "system_config";
```
(Only used when not using the [config file](/docs/install/config-file))

View File

@@ -1,7 +1,3 @@
---
sidebar_position: 1
---
# Docker Help
## Containers

View File

@@ -1,4 +1,4 @@
# Hosting the machine-learning service on another system
# Remote Machine Learning
To alleviate [performance issues on low-memory systems](/docs/FAQ.md#why-is-immich-slow-on-low-memory-systems-like-the-raspberry-pi) like the Raspberry Pi, you may also host Immich's machine-learning container on a more powerful system (e.g. your laptop or desktop computer):

View File

@@ -70,7 +70,8 @@ The default configuration looks like this:
"enabled": true,
"modelName": "buffalo_l",
"minScore": 0.7,
"maxDistance": 0.6
"maxDistance": 0.6,
"minFaces": 1
}
},
"oauth": {

View File

@@ -49,11 +49,9 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## Geocoding
| Variable | Description | Default | Services |
| :--------------------------------- | :---------------------------------- | :--------------------------: | :------------ |
| `DISABLE_REVERSE_GEOCODING` | Disable Reverse Geocoding Precision | `false` | microservices |
| `REVERSE_GEOCODING_PRECISION` | Reverse Geocoding Precision | `3` | microservices |
| `REVERSE_GEOCODING_DUMP_DIRECTORY` | Reverse Geocoding Dump Directory | `./.reverse-geocoding-dump/` | microservices |
| Variable | Description | Default | Services |
| :--------------------------------- | :------------------------------- | :--------------------------: | :------------ |
| `REVERSE_GEOCODING_DUMP_DIRECTORY` | Reverse Geocoding Dump Directory | `./.reverse-geocoding-dump/` | microservices |
## Ports

View File

@@ -89,6 +89,7 @@ const config = {
},
},
navbar: {
title: 'IMMICH',
logo: {
alt: 'Immich University Logo',
src: 'img/color-logo.png',
@@ -161,6 +162,7 @@ const config = {
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
additionalLanguages: ['sql'],
},
image: 'overview/img/feature-panel.png',
}),

9112
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"docusaurus": "docusaurus",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"start": "docusaurus start",
"start": "docusaurus start --port 3005",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
@@ -17,10 +17,11 @@
"check": "tsc"
},
"dependencies": {
"@docusaurus/core": "^2.4.1",
"@docusaurus/preset-classic": "^2.4.1",
"@docusaurus/core": "^2.4.3",
"@docusaurus/preset-classic": "^2.4.3",
"@mdx-js/react": "^1.6.22",
"autoprefixer": "^10.4.13",
"classnames": "^2.3.2",
"clsx": "^1.2.1",
"docusaurus-lunr-search": "^2.3.2",
"docusaurus-preset-openapi": "^0.6.3",

View File

@@ -40,3 +40,7 @@ button {
--ifm-background-color: #000000;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
.navbar__brand .navbar__title {
@apply font-immich-title text-2xl font-normal text-immich-primary dark:text-immich-dark-primary;
}

View File

@@ -1,7 +1,9 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@tsconfig/docusaurus/tsconfig.json",
"compilerOptions": {
"baseUrl": "."
"baseUrl": ".",
"module": "Node16"
}
}

View File

@@ -16,13 +16,6 @@ from ..config import log
from ..schemas import ModelType
from .base import InferenceModel
_ST_TO_JINA_MODEL_NAME = {
"clip-ViT-B-16": "ViT-B-16::openai",
"clip-ViT-B-32": "ViT-B-32::openai",
"clip-ViT-B-32-multilingual-v1": "M-CLIP/XLM-Roberta-Large-Vit-B-32",
"clip-ViT-L-14": "ViT-L-14::openai",
}
class CLIPEncoder(InferenceModel):
_model_type = ModelType.CLIP
@@ -36,11 +29,10 @@ class CLIPEncoder(InferenceModel):
) -> None:
if mode is not None and mode not in ("text", "vision"):
raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
if "vit-b" not in model_name.lower():
raise ValueError(f"Only ViT-B models are currently supported; got '{model_name}'")
if model_name not in _MODELS:
raise ValueError(f"Unknown model name {model_name}.")
self.mode = mode
jina_model_name = self._get_jina_model_name(model_name)
super().__init__(jina_model_name, cache_dir, **model_kwargs)
super().__init__(model_name, cache_dir, **model_kwargs)
def _download(self) -> None:
models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
@@ -104,20 +96,6 @@ class CLIPEncoder(InferenceModel):
return outputs[0][0].tolist()
def _get_jina_model_name(self, model_name: str) -> str:
if model_name in _MODELS:
return model_name
elif model_name in _ST_TO_JINA_MODEL_NAME:
log.warn(
(
f"Sentence-Transformer models like '{model_name}' are not supported."
f"Using '{_ST_TO_JINA_MODEL_NAME[model_name]}' instead as it is the best match for '{model_name}'."
),
)
return _ST_TO_JINA_MODEL_NAME[model_name]
else:
raise ValueError(f"Unknown model name {model_name}.")
def _download_model(self, model_name: str, model_md5: str) -> bool:
# downloading logic is adapted from clip-server's CLIPOnnxModel class
download_model(

View File

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

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 101,
"android.injected.version.name" => "1.78.0",
"android.injected.version.code" => 106,
"android.injected.version.name" => "1.82.0",
}
)
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

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.585931">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="24.755096">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.176668">
</testcase>

View File

@@ -163,7 +163,6 @@
"home_page_building_timeline": "Building the timeline",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_success": "Download Success",
"library_page_albums": "Albums",
@@ -174,6 +173,8 @@
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"library_page_sort_most_recent_photo": "Most recent photo",
"library_page_sort_last_modified": "Last modified",
"login_disabled": "Login has been disabled",
"login_form_api_exception": "API exception. Please check the server URL and try again.",
"login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",
@@ -225,6 +226,7 @@
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
"profile_drawer_app_logs": "Logs",
"profile_drawer_trash": "Trash",
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_settings": "Settings",
"profile_drawer_sign_out": "Sign Out",
@@ -312,6 +314,7 @@
"map_settings_dialog_title": "Map Settings",
"map_settings_dark_mode": "Dark mode",
"map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_include_show_archived": "Include Archived",
"map_settings_only_relative_range": "Date range",
"map_settings_dialog_cancel": "Cancel",
"map_settings_dialog_save": "Save",
@@ -321,5 +324,17 @@
"map_no_location_permission_title": "Location Permission denied",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_location_dialog_cancel": "Cancel",
"map_location_dialog_yes": "Yes"
"map_location_dialog_yes": "Yes",
"trash_page_title": "Trash ({})",
"trash_page_info": "Trashed items will be permanently deleted after {} days",
"trash_page_no_assets": "No trashed assets",
"trash_page_delete": "Delete",
"trash_page_delete_all": "Delete All",
"trash_page_restore": "Restore",
"trash_page_restore_all": "Restore All",
"trash_page_select_btn": "Select",
"trash_page_select_assets_btn": "Select assets",
"trash_page_empty_trash_btn": "Empty trash",
"trash_page_empty_trash_dialog_ok": "Ok",
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich"
}

View File

@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 116;
CURRENT_PROJECT_VERSION = 118;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 116;
CURRENT_PROJECT_VERSION = 118;
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 = 116;
CURRENT_PROJECT_VERSION = 118;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -59,11 +59,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.76.0</string>
<string>1.78.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>116</string>
<string>118</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.78.0"
version_number: "1.82.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.611762">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="7.645306">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.937008">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.669798">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.740416">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.218788">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="93.625943">
<testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="62.107671">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="89.490906">
</testcase>

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/cache/widgets_binding.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/android_device_asset.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -37,7 +38,7 @@ import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
ImmichWidgetsBinding();
final db = await loadDb();
await initApp();

View File

@@ -47,6 +47,7 @@ class LibraryPage extends HookConsumerWidget {
useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
List<Album> sortedAlbums() {
// Created.
if (selectedAlbumSortOrder.value == 0) {
return albums
.where((a) => a.isRemote)
@@ -54,6 +55,34 @@ class LibraryPage extends HookConsumerWidget {
.reversed
.toList();
}
// Album title.
if (selectedAlbumSortOrder.value == 1) {
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
}
// Most recent photo, if unset (e.g. empty album, use modifiedAt / updatedAt).
if (selectedAlbumSortOrder.value == 2) {
return albums
.where((a) => a.isRemote)
.sorted(
(a, b) => a.lastModifiedAssetTimestamp != null &&
b.lastModifiedAssetTimestamp != null
? a.lastModifiedAssetTimestamp!
.compareTo(b.lastModifiedAssetTimestamp!)
: a.modifiedAt.compareTo(b.modifiedAt),
)
.reversed
.toList();
}
// Last modified.
if (selectedAlbumSortOrder.value == 3) {
return albums
.where((a) => a.isRemote)
.sortedBy((album) => album.modifiedAt)
.reversed
.toList();
}
// Fallback: Album title.
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
}
@@ -61,6 +90,8 @@ class LibraryPage extends HookConsumerWidget {
final options = [
"library_page_sort_created".tr(),
"library_page_sort_title".tr(),
"library_page_sort_most_recent_photo".tr(),
"library_page_sort_last_modified".tr(),
];
return PopupMenuButton(

View File

@@ -117,6 +117,7 @@ class SharingPage extends HookConsumerWidget {
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
child: Row(

View File

@@ -13,9 +13,11 @@ final archiveProvider = StreamProvider<RenderList>((ref) async* {
final query = ref
.watch(dbProvider)
.assets
.where()
.ownerIdEqualToAnyChecksum(user.isarId)
.filter()
.ownerIdEqualTo(user.isarId)
.isArchivedEqualTo(true)
.isTrashedEqualTo(false)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =

View File

@@ -364,7 +364,18 @@ class ExifBottomSheet extends HookConsumerWidget {
children: [
buildDragHeader(),
buildDate(),
if (asset.isRemote) DescriptionInput(asset: asset),
assetWithExif.when(
data: (data) => DescriptionInput(asset: data),
error: (error, stackTrace) => Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
),
loading: () => const SizedBox(
width: 75,
height: 75,
child: CircularProgressIndicator.adaptive(),
),
),
const SizedBox(height: 8.0),
buildLocation(),
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({
@@ -14,7 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
required this.isPlayingMotionVideo,
required this.onFavorite,
required this.onUploadPressed,
required this.isFavorite,
}) : super(key: key);
final Asset asset;
@@ -23,19 +23,19 @@ class TopControlAppBar extends HookConsumerWidget {
final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo;
final VoidCallback onAddToAlbumPressed;
final VoidCallback? onFavorite;
final Function(Asset) onFavorite;
final bool isPlayingMotionVideo;
final bool isFavorite;
@override
Widget build(BuildContext context, WidgetRef ref) {
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
Widget buildFavoriteButton() {
Widget buildFavoriteButton(a) {
return IconButton(
onPressed: onFavorite,
onPressed: () => onFavorite(a),
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
a.isFavorite ? Icons.favorite : Icons.favorite_border,
color: Colors.grey[200],
),
);
@@ -123,7 +123,7 @@ class TopControlAppBar extends HookConsumerWidget {
size: iconSize,
),
actions: [
if (asset.isRemote) buildFavoriteButton(),
if (asset.isRemote) buildFavoriteButton(a),
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal) buildDownloadButton(),

View File

@@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
@@ -18,11 +19,15 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/cache/original_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
@@ -31,8 +36,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:openapi/api.dart' as api;
import 'package:openapi/api.dart' show ThumbnailFormat;
// ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget {
@@ -51,6 +55,9 @@ class GalleryViewerPage extends HookConsumerWidget {
final PageController controller;
static const jpeg = ThumbnailFormat.JPEG;
static const webp = ThumbnailFormat.WEBP;
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);
@@ -59,11 +66,17 @@ class GalleryViewerPage extends HookConsumerWidget {
final isZoomed = useState<bool>(false);
final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false);
final progressValue = useState(0.0);
Offset? localPosition;
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
final header = {"Authorization": authToken};
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
final isTrashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final navStack = AutoRouter.of(context).stackData;
final isFromTrash = isTrashEnabled &&
navStack.length > 2 &&
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
Asset asset() => currentAsset;
@@ -83,93 +96,52 @@ class GalleryViewerPage extends HookConsumerWidget {
.watch(assetProvider.notifier)
.toggleFavorite([asset], !asset.isFavorite);
/// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider(
Asset asset,
api.ThumbnailFormat type,
) {
return CachedNetworkImageProvider(
getThumbnailUrl(
asset,
type: type,
),
cacheKey: getThumbnailCacheKey(
asset,
type: type,
),
headers: {"Authorization": authToken},
);
}
/// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider(
getImageUrl(asset),
cacheKey: getImageCacheKey(asset),
headers: {"Authorization": authToken},
);
}
/// Thumbnail image of a local asset. Required asset.isLocal
ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: ThumbnailSize(
MediaQuery.of(context).size.width.floor(),
MediaQuery.of(context).size.height.floor(),
),
);
}
ImageProvider remoteOriginalProvider(Asset asset) =>
CachedNetworkImageProvider(
getImageUrl(asset),
cacheKey: getImageCacheKey(asset),
headers: header,
);
/// Original (large) image of a local asset. Required asset.isLocal
ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(
isOriginal: true,
asset.local!,
);
ImageProvider localOriginalProvider(Asset asset) =>
OriginalImageProvider(asset);
ImageProvider finalImageProvider(Asset asset) {
if (ImmichImage.useLocal(asset)) {
return localOriginalProvider(asset);
} else if (isLoadOriginal.value) {
return remoteOriginalProvider(asset);
} else if (isLoadPreview.value) {
return ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
}
return ImmichImage.remoteThumbnailProvider(asset, webp, header);
}
Iterable<ImageProvider> allImageProviders(Asset asset) sync* {
if (ImmichImage.useLocal(asset)) {
yield ImmichImage.localThumbnailProvider(asset);
yield localOriginalProvider(asset);
} else {
yield ImmichImage.remoteThumbnailProvider(asset, webp, header);
if (isLoadPreview.value) {
yield ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
}
if (isLoadOriginal.value) {
yield remoteOriginalProvider(asset);
}
}
}
void precacheNextImage(int index) {
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
}
if (index < totalAssets && index >= 0) {
final asset = loadAsset(index);
if (!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
// Preload the local asset
precacheImage(localImageProvider(asset), context);
} else {
onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
}
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
),
context,
onError: onError,
);
if (isLoadPreview.value) {
// Precache the JPEG thumbnail
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
),
context,
onError: onError,
);
}
if (isLoadOriginal.value) {
// Preload the original asset
precacheImage(
originalImageProvider(asset),
context,
onError: onError,
);
}
for (final imageProvider in allImageProviders(asset)) {
precacheImage(imageProvider, context, onError: onError);
}
}
}
@@ -199,25 +171,47 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
void handleDelete(Asset deleteAsset) {
void handleDelete(Asset deleteAsset) async {
Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
{deleteAsset},
force: force,
);
if (isDeleted) {
if (totalAssets == 1) {
// Handle only one asset
AutoRouter.of(context).pop();
} else {
// Go to next page otherwise
controller.nextPage(
duration: const Duration(milliseconds: 100),
curve: Curves.fastLinearToSlowEaseIn,
);
}
}
return isDeleted;
}
// Asset is trashed
if (isTrashEnabled && !isFromTrash) {
final isDeleted = await onDelete(false);
// Can only trash assets stored in server. Local assets are always permanently removed for now
if (context.mounted && isDeleted && deleteAsset.isRemote) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'Asset trashed',
gravity: ToastGravity.BOTTOM,
);
}
return;
}
// Asset is permanently removed
showDialog(
context: context,
builder: (BuildContext _) {
return DeleteDialog(
onDelete: () {
if (totalAssets == 1) {
// Handle only one asset
AutoRouter.of(context).pop();
} else {
// Go to next page otherwise
controller.nextPage(
duration: const Duration(milliseconds: 100),
curve: Curves.fastLinearToSlowEaseIn,
);
}
ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
},
);
return DeleteDialog(onDelete: () => onDelete(true));
},
);
}
@@ -303,10 +297,8 @@ class GalleryViewerPage extends HookConsumerWidget {
child: TopControlAppBar(
isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: asset(),
isFavorite: asset().isFavorite,
onMoreInfoPressed: showInfo,
onFavorite:
asset().isRemote ? () => toggleFavorite(asset()) : null,
onFavorite: toggleFavorite,
onUploadPressed:
asset().isLocal ? () => handleUpload(asset()) : null,
onDownloadPressed: asset().isLocal
@@ -346,7 +338,6 @@ class GalleryViewerPage extends HookConsumerWidget {
activeColor: Colors.white,
inactiveColor: Colors.white.withOpacity(0.75),
onChanged: (position) {
progressValue.value = position;
ref.read(videoPlayerControlsProvider.notifier).position = position;
},
),
@@ -485,27 +476,6 @@ class GalleryViewerPage extends HookConsumerWidget {
}
});
ImageProvider imageProvider(Asset asset) {
if (!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
return localImageProvider(asset);
} else {
if (isLoadOriginal.value) {
return originalImageProvider(asset);
} else if (isLoadPreview.value) {
return remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
);
} else {
return remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
);
}
}
}
return Scaffold(
backgroundColor: Colors.black,
body: WillPopScope(
@@ -531,79 +501,51 @@ class GalleryViewerPage extends HookConsumerWidget {
itemCount: totalAssets,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (currentIndex.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
final next = currentIndex.value < value ? value + 1 : value - 1;
precacheNextImage(next);
currentIndex.value = value;
progressValue.value = 0.0;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value
? (context, event) {
final a = asset();
if (!a.isLocal ||
(a.isRemote &&
Store.get(StoreKey.preferRemoteImage, false))) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(
a,
type: api.ThumbnailFormat.WEBP,
),
cacheKey: getThumbnailCacheKey(
a,
type: api.ThumbnailFormat.WEBP,
),
httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) =>
const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);
loadingBuilder: (context, event, index) {
final a = loadAsset(index);
if (ImmichImage.useLocal(a)) {
return Image(
image: ImmichImage.localThumbnailProvider(a),
fit: BoxFit.contain,
);
}
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(a, type: webp),
cacheKey: getThumbnailCacheKey(a, type: webp),
httpHeaders: header,
progressIndicatorBuilder: (_, __, ___) => const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);
if (isLoadOriginal.value) {
// loading the preview in the loadingBuilder only
// makes sense if the original is loaded in the builder
return CachedNetworkImage(
imageUrl: getThumbnailUrl(
a,
type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
a,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
errorWidget: (_, __, ___) => webPThumbnail,
);
} else {
return webPThumbnail;
}
} else {
return Image(
image: localThumbnailImageProvider(a),
fit: BoxFit.contain,
);
}
}
: null,
// loading the preview in the loadingBuilder only
// makes sense if the original is loaded in the builder
return isLoadPreview.value && isLoadOriginal.value
? CachedNetworkImage(
imageUrl: getThumbnailUrl(a, type: jpeg),
cacheKey: getThumbnailCacheKey(a, type: jpeg),
httpHeaders: header,
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
errorWidget: (_, __, ___) => webPThumbnail,
)
: webPThumbnail;
},
builder: (context, index) {
final asset = loadAsset(index);
final ImageProvider provider = imageProvider(asset);
final ImageProvider provider = finalImageProvider(asset);
if (asset.isImage && !isPlayingMotionVideo.value) {
return PhotoViewGalleryPageOptions(

View File

@@ -217,24 +217,25 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final assetCountInAlbum = await album.assetCountAsync;
if (assetCountInAlbum > 0) {
final assetList =
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
final assetList = await album.getAssetListPaged(page: 0, size: 1);
if (assetList.isNotEmpty) {
final thumbnailAsset = assetList.first;
try {
final thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
} catch (e, stack) {
log.severe(
"Failed to get thumbnail for album ${album.name}",
e.toString(),
stack,
);
}
// Even though we check assetCountInAlbum to make sure that there are assets in album
// The `getAssetListPaged` method still return empty list and cause not assets get rendered
if (assetList.isEmpty) {
continue;
}
final thumbnailAsset = assetList.first;
try {
final thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
} catch (e, stack) {
log.severe(
"Failed to get thumbnail for album ${album.name}",
e.toString(),
stack,
);
}
availableAlbums.add(availableAlbum);

View File

@@ -220,7 +220,9 @@ class BackupService {
final List<String> duplicatedAssetIds = [];
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
await PhotoManager.requestPermissionExtend();
if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend();
}
List<AssetEntity> assetsToUpload = sortAssets
// Upload images before video assets

View File

@@ -81,7 +81,9 @@ class BackupControllerPage extends HookConsumerWidget {
context: context,
msg: "Deleting ${assets.length} assets on the server...",
);
await ref.read(assetProvider.notifier).deleteAssets(assets);
await ref
.read(assetProvider.notifier)
.deleteAssets(assets, force: true);
ImmichToast.show(
context: context,
msg: "Deleted ${assets.length} assets on the server. "

View File

@@ -13,9 +13,11 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
final query = ref
.watch(dbProvider)
.assets
.where()
.ownerIdEqualToAnyChecksum(user.isarId)
.filter()
.ownerIdEqualTo(user.isarId)
.isFavoriteEqualTo(true)
.isTrashedEqualTo(false)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =

View File

@@ -142,7 +142,7 @@ class RenderList {
) async {
final List<RenderAssetGridElement> elements = [];
const pageSize = 500;
const pageSize = 50000;
const sectionSize = 60; // divides evenly by 2,3,4,5,6
if (groupBy == GroupAssetsBy.none) {

View File

@@ -60,6 +60,39 @@ class ThumbnailImage extends StatelessWidget {
}
}
Widget buildVideoIcon() {
final minutes = asset.duration.inMinutes;
final durationString = asset.duration.toString();
return Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
minutes > 59
? durationString.substring(0, 7) // h:mm:ss
: minutes > 0
? durationString.substring(2, 7) // mm:ss
: durationString.substring(3, 7), // m:ss
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(
width: 3,
),
const Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 18,
),
],
),
);
}
Widget buildImage() {
final image = SizedBox(
width: 300,
@@ -162,26 +195,7 @@ class ThumbnailImage extends StatelessWidget {
size: 18,
),
),
if (!asset.isImage)
Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
if (!asset.isImage) buildVideoIcon(),
],
),
);

View File

@@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart';
@@ -43,6 +44,8 @@ class ControlBottomAppBar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
var hasRemote = selectionAssetState == AssetState.remote;
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
Widget renderActionButtons() {
return Row(
@@ -70,14 +73,20 @@ class ControlBottomAppBar extends ConsumerWidget {
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),
onPressed: enabled
? () => showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete,
);
},
)
? () {
if (!trashEnabled) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete,
);
},
);
} else {
onDelete();
}
}
: null,
),
if (!hasRemote)

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dar
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ProfileDrawer extends HookConsumerWidget {
@@ -16,6 +17,9 @@ class ProfileDrawer extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
buildSignOutButton() {
return ListTile(
leading: SizedBox(
@@ -91,6 +95,29 @@ class ProfileDrawer extends HookConsumerWidget {
);
}
buildTrashButton() {
return ListTile(
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.delete_rounded,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_trash",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () {
AutoRouter.of(context).push(const TrashRoute());
},
);
}
return Drawer(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
@@ -105,6 +132,7 @@ class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawerHeader(),
buildSettingButton(),
buildAppLogButton(),
if (trashEnabled) buildTrashButton(),
buildSignOutButton(),
],
),

View File

@@ -43,6 +43,8 @@ class HomePage extends HookConsumerWidget {
final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider);
final currentUser = ref.watch(currentUserProvider);
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
@@ -54,7 +56,7 @@ class HomePage extends HookConsumerWidget {
Future(() => ref.read(assetProvider.notifier).getAllAsset());
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.read(serverInfoProvider.notifier).getServerVersion();
ref.read(serverInfoProvider.notifier).getServerInfo();
selectionEnabledHook.addListener(() {
multiselectEnabled.state = selectionEnabledHook.value;
@@ -139,30 +141,34 @@ class HomePage extends HookConsumerWidget {
void onDelete() async {
processing.value = true;
try {
await ref.read(assetProvider.notifier).deleteAssets(selection.value);
await ref
.read(assetProvider.notifier)
.deleteAssets(selection.value, force: !trashEnabled);
final hasRemote = selection.value.any((a) => a.isRemote);
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
final trashOrRemoved =
!trashEnabled ? 'deleted permanently' : 'trashed';
if (hasRemote) {
ImmichToast.show(
context: context,
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
gravity: ToastGravity.BOTTOM,
);
}
selectionEnabledHook.value = false;
} finally {
processing.value = false;
}
}
void onUpload() async {
void onUpload() {
processing.value = true;
selectionEnabledHook.value = false;
try {
final Set<Asset> assets = selection.value;
if (assets.length > 30) {
ImmichToast.show(
context: context,
msg: 'home_page_upload_err_limit'.tr(),
gravity: ToastGravity.BOTTOM,
);
} else {
processing.value = false;
selectionEnabledHook.value = false;
await ref
.read(manualUploadProvider.notifier)
.uploadAssets(context, assets);
}
ref
.read(manualUploadProvider.notifier)
.uploadAssets(context, selection.value);
} finally {
processing.value = false;
}

View File

@@ -1,29 +1,33 @@
class MapState {
final bool isDarkTheme;
final bool showFavoriteOnly;
final bool includeArchived;
final int relativeTime;
MapState({
this.isDarkTheme = false,
this.showFavoriteOnly = false,
this.includeArchived = false,
this.relativeTime = 0,
});
MapState copyWith({
bool? isDarkTheme,
bool? showFavoriteOnly,
bool? includeArchived,
int? relativeTime,
}) {
return MapState(
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
includeArchived: includeArchived ?? this.includeArchived,
relativeTime: relativeTime ?? this.relativeTime,
);
}
@override
String toString() {
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)';
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived)';
}
@override
@@ -33,13 +37,15 @@ class MapState {
return other is MapState &&
other.isDarkTheme == isDarkTheme &&
other.showFavoriteOnly == showFavoriteOnly &&
other.relativeTime == relativeTime;
other.relativeTime == relativeTime &&
other.includeArchived == includeArchived;
}
@override
int get hashCode {
return isDarkTheme.hashCode ^
showFavoriteOnly.hashCode ^
relativeTime.hashCode;
relativeTime.hashCode ^
includeArchived.hashCode;
}
}

View File

@@ -10,6 +10,7 @@ final mapMarkersProvider =
final mapState = ref.read(mapStateNotifier);
DateTime? fileCreatedAfter;
bool? isFavorite;
bool? isIncludeArchived;
if (mapState.relativeTime != 0) {
fileCreatedAfter =
@@ -20,8 +21,13 @@ final mapMarkersProvider =
isFavorite = true;
}
if (!mapState.includeArchived) {
isIncludeArchived = false;
}
final markers = await service.getMapMarkers(
isFavorite: isFavorite,
withArchived: isIncludeArchived,
fileCreatedAfter: fileCreatedAfter,
);

View File

@@ -11,6 +11,8 @@ class MapStateNotifier extends StateNotifier<MapState> {
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
showFavoriteOnly: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
relativeTime: appSettingsProvider
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
),
@@ -31,11 +33,19 @@ class MapStateNotifier extends StateNotifier<MapState> {
void switchFavoriteOnly(bool isFavoriteOnly) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapShowFavoriteOnly,
appSettingsProvider,
isFavoriteOnly,
);
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
}
void switchIncludeArchived(bool isIncludeArchived) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapIncludeArchived,
isIncludeArchived,
);
state = state.copyWith(includeArchived: isIncludeArchived);
}
void setRelativeTime(int relativeTime) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapRelativeDate,

View File

@@ -23,12 +23,14 @@ class MapSerivce {
Future<List<MapMarkerResponseDto>> getMapMarkers({
bool? isFavorite,
bool? withArchived,
DateTime? fileCreatedAfter,
DateTime? fileCreatedBefore,
}) async {
try {
final markers = await _apiService.assetApi.getMapMarkers(
isFavorite: isFavorite,
isArchived: withArchived,
fileCreatedAfter: fileCreatedAfter,
fileCreatedBefore: fileCreatedBefore,
);

View File

@@ -13,6 +13,7 @@ class MapSettingsDialog extends HookConsumerWidget {
final mapSettings = ref.read(mapStateNotifier);
final isDarkMode = useState(mapSettings.isDarkTheme);
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
final showIncludeArchived = useState(mapSettings.includeArchived);
final showRelativeDate = useState(mapSettings.relativeTime);
final ThemeData theme = Theme.of(context);
@@ -48,6 +49,22 @@ class MapSettingsDialog extends HookConsumerWidget {
);
}
Widget buildIncludeArchivedSetting() {
return SwitchListTile.adaptive(
value: showIncludeArchived.value,
onChanged: (value) {
showIncludeArchived.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_include_show_archived".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildDateRangeSetting() {
final now = DateTime.now();
return DropdownMenu(
@@ -127,6 +144,8 @@ class MapSettingsDialog extends HookConsumerWidget {
mapSettingsNotifier.switchTheme(isDarkMode.value);
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
mapSettingsNotifier
.switchIncludeArchived(showIncludeArchived.value);
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
@@ -166,6 +185,7 @@ class MapSettingsDialog extends HookConsumerWidget {
children: [
buildMapThemeSetting(),
buildFavoriteOnlySetting(),
buildIncludeArchivedSetting(),
const SizedBox(
height: 10,
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -29,8 +30,9 @@ class MapThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tileLayer = TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
urlTemplate: ref.watch(
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
),
);
return SizedBox(

View File

@@ -20,6 +20,7 @@ import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
@@ -154,7 +155,8 @@ class MapPageState extends ConsumerState<MapPage> {
ref.listen(mapStateNotifier, (previous, next) {
bool shouldRefetch =
previous?.showFavoriteOnly != next.showFavoriteOnly ||
previous?.relativeTime != next.relativeTime;
previous?.relativeTime != next.relativeTime ||
previous?.includeArchived != next.includeArchived;
if (shouldRefetch) {
refetchMarkers.value = shouldRefetch;
ref.invalidate(mapMarkersProvider);
@@ -358,8 +360,9 @@ class MapPageState extends ConsumerState<MapPage> {
}
final tileLayer = TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
urlTemplate: ref.watch(
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
),
maxNativeZoom: 19,
maxZoom: 19,
);

View File

@@ -22,9 +22,9 @@ class MemoryService {
Future<List<Memory>?> getMemoryLane() async {
try {
final now = DateTime.now();
final beginningOfDate = DateTime(now.year, now.month, now.day);
final data = await _apiService.assetApi.getMemoryLane(
beginningOfDate,
now.day,
now.month,
);
if (data == null) {

View File

@@ -8,15 +8,21 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:latlong2/latlong.dart';
class CuratedPlacesRow extends CuratedRow {
final bool isMapEnabled;
const CuratedPlacesRow({
super.key,
required super.content,
this.isMapEnabled = true,
super.imageSize,
super.onTap,
});
@override
Widget build(BuildContext context) {
// Calculating the actual index of the content based on the whether map is enabled or not.
// If enabled, inject map as the first item in the list (index 0) and so the actual content will start from index 1
final int actualContentIndex = isMapEnabled ? 1 : 0;
Widget buildMapThumbnail() {
return GestureDetector(
onTap: () => AutoRouter.of(context).push(
@@ -75,6 +81,24 @@ class CuratedPlacesRow extends CuratedRow {
);
}
// Return empty thumbnail
if (!isMapEnabled && content.isEmpty) {
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: imageSize,
height: imageSize,
child: ThumbnailWithInfo(
textInfo: '',
onTap: () {},
),
),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
@@ -82,11 +106,10 @@ class CuratedPlacesRow extends CuratedRow {
),
itemBuilder: (context, index) {
// Injecting Map thumbnail as the first element
if (index == 0) {
if (isMapEnabled && index == 0) {
return buildMapThumbnail();
}
// The actual index is 1 less than the virutal index since we inject map into the first position
final actualIndex = index - 1;
final actualIndex = index - actualContentIndex;
final object = content[actualIndex];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
@@ -103,8 +126,7 @@ class CuratedPlacesRow extends CuratedRow {
),
);
},
// Adding 1 to inject map thumbnail as first element
itemCount: content.length + 1,
itemCount: content.length + actualContentIndex,
);
}
}

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
// ignore: must_be_immutable
@@ -27,6 +28,8 @@ class SearchPage extends HookConsumerWidget {
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
final curatedLocation = ref.watch(getCuratedLocationProvider);
final curatedPeople = ref.watch(getCuratedPeopleProvider);
final isMapEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
double imageSize = math.min(MediaQuery.of(context).size.width / 3, 150);
@@ -107,6 +110,7 @@ class SearchPage extends HookConsumerWidget {
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (locations) => CuratedPlacesRow(
isMapEnabled: isMapEnabled,
content: locations
.map(
(o) => CuratedContent(

View File

@@ -48,6 +48,7 @@ enum AppSettingsEnum<T> {
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
mapThemeMode<bool>(StoreKey.mapThemeMode, null, false),
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
;

View File

@@ -0,0 +1,125 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/trash/services/trash.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
class TrashNotifier extends StateNotifier<bool> {
final Isar _db;
final Ref _ref;
final TrashService _trashService;
final _log = Logger('TrashNotifier');
TrashNotifier(
this._trashService,
this._db,
this._ref,
) : super(false);
Future<void> emptyTrash() async {
try {
final user = _ref.read(currentUserProvider);
if (user == null) {
return;
}
await _trashService.emptyTrash();
final dbIds = await _db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(user.isarId)
.isTrashedEqualTo(true)
.idProperty()
.findAll();
await _db.writeTxn(() async {
await _db.exifInfos.deleteAll(dbIds);
await _db.assets.deleteAll(dbIds);
});
} catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
}
}
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
try {
final result = await _trashService.restoreAssets(assetList);
if (result) {
final remoteAssets = assetList.where((a) => a.isRemote).toList();
final updatedAssets = remoteAssets.map((e) {
e.isTrashed = false;
return e;
}).toList();
await _db.writeTxn(() async {
await _db.assets.putAll(updatedAssets);
});
return true;
}
} catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
}
return false;
}
Future<void> restoreTrash() async {
try {
final user = _ref.read(currentUserProvider);
if (user == null) {
return;
}
await _trashService.restoreTrash();
final assets = await _db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(user.isarId)
.isTrashedEqualTo(true)
.findAll();
final updatedAssets = assets.map((e) {
e.isTrashed = false;
return e;
}).toList();
await _db.writeTxn(() async {
await _db.assets.putAll(updatedAssets);
});
} catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
}
}
}
final trashProvider = StateNotifierProvider<TrashNotifier, bool>((ref) {
return TrashNotifier(
ref.watch(trashServiceProvider),
ref.watch(dbProvider),
ref,
);
});
final trashedAssetsProvider = StreamProvider<RenderList>((ref) async* {
final user = ref.read(currentUserProvider);
if (user == null) return;
final query = ref
.watch(dbProvider)
.assets
.filter()
.ownerIdEqualTo(user.isarId)
.isTrashedEqualTo(true)
.sortByFileCreatedAt();
const groupBy = GroupAssetsBy.none;
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
});

View File

@@ -0,0 +1,48 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final trashServiceProvider = Provider<TrashService>((ref) {
return TrashService(
ref.watch(apiServiceProvider),
);
});
class TrashService {
final _log = Logger("TrashService");
final ApiService _apiService;
TrashService(this._apiService);
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
try {
List<String> remoteIds =
assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList();
await _apiService.assetApi.restoreAssets(BulkIdsDto(ids: remoteIds));
return true;
} catch (error, stack) {
_log.severe("Cannot restore assets ${error.toString()}", error, stack);
return false;
}
}
Future<void> emptyTrash() async {
try {
await _apiService.assetApi.emptyTrash();
} catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
}
}
Future<void> restoreTrash() async {
try {
await _apiService.assetApi.restoreTrash();
} catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
}
}
}

View File

@@ -0,0 +1,276 @@
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:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/trash/providers/trashed_asset.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class TrashPage extends HookConsumerWidget {
const TrashPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashedAssets = ref.watch(trashedAssetsProvider);
final trashDays =
ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final processing = useState(false);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
onEmptyTrash() async {
processing.value = true;
await ref.read(trashProvider.notifier).emptyTrash();
processing.value = false;
selectionEnabledHook.value = false;
if (context.mounted) {
ImmichToast.show(
context: context,
msg: 'Emptied trash',
gravity: ToastGravity.BOTTOM,
);
}
}
handleEmptyTrash() async {
await showDialog(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => onEmptyTrash(),
title: "trash_page_empty_trash_btn".tr(),
ok: "trash_page_empty_trash_dialog_ok".tr(),
content: "trash_page_empty_trash_dialog_content".tr(),
),
);
}
Future<void> onPermanentlyDelete() async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
await ref
.read(assetProvider.notifier)
.deleteAssets(selection.value, force: true);
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
if (context.mounted) {
ImmichToast.show(
context: context,
msg:
'${selection.value.length} $assetOrAssets deleted permanently',
gravity: ToastGravity.BOTTOM,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
handlePermanentDelete() async {
await showDialog(
context: context,
builder: (context) => DeleteDialog(
onDelete: () => onPermanentlyDelete(),
),
);
}
Future<void> handleRestoreAll() async {
processing.value = true;
await ref.read(trashProvider.notifier).restoreTrash();
processing.value = false;
selectionEnabledHook.value = false;
}
Future<void> handleRestore() async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
final result = await ref
.read(trashProvider.notifier)
.restoreAssets(selection.value);
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
if (result && context.mounted) {
ImmichToast.show(
context: context,
msg:
'${selection.value.length} $assetOrAssets restored successfully',
gravity: ToastGravity.BOTTOM,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
String getAppBarTitle(String count) {
if (selectionEnabledHook.value) {
return selection.value.isNotEmpty
? "${selection.value.length}"
: "trash_page_select_assets_btn".tr();
}
return 'trash_page_title'.tr(args: [count]);
}
AppBar buildAppBar(String count) {
return AppBar(
leading: IconButton(
onPressed: !selectionEnabledHook.value
? () => AutoRouter.of(context).pop()
: () {
selectionEnabledHook.value = false;
selection.value = {};
},
icon: !selectionEnabledHook.value
? const Icon(Icons.arrow_back_ios_rounded)
: const Icon(Icons.close_rounded),
),
centerTitle: !selectionEnabledHook.value,
automaticallyImplyLeading: false,
title: Text(getAppBarTitle(count)),
actions: <Widget>[
if (!selectionEnabledHook.value)
PopupMenuButton<void Function()>(
itemBuilder: (context) {
return [
PopupMenuItem(
value: () => selectionEnabledHook.value = true,
child: const Text('trash_page_select_btn').tr(),
),
PopupMenuItem(
value: handleEmptyTrash,
child: const Text('trash_page_empty_trash_btn').tr(),
),
];
},
onSelected: (fn) => fn(),
),
],
);
}
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: Theme.of(context).canvasColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
icon: Icon(
Icons.delete_forever,
color: Colors.red[400],
),
label: Text(
selection.value.isEmpty
? 'trash_page_delete_all'.tr()
: 'trash_page_delete'.tr(),
style: TextStyle(
fontSize: 14,
color: Colors.red[400],
fontWeight: FontWeight.bold,
),
),
onPressed: processing.value
? null
: selection.value.isEmpty
? handleEmptyTrash
: handlePermanentDelete,
),
TextButton.icon(
icon: const Icon(
Icons.history_rounded,
),
label: Text(
selection.value.isEmpty
? 'trash_page_restore_all'.tr()
: 'trash_page_restore'.tr(),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
onPressed: processing.value
? null
: selection.value.isEmpty
? handleRestoreAll
: handleRestore,
),
],
),
),
),
),
);
}
return trashedAssets.when(
loading: () => Scaffold(
appBar: buildAppBar("?"),
body: const Center(child: CircularProgressIndicator()),
),
error: (error, stackTrace) => Scaffold(
appBar: buildAppBar("!"),
body: Center(child: Text(error.toString())),
),
data: (data) => Scaffold(
appBar: buildAppBar(data.totalAssets.toString()),
body: data.isEmpty
? Center(
child: Text('trash_page_no_assets'.tr()),
)
: Stack(
children: [
SafeArea(
child: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
showMultiSelectIndicator: false,
topWidget: Padding(
padding: const EdgeInsets.only(
top: 24,
bottom: 24,
left: 12,
right: 12,
),
child: const Text(
"trash_page_info",
).tr(args: ["$trashDays"]),
),
),
),
if (selectionEnabledHook.value) buildBottomBar(),
if (processing.value)
const Center(child: ImmichLoadingIndicator()),
],
),
),
);
}
}

View File

@@ -28,6 +28,7 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
import 'package:immich_mobile/modules/trash/views/trash_page.dart';
import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
import 'package:immich_mobile/modules/search/views/all_people_page.dart';
import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
@@ -155,6 +156,7 @@ part 'router.gr.dart';
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
],
)
class AppRouter extends _$AppRouter {

View File

@@ -312,6 +312,12 @@ class _$AppRouter extends RootStackRouter {
),
);
},
TrashRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const TrashPage(),
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
@@ -624,6 +630,14 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
TrashRoute.name,
path: '/trash-page',
guards: [
authGuard,
duplicateGuard,
],
),
];
}
@@ -1394,6 +1408,18 @@ class AlbumOptionsRouteArgs {
}
}
/// generated route for
/// [TrashPage]
class TrashRoute extends PageRouteInfo<void> {
const TrashRoute()
: super(
TrashRoute.name,
path: '/trash-page',
);
static const String name = 'TrashRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@@ -64,10 +64,10 @@ class TabNavigationObserver extends AutoRouterObserver {
}
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
ref.read(serverInfoProvider.notifier).getServerVersion();
} catch (e) {
debugPrint("Error refreshing user info $e");
}
}
ref.watch(serverInfoProvider.notifier).getServerVersion();
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/painting.dart';
import 'original_image_provider.dart';
/// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small iamges
final class CustomImageCache implements ImageCache {
final _small = ImageCache();
final _large = ImageCache();
@override
int get maximumSize => _small.maximumSize + _large.maximumSize;
@override
int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes;
@override
set maximumSize(int value) => _small.maximumSize = value;
@override
set maximumSizeBytes(int value) => _small.maximumSize = value;
@override
void clear() {
_small.clear();
_large.clear();
}
@override
void clearLiveImages() {
_small.clearLiveImages();
_large.clearLiveImages();
}
@override
bool containsKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).containsKey(key);
@override
int get currentSize => _small.currentSize + _large.currentSize;
@override
int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes;
@override
bool evict(Object key, {bool includeLive = true}) =>
(key is OriginalImageProvider ? _large : _small)
.evict(key, includeLive: includeLive);
@override
int get liveImageCount => _small.liveImageCount + _large.liveImageCount;
@override
int get pendingImageCount =>
_small.pendingImageCount + _large.pendingImageCount;
@override
ImageStreamCompleter? putIfAbsent(
Object key,
ImageStreamCompleter Function() loader, {
ImageErrorListener? onError,
}) =>
(key is OriginalImageProvider ? _large : _small)
.putIfAbsent(key, loader, onError: onError);
@override
ImageCacheStatus statusForKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).statusForKey(key);
}

View File

@@ -0,0 +1,73 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/models/asset.dart';
/// Loads the original image for local assets
@immutable
final class OriginalImageProvider extends ImageProvider<OriginalImageProvider> {
final Asset asset;
const OriginalImageProvider(this.asset);
@override
Future<OriginalImageProvider> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<OriginalImageProvider>(this);
@override
ImageStreamCompleter loadImage(
OriginalImageProvider key,
ImageDecoderCallback decode,
) =>
MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription(asset.fileName);
},
);
Future<ui.Codec> _loadAsync(
OriginalImageProvider key,
ImageDecoderCallback decode,
) async {
final ui.ImmutableBuffer buffer;
if (asset.isImage) {
final File? file = await asset.local?.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
}
try {
buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
} catch (error) {
throw StateError("Loading asset ${asset.fileName} failed");
}
} else {
final thumbBytes = await asset.local?.thumbnailData;
if (thumbBytes == null) {
throw StateError("Loading thumb for video ${asset.fileName} failed");
}
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
}
try {
final codec = await decode(buffer);
debugPrint("Decoded image ${asset.fileName}");
return codec;
} catch (error) {
throw StateError("Decoding asset ${asset.fileName} failed");
}
}
@override
bool operator ==(Object other) {
if (other is! OriginalImageProvider) return false;
if (identical(this, other)) return true;
return asset == other.asset;
}
@override
int get hashCode => asset.hashCode;
}

View File

@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'custom_image_cache.dart';
final class ImmichWidgetsBinding extends WidgetsFlutterBinding {
@override
ImageCache createImageCache() => CustomImageCache();
}

View File

@@ -18,6 +18,7 @@ class Album {
required this.name,
required this.createdAt,
required this.modifiedAt,
this.lastModifiedAssetTimestamp,
required this.shared,
});
@@ -29,6 +30,7 @@ class Album {
String name;
DateTime createdAt;
DateTime modifiedAt;
DateTime? lastModifiedAssetTimestamp;
bool shared;
final IsarLink<User> owner = IsarLink<User>();
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
@@ -83,12 +85,21 @@ class Album {
@override
bool operator ==(other) {
if (other is! Album) return false;
final lastModifiedAssetTimestampIsSetAndEqual =
lastModifiedAssetTimestamp != null &&
other.lastModifiedAssetTimestamp != null
? lastModifiedAssetTimestamp!
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
: true;
return id == other.id &&
remoteId == other.remoteId &&
localId == other.localId &&
name == other.name &&
createdAt.isAtSameMomentAs(other.createdAt) &&
modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
lastModifiedAssetTimestampIsSetAndEqual &&
shared == other.shared &&
owner.value == other.owner.value &&
thumbnail.value == other.thumbnail.value &&
@@ -105,6 +116,7 @@ class Album {
name.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
lastModifiedAssetTimestamp.hashCode ^
shared.hashCode ^
owner.value.hashCode ^
thumbnail.value.hashCode ^
@@ -130,6 +142,7 @@ class Album {
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
);
a.owner.value = await db.users.getById(dto.ownerId);

View File

@@ -22,28 +22,33 @@ const AlbumSchema = CollectionSchema(
name: r'createdAt',
type: IsarType.dateTime,
),
r'localId': PropertySchema(
r'lastModifiedAssetTimestamp': PropertySchema(
id: 1,
name: r'lastModifiedAssetTimestamp',
type: IsarType.dateTime,
),
r'localId': PropertySchema(
id: 2,
name: r'localId',
type: IsarType.string,
),
r'modifiedAt': PropertySchema(
id: 2,
id: 3,
name: r'modifiedAt',
type: IsarType.dateTime,
),
r'name': PropertySchema(
id: 3,
id: 4,
name: r'name',
type: IsarType.string,
),
r'remoteId': PropertySchema(
id: 4,
id: 5,
name: r'remoteId',
type: IsarType.string,
),
r'shared': PropertySchema(
id: 5,
id: 6,
name: r'shared',
type: IsarType.bool,
)
@@ -143,11 +148,12 @@ void _albumSerialize(
Map<Type, List<int>> allOffsets,
) {
writer.writeDateTime(offsets[0], object.createdAt);
writer.writeString(offsets[1], object.localId);
writer.writeDateTime(offsets[2], object.modifiedAt);
writer.writeString(offsets[3], object.name);
writer.writeString(offsets[4], object.remoteId);
writer.writeBool(offsets[5], object.shared);
writer.writeDateTime(offsets[1], object.lastModifiedAssetTimestamp);
writer.writeString(offsets[2], object.localId);
writer.writeDateTime(offsets[3], object.modifiedAt);
writer.writeString(offsets[4], object.name);
writer.writeString(offsets[5], object.remoteId);
writer.writeBool(offsets[6], object.shared);
}
Album _albumDeserialize(
@@ -158,11 +164,12 @@ Album _albumDeserialize(
) {
final object = Album(
createdAt: reader.readDateTime(offsets[0]),
localId: reader.readStringOrNull(offsets[1]),
modifiedAt: reader.readDateTime(offsets[2]),
name: reader.readString(offsets[3]),
remoteId: reader.readStringOrNull(offsets[4]),
shared: reader.readBool(offsets[5]),
lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[1]),
localId: reader.readStringOrNull(offsets[2]),
modifiedAt: reader.readDateTime(offsets[3]),
name: reader.readString(offsets[4]),
remoteId: reader.readStringOrNull(offsets[5]),
shared: reader.readBool(offsets[6]),
);
object.id = id;
return object;
@@ -178,14 +185,16 @@ P _albumDeserializeProp<P>(
case 0:
return (reader.readDateTime(offset)) as P;
case 1:
return (reader.readStringOrNull(offset)) as P;
return (reader.readDateTimeOrNull(offset)) as P;
case 2:
return (reader.readDateTime(offset)) as P;
case 3:
return (reader.readString(offset)) as P;
case 4:
return (reader.readStringOrNull(offset)) as P;
case 3:
return (reader.readDateTime(offset)) as P;
case 4:
return (reader.readString(offset)) as P;
case 5:
return (reader.readStringOrNull(offset)) as P;
case 6:
return (reader.readBool(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -520,6 +529,80 @@ extension AlbumQueryFilter on QueryBuilder<Album, Album, QFilterCondition> {
});
}
QueryBuilder<Album, Album, QAfterFilterCondition>
lastModifiedAssetTimestampIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'lastModifiedAssetTimestamp',
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition>
lastModifiedAssetTimestampIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'lastModifiedAssetTimestamp',
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition>
lastModifiedAssetTimestampEqualTo(DateTime? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'lastModifiedAssetTimestamp',
value: value,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition>
lastModifiedAssetTimestampGreaterThan(
DateTime? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'lastModifiedAssetTimestamp',
value: value,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition>
lastModifiedAssetTimestampLessThan(
DateTime? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'lastModifiedAssetTimestamp',
value: value,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition>
lastModifiedAssetTimestampBetween(
DateTime? lower,
DateTime? upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'lastModifiedAssetTimestamp',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> localIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -1158,6 +1241,19 @@ extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
});
}
QueryBuilder<Album, Album, QAfterSortBy> sortByLastModifiedAssetTimestamp() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
});
}
QueryBuilder<Album, Album, QAfterSortBy>
sortByLastModifiedAssetTimestampDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> sortByLocalId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'localId', Sort.asc);
@@ -1244,6 +1340,19 @@ extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
});
}
QueryBuilder<Album, Album, QAfterSortBy> thenByLastModifiedAssetTimestamp() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
});
}
QueryBuilder<Album, Album, QAfterSortBy>
thenByLastModifiedAssetTimestampDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> thenByLocalId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'localId', Sort.asc);
@@ -1312,6 +1421,12 @@ extension AlbumQueryWhereDistinct on QueryBuilder<Album, Album, QDistinct> {
});
}
QueryBuilder<Album, Album, QDistinct> distinctByLastModifiedAssetTimestamp() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'lastModifiedAssetTimestamp');
});
}
QueryBuilder<Album, Album, QDistinct> distinctByLocalId(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -1359,6 +1474,13 @@ extension AlbumQueryProperty on QueryBuilder<Album, Album, QQueryProperty> {
});
}
QueryBuilder<Album, DateTime?, QQueryOperations>
lastModifiedAssetTimestampProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'lastModifiedAssetTimestamp');
});
}
QueryBuilder<Album, String?, QQueryOperations> localIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'localId');

View File

@@ -30,7 +30,8 @@ class Asset {
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite,
isArchived = remote.isArchived;
isArchived = remote.isArchived,
isTrashed = remote.isTrashed;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
@@ -45,6 +46,7 @@ class Asset {
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
isTrashed = false,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
@@ -74,6 +76,7 @@ class Asset {
this.exifInfo,
required this.isFavorite,
required this.isArchived,
required this.isTrashed,
});
@ignore
@@ -100,12 +103,6 @@ class Asset {
/// stores the raw SHA1 bytes as a base64 String
/// because Isar cannot sort lists of byte arrays
@Index(
unique: true,
replace: false,
type: IndexType.hash,
composite: [CompositeIndex("ownerId")],
)
String checksum;
@Index(unique: false, replace: false, type: IndexType.hash)
@@ -114,6 +111,11 @@ class Asset {
@Index(unique: false, replace: false, type: IndexType.hash)
String? localId;
@Index(
unique: true,
replace: false,
composite: [CompositeIndex("checksum", type: IndexType.hash)],
)
int ownerId;
DateTime fileCreatedAt;
@@ -139,6 +141,8 @@ class Asset {
bool isArchived;
bool isTrashed;
@ignore
ExifInfo? exifInfo;
@@ -178,6 +182,7 @@ class Asset {
@override
bool operator ==(other) {
if (other is! Asset) return false;
if (identical(this, other)) return true;
return id == other.id &&
checksum == other.checksum &&
remoteId == other.remoteId &&
@@ -194,7 +199,8 @@ class Asset {
livePhotoVideoId == other.livePhotoVideoId &&
isFavorite == other.isFavorite &&
isLocal == other.isLocal &&
isArchived == other.isArchived;
isArchived == other.isArchived &&
isTrashed == other.isTrashed;
}
@override
@@ -216,7 +222,8 @@ class Asset {
livePhotoVideoId.hashCode ^
isFavorite.hashCode ^
isLocal.hashCode ^
isArchived.hashCode;
isArchived.hashCode ^
isTrashed.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) {
@@ -229,8 +236,9 @@ class Asset {
width == null && a.width != null ||
height == null && a.height != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
!isRemote && a.isRemote && isFavorite != a.isFavorite ||
!isRemote && a.isRemote && isArchived != a.isArchived;
isFavorite != a.isFavorite ||
isArchived != a.isArchived ||
isTrashed != a.isTrashed;
}
/// Returns a new [Asset] with values from this and merged & updated with [a]
@@ -261,6 +269,7 @@ class Asset {
livePhotoVideoId: livePhotoVideoId,
isFavorite: isFavorite,
isArchived: isArchived,
isTrashed: isTrashed,
);
}
} else {
@@ -275,6 +284,7 @@ class Asset {
// isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite,
isArchived: a.isArchived,
isTrashed: a.isTrashed,
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
);
} else {
@@ -306,6 +316,7 @@ class Asset {
String? livePhotoVideoId,
bool? isFavorite,
bool? isArchived,
bool? isTrashed,
ExifInfo? exifInfo,
}) =>
Asset(
@@ -325,6 +336,7 @@ class Asset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
isFavorite: isFavorite ?? this.isFavorite,
isArchived: isArchived ?? this.isArchived,
isTrashed: isTrashed ?? this.isTrashed,
exifInfo: exifInfo ?? this.exifInfo,
);
@@ -378,7 +390,8 @@ class Asset {
"storage": "$storage",
"width": ${width ?? "N/A"},
"height": ${height ?? "N/A"},
"isArchived": $isArchived
"isArchived": $isArchived,
"isTrashed": $isTrashed,
}""";
}
}

View File

@@ -57,39 +57,44 @@ const AssetSchema = CollectionSchema(
name: r'isFavorite',
type: IsarType.bool,
),
r'livePhotoVideoId': PropertySchema(
r'isTrashed': PropertySchema(
id: 8,
name: r'isTrashed',
type: IsarType.bool,
),
r'livePhotoVideoId': PropertySchema(
id: 9,
name: r'livePhotoVideoId',
type: IsarType.string,
),
r'localId': PropertySchema(
id: 9,
id: 10,
name: r'localId',
type: IsarType.string,
),
r'ownerId': PropertySchema(
id: 10,
id: 11,
name: r'ownerId',
type: IsarType.long,
),
r'remoteId': PropertySchema(
id: 11,
id: 12,
name: r'remoteId',
type: IsarType.string,
),
r'type': PropertySchema(
id: 12,
id: 13,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 13,
id: 14,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 14,
id: 15,
name: r'width',
type: IsarType.int,
)
@@ -100,24 +105,6 @@ const AssetSchema = CollectionSchema(
deserializeProp: _assetDeserializeProp,
idName: r'id',
indexes: {
r'checksum_ownerId': IndexSchema(
id: 5611361749756160119,
name: r'checksum_ownerId',
unique: true,
replace: false,
properties: [
IndexPropertySchema(
name: r'checksum',
type: IndexType.hash,
caseSensitive: true,
),
IndexPropertySchema(
name: r'ownerId',
type: IndexType.value,
caseSensitive: false,
)
],
),
r'remoteId': IndexSchema(
id: 6301175856541681032,
name: r'remoteId',
@@ -143,6 +130,24 @@ const AssetSchema = CollectionSchema(
caseSensitive: true,
)
],
),
r'ownerId_checksum': IndexSchema(
id: -3295822444433175883,
name: r'ownerId_checksum',
unique: true,
replace: false,
properties: [
IndexPropertySchema(
name: r'ownerId',
type: IndexType.value,
caseSensitive: false,
),
IndexPropertySchema(
name: r'checksum',
type: IndexType.hash,
caseSensitive: true,
)
],
)
},
links: {},
@@ -196,13 +201,14 @@ void _assetSerialize(
writer.writeInt(offsets[5], object.height);
writer.writeBool(offsets[6], object.isArchived);
writer.writeBool(offsets[7], object.isFavorite);
writer.writeString(offsets[8], object.livePhotoVideoId);
writer.writeString(offsets[9], object.localId);
writer.writeLong(offsets[10], object.ownerId);
writer.writeString(offsets[11], object.remoteId);
writer.writeByte(offsets[12], object.type.index);
writer.writeDateTime(offsets[13], object.updatedAt);
writer.writeInt(offsets[14], object.width);
writer.writeBool(offsets[8], object.isTrashed);
writer.writeString(offsets[9], object.livePhotoVideoId);
writer.writeString(offsets[10], object.localId);
writer.writeLong(offsets[11], object.ownerId);
writer.writeString(offsets[12], object.remoteId);
writer.writeByte(offsets[13], object.type.index);
writer.writeDateTime(offsets[14], object.updatedAt);
writer.writeInt(offsets[15], object.width);
}
Asset _assetDeserialize(
@@ -221,14 +227,15 @@ Asset _assetDeserialize(
id: id,
isArchived: reader.readBool(offsets[6]),
isFavorite: reader.readBool(offsets[7]),
livePhotoVideoId: reader.readStringOrNull(offsets[8]),
localId: reader.readStringOrNull(offsets[9]),
ownerId: reader.readLong(offsets[10]),
remoteId: reader.readStringOrNull(offsets[11]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
isTrashed: reader.readBool(offsets[8]),
livePhotoVideoId: reader.readStringOrNull(offsets[9]),
localId: reader.readStringOrNull(offsets[10]),
ownerId: reader.readLong(offsets[11]),
remoteId: reader.readStringOrNull(offsets[12]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[13]),
width: reader.readIntOrNull(offsets[14]),
updatedAt: reader.readDateTime(offsets[14]),
width: reader.readIntOrNull(offsets[15]),
);
return object;
}
@@ -257,19 +264,21 @@ P _assetDeserializeProp<P>(
case 7:
return (reader.readBool(offset)) as P;
case 8:
return (reader.readStringOrNull(offset)) as P;
return (reader.readBool(offset)) as P;
case 9:
return (reader.readStringOrNull(offset)) as P;
case 10:
return (reader.readLong(offset)) as P;
case 11:
return (reader.readStringOrNull(offset)) as P;
case 11:
return (reader.readLong(offset)) as P;
case 12:
return (reader.readStringOrNull(offset)) as P;
case 13:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 13:
return (reader.readDateTime(offset)) as P;
case 14:
return (reader.readDateTime(offset)) as P;
case 15:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -302,89 +311,89 @@ void _assetAttach(IsarCollection<dynamic> col, Id id, Asset object) {
}
extension AssetByIndex on IsarCollection<Asset> {
Future<Asset?> getByChecksumOwnerId(String checksum, int ownerId) {
return getByIndex(r'checksum_ownerId', [checksum, ownerId]);
Future<Asset?> getByOwnerIdChecksum(int ownerId, String checksum) {
return getByIndex(r'ownerId_checksum', [ownerId, checksum]);
}
Asset? getByChecksumOwnerIdSync(String checksum, int ownerId) {
return getByIndexSync(r'checksum_ownerId', [checksum, ownerId]);
Asset? getByOwnerIdChecksumSync(int ownerId, String checksum) {
return getByIndexSync(r'ownerId_checksum', [ownerId, checksum]);
}
Future<bool> deleteByChecksumOwnerId(String checksum, int ownerId) {
return deleteByIndex(r'checksum_ownerId', [checksum, ownerId]);
Future<bool> deleteByOwnerIdChecksum(int ownerId, String checksum) {
return deleteByIndex(r'ownerId_checksum', [ownerId, checksum]);
}
bool deleteByChecksumOwnerIdSync(String checksum, int ownerId) {
return deleteByIndexSync(r'checksum_ownerId', [checksum, ownerId]);
bool deleteByOwnerIdChecksumSync(int ownerId, String checksum) {
return deleteByIndexSync(r'ownerId_checksum', [ownerId, checksum]);
}
Future<List<Asset?>> getAllByChecksumOwnerId(
List<String> checksumValues, List<int> ownerIdValues) {
final len = checksumValues.length;
assert(ownerIdValues.length == len,
Future<List<Asset?>> getAllByOwnerIdChecksum(
List<int> ownerIdValues, List<String> checksumValues) {
final len = ownerIdValues.length;
assert(checksumValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([checksumValues[i], ownerIdValues[i]]);
values.add([ownerIdValues[i], checksumValues[i]]);
}
return getAllByIndex(r'checksum_ownerId', values);
return getAllByIndex(r'ownerId_checksum', values);
}
List<Asset?> getAllByChecksumOwnerIdSync(
List<String> checksumValues, List<int> ownerIdValues) {
final len = checksumValues.length;
assert(ownerIdValues.length == len,
List<Asset?> getAllByOwnerIdChecksumSync(
List<int> ownerIdValues, List<String> checksumValues) {
final len = ownerIdValues.length;
assert(checksumValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([checksumValues[i], ownerIdValues[i]]);
values.add([ownerIdValues[i], checksumValues[i]]);
}
return getAllByIndexSync(r'checksum_ownerId', values);
return getAllByIndexSync(r'ownerId_checksum', values);
}
Future<int> deleteAllByChecksumOwnerId(
List<String> checksumValues, List<int> ownerIdValues) {
final len = checksumValues.length;
assert(ownerIdValues.length == len,
Future<int> deleteAllByOwnerIdChecksum(
List<int> ownerIdValues, List<String> checksumValues) {
final len = ownerIdValues.length;
assert(checksumValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([checksumValues[i], ownerIdValues[i]]);
values.add([ownerIdValues[i], checksumValues[i]]);
}
return deleteAllByIndex(r'checksum_ownerId', values);
return deleteAllByIndex(r'ownerId_checksum', values);
}
int deleteAllByChecksumOwnerIdSync(
List<String> checksumValues, List<int> ownerIdValues) {
final len = checksumValues.length;
assert(ownerIdValues.length == len,
int deleteAllByOwnerIdChecksumSync(
List<int> ownerIdValues, List<String> checksumValues) {
final len = ownerIdValues.length;
assert(checksumValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([checksumValues[i], ownerIdValues[i]]);
values.add([ownerIdValues[i], checksumValues[i]]);
}
return deleteAllByIndexSync(r'checksum_ownerId', values);
return deleteAllByIndexSync(r'ownerId_checksum', values);
}
Future<Id> putByChecksumOwnerId(Asset object) {
return putByIndex(r'checksum_ownerId', object);
Future<Id> putByOwnerIdChecksum(Asset object) {
return putByIndex(r'ownerId_checksum', object);
}
Id putByChecksumOwnerIdSync(Asset object, {bool saveLinks = true}) {
return putByIndexSync(r'checksum_ownerId', object, saveLinks: saveLinks);
Id putByOwnerIdChecksumSync(Asset object, {bool saveLinks = true}) {
return putByIndexSync(r'ownerId_checksum', object, saveLinks: saveLinks);
}
Future<List<Id>> putAllByChecksumOwnerId(List<Asset> objects) {
return putAllByIndex(r'checksum_ownerId', objects);
Future<List<Id>> putAllByOwnerIdChecksum(List<Asset> objects) {
return putAllByIndex(r'ownerId_checksum', objects);
}
List<Id> putAllByChecksumOwnerIdSync(List<Asset> objects,
List<Id> putAllByOwnerIdChecksumSync(List<Asset> objects,
{bool saveLinks = true}) {
return putAllByIndexSync(r'checksum_ownerId', objects,
return putAllByIndexSync(r'ownerId_checksum', objects,
saveLinks: saveLinks);
}
}
@@ -463,145 +472,6 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToAnyOwnerId(
String checksum) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'checksum_ownerId',
value: [checksum],
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumNotEqualToAnyOwnerId(
String checksum) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [],
upper: [checksum],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum],
includeLower: false,
upper: [],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum],
includeLower: false,
upper: [],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [],
upper: [checksum],
includeUpper: false,
));
}
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumOwnerIdEqualTo(
String checksum, int ownerId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'checksum_ownerId',
value: [checksum, ownerId],
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause>
checksumEqualToOwnerIdNotEqualTo(String checksum, int ownerId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum],
upper: [checksum, ownerId],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum, ownerId],
includeLower: false,
upper: [checksum],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum, ownerId],
includeLower: false,
upper: [checksum],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum],
upper: [checksum, ownerId],
includeUpper: false,
));
}
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause>
checksumEqualToOwnerIdGreaterThan(
String checksum,
int ownerId, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum, ownerId],
includeLower: include,
upper: [checksum],
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToOwnerIdLessThan(
String checksum,
int ownerId, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum],
upper: [checksum, ownerId],
includeUpper: include,
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToOwnerIdBetween(
String checksum,
int lowerOwnerId,
int upperOwnerId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'checksum_ownerId',
lower: [checksum, lowerOwnerId],
includeLower: includeLower,
upper: [checksum, upperOwnerId],
includeUpper: includeUpper,
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> remoteIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
@@ -731,6 +601,141 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
}
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdEqualToAnyChecksum(
int ownerId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'ownerId_checksum',
value: [ownerId],
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdNotEqualToAnyChecksum(
int ownerId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [],
upper: [ownerId],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [ownerId],
includeLower: false,
upper: [],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [ownerId],
includeLower: false,
upper: [],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [],
upper: [ownerId],
includeUpper: false,
));
}
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdGreaterThanAnyChecksum(
int ownerId, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [ownerId],
includeLower: include,
upper: [],
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdLessThanAnyChecksum(
int ownerId, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [],
upper: [ownerId],
includeUpper: include,
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdBetweenAnyChecksum(
int lowerOwnerId,
int upperOwnerId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [lowerOwnerId],
includeLower: includeLower,
upper: [upperOwnerId],
includeUpper: includeUpper,
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdChecksumEqualTo(
int ownerId, String checksum) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'ownerId_checksum',
value: [ownerId, checksum],
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause>
ownerIdEqualToChecksumNotEqualTo(int ownerId, String checksum) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [ownerId],
upper: [ownerId, checksum],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [ownerId, checksum],
includeLower: false,
upper: [ownerId],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [ownerId, checksum],
includeLower: false,
upper: [ownerId],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'ownerId_checksum',
lower: [ownerId],
upper: [ownerId, checksum],
includeUpper: false,
));
}
});
}
}
extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
@@ -1294,6 +1299,16 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> isTrashedEqualTo(
bool value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'isTrashed',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> livePhotoVideoIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -2062,6 +2077,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashed() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isTrashed', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashedDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isTrashed', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByLivePhotoVideoId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'livePhotoVideoId', Sort.asc);
@@ -2256,6 +2283,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashed() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isTrashed', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashedDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isTrashed', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByLivePhotoVideoId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'livePhotoVideoId', Sort.asc);
@@ -2392,6 +2431,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByIsTrashed() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'isTrashed');
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByLivePhotoVideoId(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -2494,6 +2539,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, bool, QQueryOperations> isTrashedProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isTrashed');
});
}
QueryBuilder<Asset, String?, QQueryOperations> livePhotoVideoIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'livePhotoVideoId');

View File

@@ -2,22 +2,30 @@ import 'package:openapi/api.dart';
class ServerInfoState {
final ServerVersionResponseDto serverVersion;
final ServerFeaturesDto serverFeatures;
final ServerConfigDto serverConfig;
final bool isVersionMismatch;
final String versionMismatchErrorMessage;
ServerInfoState({
required this.serverVersion,
required this.serverFeatures,
required this.serverConfig,
required this.isVersionMismatch,
required this.versionMismatchErrorMessage,
});
ServerInfoState copyWith({
ServerVersionResponseDto? serverVersion,
ServerFeaturesDto? serverFeatures,
ServerConfigDto? serverConfig,
bool? isVersionMismatch,
String? versionMismatchErrorMessage,
}) {
return ServerInfoState(
serverVersion: serverVersion ?? this.serverVersion,
serverFeatures: serverFeatures ?? this.serverFeatures,
serverConfig: serverConfig ?? this.serverConfig,
isVersionMismatch: isVersionMismatch ?? this.isVersionMismatch,
versionMismatchErrorMessage:
versionMismatchErrorMessage ?? this.versionMismatchErrorMessage,
@@ -26,7 +34,7 @@ class ServerInfoState {
@override
String toString() {
return 'ServerInfoState( serverVersion: $serverVersion, isVersionMismatch: $isVersionMismatch, versionMismatchErrorMessage: $versionMismatchErrorMessage)';
return 'ServerInfoState( serverVersion: $serverVersion, serverFeatures: $serverFeatures, serverConfig: $serverConfig, isVersionMismatch: $isVersionMismatch, versionMismatchErrorMessage: $versionMismatchErrorMessage)';
}
@override
@@ -35,6 +43,8 @@ class ServerInfoState {
return other is ServerInfoState &&
other.serverVersion == serverVersion &&
other.serverFeatures == serverFeatures &&
other.serverConfig == serverConfig &&
other.isVersionMismatch == isVersionMismatch &&
other.versionMismatchErrorMessage == versionMismatchErrorMessage;
}
@@ -42,6 +52,8 @@ class ServerInfoState {
@override
int get hashCode {
return serverVersion.hashCode ^
serverFeatures.hashCode ^
serverConfig.hashCode ^
isVersionMismatch.hashCode ^
versionMismatchErrorMessage.hashCode;
}

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