1
0
forked from Cutlery/immich

Compare commits

..

32 Commits

Author SHA1 Message Date
shenlong-tanwen 14978ece65 chore: pull main 2024-03-03 05:14:34 +05:30
renovate[bot] fd3503e77d chore(deps): update dependency @types/pg to v8.11.2 (#7585)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-02 18:08:41 -05:00
martin ebe7a14c14 fix(server): prevent leaking isFavorite and isArchived info (#7580)
* fix: prevent leaking favorites info

* add e2e test

* fix: e2e test

* fix: isArchived

* fix: keep old version
2024-03-02 18:01:24 -05:00
Sam Holton f03381a5b1 feat(server): allow oauth claim to set 0 for no quota (#7581)
* feat(server): allow oauth claim to set 0 for no quota

* PR feedback to remove extra objects from user.stub.ts
2024-03-02 14:18:56 -06:00
martin 8d44afe915 feat(web): ascending order for slideshow (#7502)
* feat: ascending order for slideshow

* feat: use dropdown

* rename

* fix: size

* pr feedback

* fix: hide text on small screen

* Wording

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-03-02 09:50:02 -06:00
martin db455060f0 fix(web): show history search box only when needed (#7544)
show history search box only when needed
2024-03-02 09:38:34 -06:00
Ignacy Kajdan b63b42d3d7 docs: fix the database name env variable (#7576) 2024-03-02 08:58:07 -05:00
Michel Heusschen a4e6c43823 perf(web): asset delete (#7555)
* perf(web): asset delete

* update asset delete on search page

* don't use arrow function in class
2024-03-01 19:49:31 -05:00
Sam Holton 7303fab9d9 feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim (#7548)
* feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim

* feat(server/web): fix format and use domain.util constants

* address some pr feedback

* simplify oauth storage quota logic

* adding tests and pr feedback

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-01 19:46:07 -05:00
Mert 8b02f18e99 fix(web): prevent duplicate calls to time bucket endpoint (#7563) 2024-03-01 14:16:07 -05:00
waclaw66 670a3838a3 fix(mobile): bottom bar Upload translation (#7553)
Co-authored-by: Václav Nováček <waclaw@waclaw.cz>
2024-03-01 11:24:55 -06:00
Simon Séhier 3e06062974 fix(immich-admin): only 1st argument was passed (#7552) 2024-03-01 07:34:59 -05:00
martin 3b772a772c fix(web): immich version (#7541)
* fix: web version

* update package-lock.json

* update typescript-sdk
2024-03-01 01:26:50 -06:00
Ben McCann 55ecfafa82 chore(web): fix eslint setup in VS Code (#7543) 2024-02-29 19:28:54 -05:00
Michel Heusschen c89d91e006 feat: filter people when using smart search (#7521) 2024-02-29 16:14:48 -05:00
Jason Rasmussen 15a4a4aaaa chore: remove unused upload property (#7535)
* chore: remove isExternal

* chore: open-api
2024-02-29 16:02:08 -05:00
Jason Rasmussen 3d25d91e77 refactor: library e2e (#7538)
* refactor: library e2e

* refactor: remove before each usages
2024-02-29 15:10:08 -05:00
Jonathan Jogenfors efa6efd200 feat(server,web): remove external path nonsense and make libraries admin-only (#7237)
* remove external path

* open-api

* make sql

* move library settings to admin panel

* Add documentation

* show external libraries only

* fix library list

* make user library settings look good

* fix test

* fix tests

* fix tests

* can pick user for library

* fix tests

* fix e2e

* chore: make sql

* Use unauth exception

* delete user library list

* cleanup

* fix e2e

* fix await lint

* chore: remove unused code

* chore: cleanup

* revert docs

* fix: is admin stuff

* table alignment

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-29 12:35:37 -06:00
Michel Heusschen 369acc7bea fix(web): asset disappears from album after metadata edit (#7520) 2024-02-29 11:44:30 -06:00
Jason Rasmussen 100363c7be refactor(e2e): use better dummy assets (#7536) 2024-02-29 12:07:01 -05:00
Jason Rasmussen af0de1a768 chore: linting (#7532)
* chore: linting

* fix: broken tests

* fix: formatting
2024-02-29 11:26:55 -05:00
Jason Rasmussen 09a7291527 refactor(web): drop axios (#7490)
* refactor: downloadApi

* refactor: assetApi

* chore: drop axios

* chore: tidy up

* chore: fix exports

* fix: show notification when download starts
2024-02-29 11:22:39 -05:00
Jason Rasmussen bb3d81bfc5 chore: build tweaks (#7484) 2024-02-29 09:22:25 -05:00
dependabot[bot] f1331905f0 chore(deps): bump tj-actions/verify-changed-files from 18 to 19 (#7524)
Bumps [tj-actions/verify-changed-files](https://github.com/tj-actions/verify-changed-files) from 18 to 19.
- [Release notes](https://github.com/tj-actions/verify-changed-files/releases)
- [Changelog](https://github.com/tj-actions/verify-changed-files/blob/main/HISTORY.md)
- [Commits](https://github.com/tj-actions/verify-changed-files/compare/v18...v19)

---
updated-dependencies:
- dependency-name: tj-actions/verify-changed-files
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-29 07:56:20 -05:00
renovate[bot] 7eb8e2ff9c chore(deps): update dependency @types/pg to v8.11.1 (#7522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-29 07:54:48 -05:00
shenlong 289194a356 refactor(mobile): bring back backup selected local assets to timeline (#7090)
* feat(mobile): select which local assets to display in timeline

* remove album selection chips

* refactor: move backup selection to device asset

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2024-02-24 21:49:58 -06:00
shenlong-tanwen 7f20d689ea fix: shared album route pass isarId 2024-02-14 09:24:06 +05:30
shenlong-tanwen f168f9e117 refactor: sync shared albums after device albums from homepage 2024-02-14 09:21:01 +05:30
Alex Tran 8bd90669c0 merge main 2024-02-13 20:31:14 -06:00
shenlong-tanwen 8f7e06bebd fix: use mutex for sync local albums 2024-02-10 22:26:09 +05:30
shenlong-tanwen eac150114f refactor(album): bring back backup_albums 2024-02-09 22:40:03 +05:30
shenlong-tanwen 296ae54335 refactor(mobile): split albums into remote / local 2024-02-04 17:35:42 +05:30
226 changed files with 10199 additions and 27270 deletions
+13 -12
View File
@@ -1,30 +1,31 @@
.vscode/
.github/
.git/
design/
docker/
docs/
e2e/
fastlane/
machine-learning/
misc/
mobile/
server/node_modules/
cli/coverage/
cli/dist/
cli/node_modules/
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
server/coverage/
server/.reverse-geocoding-dump/
server/node_modules/
server/upload/
server/dist/
server/www/
server/test/assets/
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
cli/node_modules/
cli/.reverse-geocoding-dump/
cli/upload/
cli/dist/
e2e/
open-api/typescript-sdk/node_modules/
open-api/typescript-sdk/build/
+1 -1
View File
@@ -16,4 +16,4 @@ max_line_length = off
trim_trailing_whitespace = false
[*.{yml,yaml}]
quote_type = double
quote_type = single
-2
View File
@@ -8,8 +8,6 @@ mobile/openapi/.openapi-generator/FILES linguist-generated=true
mobile/lib/**/*.g.dart -diff -merge
mobile/lib/**/*.g.dart linguist-generated=true
open-api/typescript-sdk/axios-client/**/* -diff -merge
open-api/typescript-sdk/axios-client/**/* linguist-generated=true
open-api/typescript-sdk/fetch-client.ts -diff -merge
open-api/typescript-sdk/fetch-client.ts linguist-generated=true
+24 -9
View File
@@ -35,7 +35,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
submodules: 'recursive'
- name: Run e2e tests
run: make server-e2e-jobs
@@ -184,7 +184,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
submodules: 'recursive'
- name: Setup Node
uses: actions/setup-node@v4
@@ -194,25 +194,40 @@ jobs:
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup cli
run: npm ci && npm run build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
if: ${{ !cancelled() }}
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: npx playwright install --with-deps chromium
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose build
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
run: npm run test
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
run: npx playwright test
if: ${{ !cancelled() }}
mobile-unit-tests:
name: Mobile
@@ -222,8 +237,8 @@ jobs:
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.16.9"
channel: 'stable'
flutter-version: '3.16.9'
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1
@@ -241,7 +256,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: 3.11
cache: "poetry"
cache: 'poetry'
- name: Install dependencies
run: |
poetry install --with dev --with cpu
@@ -279,7 +294,7 @@ jobs:
- name: Run API generation
run: make open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@v18
uses: tj-actions/verify-changed-files@v19
id: verify-changed-files
with:
files: |
@@ -334,7 +349,7 @@ jobs:
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@v18
uses: tj-actions/verify-changed-files@v19
id: verify-changed-files
with:
files: |
@@ -352,7 +367,7 @@ jobs:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@v18
uses: tj-actions/verify-changed-files@v19
id: verify-changed-sql-files
with:
files: |
-8
View File
@@ -54,14 +54,6 @@
"@oazapfts/runtime": "^1.0.0",
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"axios": "^1.6.7"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"../server": {
+4 -4
View File
@@ -2,7 +2,7 @@
# - https://immich.app/docs/developer/setup
# - https://immich.app/docs/developer/troubleshooting
version: "3.8"
version: '3.8'
name: immich-dev
@@ -30,7 +30,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
command: [ "/usr/src/app/bin/immich-dev", "immich" ]
command: ['/usr/src/app/bin/immich-dev', 'immich']
<<: *server-common
ports:
- 3001:3001
@@ -41,7 +41,7 @@ services:
immich-microservices:
container_name: immich_microservices
command: [ "/usr/src/app/bin/immich-dev", "microservices" ]
command: ['/usr/src/app/bin/immich-dev', 'microservices']
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
@@ -57,7 +57,7 @@ services:
image: immich-web-dev:latest
build:
context: ../web
command: [ "/usr/src/app/bin/immich-web" ]
command: ['/usr/src/app/bin/immich-web']
env_file:
- .env
ports:
+3 -3
View File
@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
name: immich-prod
@@ -17,7 +17,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
command: [ "start.sh", "immich" ]
command: ['start.sh', 'immich']
<<: *server-common
ports:
- 2283:3001
@@ -27,7 +27,7 @@ services:
immich-microservices:
container_name: immich_microservices
command: [ "start.sh", "microservices" ]
command: ['start.sh', 'microservices']
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
+3 -3
View File
@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
#
# WARNING: Make sure to use the docker-compose.yml of the current release:
@@ -14,7 +14,7 @@ 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
@@ -33,7 +33,7 @@ services:
# extends: # uncomment this section for hardware acceleration - see https://immich.app/docs/features/hardware-transcoding
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
command: [ "start.sh", "microservices" ]
command: ['start.sh', 'microservices']
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
+3 -1
View File
@@ -67,9 +67,11 @@ Once you have a new OAuth client application configured, Immich can be configure
| Client Secret | string | (required) | Required. Client Secret (previous step) |
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
| Storage Claim | string | preferred_username | Claim mapping for the user's storage label |
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |
+6 -3
View File
@@ -95,13 +95,16 @@ The default configuration looks like this:
"issuerUrl": "",
"clientId": "",
"clientSecret": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"scope": "openid email profile",
"signingAlgorithm": "RS256",
"storageLabelClaim": "preferred_username",
"storageQuotaClaim": "immich_quota",
"defaultStorageQuota": 0,
"buttonText": "Login with OAuth",
"autoRegister": true,
"autoLaunch": false
"autoLaunch": false,
"mobileOverrideEnabled": false,
"mobileRedirectUri": ""
},
"passwordLogin": {
"enabled": true
+1 -1
View File
@@ -67,7 +67,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `DB_PORT` | Database Port | `5432` | server, microservices |
| `DB_USERNAME` | Database User | `postgres` | server, microservices |
| `DB_PASSWORD` | Database Password | `postgres` | server, microservices |
| `DB_DATABASE` | Database Name | `immich` | server, microservices |
| `DB_DATABASE_NAME` | Database Name | `immich` | server, microservices |
| `DB_VECTOR_EXTENSION`<sup>\*1</sup> | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server, microservices |
\*1: This setting cannot be changed after the server has successfully started up
+31
View File
@@ -0,0 +1,31 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'],
root: true,
env: {
node: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@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',
'unicorn/prefer-module': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
},
};
+16
View File
@@ -0,0 +1,16 @@
.DS_Store
node_modules
/build
/package
.env
.env.*
!.env.example
*.md
*.json
coverage
dist
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true,
"organizeImportsSkipDestructiveCodeActions": true,
"plugins": ["prettier-plugin-organize-imports"]
}
+4 -3
View File
@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
name: immich-e2e
@@ -16,6 +16,7 @@ x-server-build: &server-common
- IMMICH_MACHINE_LEARNING_ENABLED=false
volumes:
- upload:/usr/src/app/upload
- ../server/test/assets:/data/assets
depends_on:
- redis
- database
@@ -23,14 +24,14 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich-e2e-server
command: [ "./start.sh", "immich" ]
command: ['./start.sh', 'immich']
<<: *server-common
ports:
- 2283:3001
immich-microservices:
container_name: immich-e2e-microservices
command: [ "./start.sh", "microservices" ]
command: ['./start.sh', 'microservices']
<<: *server-common
redis:
+2209 -12
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -7,7 +7,11 @@
"scripts": {
"test": "vitest --config vitest.config.ts",
"test:web": "npx playwright test",
"start:web": "npx playwright test --ui"
"start:web": "npx playwright test --ui",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix"
},
"keywords": [],
"author": "",
@@ -19,11 +23,21 @@
"@types/luxon": "^3.4.2",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.1",
"exiftool-vendored": "^24.5.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"socket.io-client": "^4.7.4",
"supertest": "^6.3.4",
"typescript": "^5.3.3",
+11 -37
View File
@@ -20,10 +20,7 @@ describe('/activity', () => {
let album: AlbumResponseDto;
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
create(
{ activityCreateDto: dto },
{ headers: asBearerAuth(accessToken || admin.accessToken) },
);
create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) });
beforeAll(async () => {
apiUtils.setup();
@@ -56,13 +53,9 @@ describe('/activity', () => {
});
it('should require an albumId', async () => {
const { status, body } = await request(app)
.get('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get('/activity').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid albumId', async () => {
@@ -71,9 +64,7 @@ describe('/activity', () => {
.query({ albumId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid assetId', async () => {
@@ -82,9 +73,7 @@ describe('/activity', () => {
.query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
});
it('should start off empty', async () => {
@@ -160,9 +149,7 @@ describe('/activity', () => {
});
it('should filter by userId', async () => {
const [reaction] = await Promise.all([
createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
const response1 = await request(app)
.get('/activity')
@@ -215,9 +202,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidDto.invalid });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should require a comment when type is comment', async () => {
@@ -226,12 +211,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidDto.notFound, type: 'comment', comment: null });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest([
'comment must be a string',
'comment should not be empty',
]),
);
expect(body).toEqual(errorDto.badRequest(['comment must be a string', 'comment should not be empty']));
});
it('should add a comment to an album', async () => {
@@ -271,9 +251,7 @@ describe('/activity', () => {
});
it('should return a 200 for a duplicate like on the album', async () => {
const [reaction] = await Promise.all([
createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
const { status, body } = await request(app)
.post('/activity')
@@ -356,9 +334,7 @@ describe('/activity', () => {
describe('DELETE /activity/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/activity/${uuidDto.notFound}`,
);
const { status, body } = await request(app).delete(`/activity/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -420,9 +396,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no activity.delete access'),
);
expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access'));
});
it('should let a non-owner remove their own comment', async () => {
+26 -38
View File
@@ -41,7 +41,7 @@ describe('/album', () => {
]);
[user1Asset1, user1Asset2] = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken, { isFavorite: true }),
apiUtils.createAsset(user1.accessToken),
]);
@@ -93,10 +93,7 @@ describe('/album', () => {
}),
]);
await deleteUser(
{ id: user3.userId },
{ headers: asBearerAuth(admin.accessToken) },
);
await deleteUser({ id: user3.userId }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /album', () => {
@@ -111,9 +108,7 @@ describe('/album', () => {
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(['shared must be a boolean value']),
);
expect(body).toEqual(errorDto.badRequest(['shared must be a boolean value']));
});
it('should reject an invalid assetId param', async () => {
@@ -124,6 +119,17 @@ describe('/album', () => {
expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
});
it("should not show other users' favorites", async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toEqual(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining({ isFavorite: false })],
});
});
it('should not return shared albums with a deleted owner', async () => {
const { status, body } = await request(app)
.get('/album?shared=true')
@@ -153,9 +159,7 @@ describe('/album', () => {
});
it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(app)
.get('/album')
.set('Authorization', `Bearer ${user1.accessToken}`);
const { status, body } = await request(app).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
@@ -250,9 +254,7 @@ describe('/album', () => {
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/album/${user1Albums[0].id}`,
);
const { status, body } = await request(app).get(`/album/${user1Albums[0].id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -265,7 +267,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
});
});
@@ -277,7 +279,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [expect.objectContaining(user2Albums[0].assets[0])],
assets: [expect.objectContaining({ id: user2Albums[0].assets[0].id })],
});
});
@@ -289,7 +291,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
});
});
@@ -326,9 +328,7 @@ describe('/album', () => {
describe('POST /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/album')
.send({ albumName: 'New album' });
const { status, body } = await request(app).post('/album').send({ albumName: 'New album' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -360,9 +360,7 @@ describe('/album', () => {
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/album/${user1Albums[0].id}/assets`,
);
const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/assets`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -375,9 +373,7 @@ describe('/album', () => {
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
it('should be able to add own asset to shared album', async () => {
@@ -388,9 +384,7 @@ describe('/album', () => {
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
});
@@ -473,9 +467,7 @@ describe('/album', () => {
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
});
it('should be able to remove own asset from shared album', async () => {
@@ -485,9 +477,7 @@ describe('/album', () => {
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
});
});
@@ -501,9 +491,7 @@ describe('/album', () => {
});
it('should require authentication', async () => {
const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] });
const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/users`).send({ sharedUserIds: [] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
+50 -109
View File
@@ -13,21 +13,15 @@ import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import {
apiUtils,
app,
dbUtils,
tempDir,
testAssetDir,
wsUtils,
} from 'src/utils';
import { apiUtils, app, dbUtils, tempDir, testAssetDir, wsUtils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const sha1 = (bytes: Buffer) =>
createHash('sha1').update(bytes).digest('base64');
const sha1 = (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64');
const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename);
@@ -65,50 +59,45 @@ describe('/asset', () => {
]);
// asset location
assetLocation = await apiUtils.createAsset(
admin.accessToken,
{},
{
assetLocation = await apiUtils.createAsset(admin.accessToken, {
assetData: {
filename: 'thompson-springs.jpg',
bytes: await readFile(locationAssetFilepath),
},
);
});
await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id });
user1Assets = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(
user1.accessToken,
{
isFavorite: true,
isExternal: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
},
{ filename: 'example.mp4' },
),
apiUtils.createAsset(user1.accessToken, {
isFavorite: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
]);
user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
for (const asset of [...user1Assets, ...user2Assets]) {
expect(asset.duplicate).toBe(false);
}
await Promise.all([
// stats
apiUtils.createAsset(userStats.accessToken),
apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
apiUtils.createAsset(userStats.accessToken, { isArchived: true }),
apiUtils.createAsset(
userStats.accessToken,
{
isArchived: true,
isFavorite: true,
},
{ filename: 'example.mp4' },
),
apiUtils.createAsset(userStats.accessToken, {
isArchived: true,
isFavorite: true,
assetData: { filename: 'example.mp4' },
}),
]);
const person1 = await apiUtils.createPerson(user1.accessToken, {
@@ -126,9 +115,7 @@ describe('/asset', () => {
describe('GET /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/${uuidDto.notFound}`,
);
const { status, body } = await request(app).get(`/asset/${uuidDto.notFound}`);
expect(body).toEqual(errorDto.unauthorized);
expect(status).toBe(401);
});
@@ -163,9 +150,7 @@ describe('/asset', () => {
assetIds: [user1Assets[0].id],
});
const { status, body } = await request(app).get(
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
const { status, body } = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id });
});
@@ -195,9 +180,7 @@ describe('/asset', () => {
assetIds: [user1Assets[0].id],
});
const data = await request(app).get(
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
expect(data.status).toBe(200);
expect(data.body).toMatchObject({ people: [] });
});
@@ -280,7 +263,7 @@ describe('/asset', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it.each(Array(10))('should return 1 random assets', async () => {
it.each(TEN_TIMES)('should return 1 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -290,14 +273,9 @@ describe('/asset', () => {
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
// assets owned by user1
expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id);
// assets owned by user2
expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id);
});
it.each(Array(10))('should return 2 random assets', async () => {
it.each(TEN_TIMES)('should return 2 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -309,24 +287,18 @@ describe('/asset', () => {
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([user1Assets.map(({ id }) => id)]).toContain(asset.id);
// assets owned by user2
expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id);
}
});
it.each(Array(10))(
it.each(TEN_TIMES)(
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
async () => {
const { status, body } = await request(app)
.get('/[]asset/random')
.get('/asset/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user2Assets[0].id }),
]);
expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
},
);
@@ -341,9 +313,7 @@ describe('/asset', () => {
describe('PUT /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/asset/:${uuidDto.notFound}`,
);
const { status, body } = await request(app).put(`/asset/:${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -365,10 +335,7 @@ describe('/asset', () => {
});
it('should favorite an asset', async () => {
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
expect(before.isFavorite).toBe(false);
const { status, body } = await request(app)
@@ -380,10 +347,7 @@ describe('/asset', () => {
});
it('should archive an asset', async () => {
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
expect(before.isArchived).toBe(false);
const { status, body } = await request(app)
@@ -497,9 +461,7 @@ describe('/asset', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['each value in ids must be a UUID']),
);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should throw an error when the id is not found', async () => {
@@ -509,9 +471,7 @@ describe('/asset', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no asset.delete access'),
);
expect(body).toEqual(errorDto.badRequest('Not found or no asset.delete access'));
});
it('should move an asset to the trash', async () => {
@@ -647,11 +607,9 @@ describe('/asset', () => {
for (const { input, expected } of tests) {
it(`should generate a thumbnail for ${input}`, async () => {
const filepath = join(testAssetDir, input);
const { id, duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{ bytes: await readFile(filepath), filename: basename(filepath) },
);
const { id, duplicate } = await apiUtils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
});
expect(duplicate).toBe(false);
@@ -667,14 +625,12 @@ describe('/asset', () => {
it('should handle a duplicate', async () => {
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
const { duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{
const { duplicate } = await apiUtils.createAsset(admin.accessToken, {
assetData: {
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
});
expect(duplicate).toBe(true);
});
@@ -701,29 +657,21 @@ describe('/asset', () => {
for (const { filepath, checksum } of motionTests) {
it(`should extract motionphoto video from ${filepath}`, async () => {
const response = await apiUtils.createAsset(
admin.accessToken,
{},
{
const response = await apiUtils.createAsset(admin.accessToken, {
assetData: {
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
});
await wsUtils.waitForEvent({ event: 'upload', assetId: response.id });
expect(response.duplicate).toBe(false);
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
response.id,
);
const asset = await apiUtils.getAssetInfo(admin.accessToken, response.id);
expect(asset.livePhotoVideoId).toBeDefined();
const video = await apiUtils.getAssetInfo(
admin.accessToken,
asset.livePhotoVideoId as string,
);
const video = await apiUtils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string);
expect(video.checksum).toStrictEqual(checksum);
});
}
@@ -731,9 +679,7 @@ describe('/asset', () => {
describe('GET /asset/thumbnail/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -775,9 +721,7 @@ describe('/asset', () => {
describe('GET /asset/file/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -792,10 +736,7 @@ describe('/asset', () => {
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
assetLocation.id,
);
const asset = await apiUtils.getAssetInfo(admin.accessToken, assetLocation.id);
const original = await readFile(locationAssetFilepath);
const originalChecksum = sha1(original);
+3 -11
View File
@@ -1,9 +1,4 @@
import {
deleteAssets,
getAuditFiles,
updateAsset,
type LoginResponseDto,
} from '@immich/sdk';
import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk';
import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
@@ -20,17 +15,14 @@ describe('/audit', () => {
describe('GET :/file-report', () => {
it('excludes assets without issues from report', async () => {
const [trashedAsset, archivedAsset, _] = await Promise.all([
const [trashedAsset, archivedAsset] = await Promise.all([
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
]);
await Promise.all([
deleteAssets(
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
{ headers: asBearerAuth(admin.accessToken) },
),
deleteAssets({ assetBulkDeleteDto: { ids: [trashedAsset.id] } }, { headers: asBearerAuth(admin.accessToken) }),
updateAsset(
{
id: archivedAsset.id,
+15 -51
View File
@@ -1,16 +1,6 @@
import {
LoginResponseDto,
getAuthDevices,
login,
signUpAdmin,
} from '@immich/sdk';
import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import {
deviceDto,
errorDto,
loginResponseDto,
signupResponseDto,
} from 'src/responses';
import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -48,18 +38,14 @@ describe(`/auth/admin-sign-up`, () => {
for (const { should, data } of invalid) {
it(`should ${should}`, async () => {
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send(data);
const { status, body } = await request(app).post('/auth/admin-sign-up').send(data);
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it(`should sign up the admin`, async () => {
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send(signupDto.admin);
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(201);
expect(body).toEqual(signupResponseDto.admin);
});
@@ -86,9 +72,7 @@ describe(`/auth/admin-sign-up`, () => {
it('should not allow a second admin to sign up', async () => {
await signUpAdmin({ signUpDto: signupDto.admin });
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send(signupDto.admin);
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
@@ -107,9 +91,7 @@ describe('/auth/*', () => {
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(app)
.post('/auth/login')
.send({ email, password: 'incorrect' });
const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.incorrectLogin);
});
@@ -125,9 +107,7 @@ describe('/auth/*', () => {
}
it('should accept a correct password', async () => {
const { status, body, headers } = await request(app)
.post('/auth/login')
.send({ email, password });
const { status, body, headers } = await request(app).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseDto.admin);
@@ -136,15 +116,9 @@ describe('/auth/*', () => {
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0]).toEqual(
`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`
);
expect(cookies[1]).toEqual(
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'
);
expect(cookies[2]).toEqual(
'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'
);
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;');
});
});
@@ -176,18 +150,12 @@ describe('/auth/*', () => {
await login({ loginCredentialDto: loginDto.admin });
}
await expect(
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
).resolves.toHaveLength(6);
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6);
const { status } = await request(app)
.delete(`/auth/devices`)
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status } = await request(app).delete(`/auth/devices`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await expect(
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
).resolves.toHaveLength(1);
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1);
});
it('should throw an error for a non-existent device id', async () => {
@@ -195,9 +163,7 @@ describe('/auth/*', () => {
.delete(`/auth/devices/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no authDevice.delete access')
);
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
@@ -219,9 +185,7 @@ describe('/auth/*', () => {
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.set('Authorization', 'Bearer 123');
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidToken);
});
+2 -4
View File
@@ -42,9 +42,7 @@ describe('/download', () => {
describe('POST /download/asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(
`/download/asset/${asset1.id}`,
);
const { status, body } = await request(app).post(`/download/asset/${asset1.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -56,7 +54,7 @@ describe('/download', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toEqual('image/jpeg');
expect(response.headers['content-type']).toEqual('image/png');
});
});
});
+456
View File
@@ -0,0 +1,456 @@
import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk';
import { userDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils, testAssetDirInternal } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/library', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let library: LibraryResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup();
user = await apiUtils.userSetup(admin.accessToken, userDto.user1);
library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
});
describe('GET /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/library');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should start with a default upload library', async () => {
const { status, body } = await request(app).get('/library').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.Upload,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]),
);
});
});
describe('POST /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/library').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require admin authentication', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ type: LibraryType.External });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should create an external library with defaults', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.External });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.External,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an external library with options', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
}),
);
});
it('should not create an external library with duplicate import paths', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path', '/path'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
});
it('should not create an external library with duplicate exclusion patterns', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**', '**/Raw/**'],
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
});
it('should create an upload library with defaults', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.Upload,
name: 'New Upload Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an upload library with options', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
}),
);
});
it('should not allow upload libraries to have import paths', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
});
it('should not allow upload libraries to have exclusion patterns', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
});
});
describe('PUT /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/library/${uuidDto.notFound}`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should change the library name', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'New Library Name' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
name: 'New Library Name',
}),
);
});
it('should not set an empty name', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
});
it('should change the import paths', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [testAssetDirInternal] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
importPaths: [testAssetDirInternal],
}),
);
});
it('should reject an empty import path', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
});
it('should reject duplicate import paths', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
});
it('should change the exclusion pattern', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
exclusionPatterns: ['**/Raw/**'],
}),
);
});
it('should reject duplicate exclusion patterns', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
});
it('should reject an empty exclusion pattern', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
});
});
describe('GET /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require admin access', async () => {
const { status, body } = await request(app)
.get(`/library/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should get library by id', async () => {
const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
const { status, body } = await request(app)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.External,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
});
describe('DELETE /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/library/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not delete the last upload library', async () => {
const libraries = await getAllLibraries(
{ $type: LibraryType.Upload },
{ headers: asBearerAuth(admin.accessToken) },
);
const adminLibraries = libraries.filter((library) => library.ownerId === admin.userId);
expect(adminLibraries.length).toBeGreaterThanOrEqual(1);
const lastLibrary = adminLibraries.pop() as LibraryResponseDto;
// delete all but the last upload library
for (const library of adminLibraries) {
const { status } = await request(app)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
}
const { status, body } = await request(app)
.delete(`/library/${lastLibrary.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(errorDto.noDeleteUploadLibrary);
expect(status).toBe(400);
});
it('should delete an external library', async () => {
const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
const { status, body } = await request(app)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
expect(body).toEqual({});
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: library.id,
}),
]),
);
});
});
describe('GET /library/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /library/:id/scan', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/scan`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /library/:id/removeOffline', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/removeOffline`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /library/:id/validate', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/validate`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should pass with no import paths', async () => {
const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { importPaths: [] });
expect(response.importPaths).toEqual([]);
});
it('should fail if path does not exist', async () => {
const pathToTest = `${testAssetDirInternal}/does/not/exist`;
const response = await apiUtils.validateLibrary(admin.accessToken, library.id, {
importPaths: [pathToTest],
});
expect(response.importPaths?.length).toEqual(1);
const pathResponse = response?.importPaths?.at(0);
expect(pathResponse).toEqual({
importPath: pathToTest,
isValid: false,
message: `Path does not exist (ENOENT)`,
});
});
it('should fail if path is a file', async () => {
const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`;
const response = await apiUtils.validateLibrary(admin.accessToken, library.id, {
importPaths: [pathToTest],
});
expect(response.importPaths?.length).toEqual(1);
const pathResponse = response?.importPaths?.at(0);
expect(pathResponse).toEqual({
importPath: pathToTest,
isValid: false,
message: `Not a directory`,
});
});
});
});
+2 -9
View File
@@ -15,16 +15,9 @@ describe(`/oauth`, () => {
describe('POST /oauth/authorize', () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app)
.post('/oauth/authorize')
.send({});
const { status, body } = await request(app).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'redirectUri must be a string',
'redirectUri should not be empty',
])
);
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
});
});
});
+8 -26
View File
@@ -24,14 +24,8 @@ describe('/partner', () => {
]);
await Promise.all([
createPartner(
{ id: user2.userId },
{ headers: asBearerAuth(user1.accessToken) }
),
createPartner(
{ id: user1.userId },
{ headers: asBearerAuth(user2.accessToken) }
),
createPartner({ id: user2.userId }, { headers: asBearerAuth(user1.accessToken) }),
createPartner({ id: user1.userId }, { headers: asBearerAuth(user2.accessToken) }),
]);
});
@@ -66,9 +60,7 @@ describe('/partner', () => {
describe('POST /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(
`/partner/${user3.userId}`
);
const { status, body } = await request(app).post(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -89,17 +81,13 @@ describe('/partner', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Partner already exists' })
);
expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
});
});
describe('PUT /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/partner/${user2.userId}`
);
const { status, body } = await request(app).put(`/partner/${user2.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -112,17 +100,13 @@ describe('/partner', () => {
.send({ inTimeline: false });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({ id: user2.userId, inTimeline: false })
);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
});
});
describe('DELETE /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/partner/${user3.userId}`
);
const { status, body } = await request(app).delete(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -142,9 +126,7 @@ describe('/partner', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Partner not found' })
);
expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
});
});
});
+4 -10
View File
@@ -65,9 +65,7 @@ describe('/activity', () => {
});
it('should return only visible people', async () => {
const { status, body } = await request(app)
.get('/person')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get('/person').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
@@ -80,9 +78,7 @@ describe('/activity', () => {
describe('GET /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/person/${uuidDto.notFound}`
);
const { status, body } = await request(app).get(`/person/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -109,9 +105,7 @@ describe('/activity', () => {
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/person/${uuidDto.notFound}`
);
const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -139,7 +133,7 @@ describe('/activity', () => {
birthDate: '123567',
response: 'Not found or no person.write access',
},
{ birthDate: 123567, response: 'Not found or no person.write access' },
{ birthDate: 123_567, response: 'Not found or no person.write access' },
]) {
const { status, body } = await request(app)
.put(`/person/${uuidDto.notFound}`)
+2 -6
View File
@@ -97,9 +97,7 @@ describe('/server-info', () => {
describe('GET /server-info/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
'/server-info/statistics'
);
const { status, body } = await request(app).get('/server-info/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -145,9 +143,7 @@ describe('/server-info', () => {
describe('GET /server-info/media-types', () => {
it('should return accepted media types', async () => {
const { status, body } = await request(app).get(
'/server-info/media-types'
);
const { status, body } = await request(app).get('/server-info/media-types');
expect(status).toBe(200);
expect(body).toEqual({
sidecar: ['.xmp'],
+44 -81
View File
@@ -46,14 +46,8 @@ describe('/shared-link', () => {
]);
[album, deletedAlbum, metadataAlbum] = await Promise.all([
createAlbum(
{ createAlbumDto: { albumName: 'album' } },
{ headers: asBearerAuth(user1.accessToken) },
),
createAlbum(
{ createAlbumDto: { albumName: 'deleted album' } },
{ headers: asBearerAuth(user2.accessToken) },
),
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
createAlbum(
{
createAlbumDto: {
@@ -65,47 +59,38 @@ describe('/shared-link', () => {
),
]);
[
linkWithDeletedAlbum,
linkWithAlbum,
linkWithAssets,
linkWithPassword,
linkWithMetadata,
linkWithoutMetadata,
] = await Promise.all([
apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.Album,
albumId: deletedAlbum.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset1.id],
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
password: 'foo',
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: true,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
await Promise.all([
apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.Album,
albumId: deletedAlbum.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset1.id],
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
password: 'foo',
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: true,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
await deleteUser(
{ id: user2.userId },
{ headers: asBearerAuth(admin.accessToken) },
);
await deleteUser({ id: user2.userId }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /shared-link', () => {
@@ -146,17 +131,13 @@ describe('/shared-link', () => {
describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => {
const { status } = await request(app)
.get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status } = await request(app).get('/shared-link/me').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(403);
});
it('should get data for correct shared link', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithAlbum.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithAlbum.key });
expect(status).toBe(200);
expect(body).toEqual(
@@ -178,18 +159,14 @@ describe('/shared-link', () => {
});
it('should return unauthorized if target has been soft deleted', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithDeletedAlbum.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key });
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidShareKey);
});
it('should return unauthorized for password protected link', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithPassword.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithPassword.key });
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidSharePassword);
@@ -211,9 +188,7 @@ describe('/shared-link', () => {
});
it('should return metadata for album shared link', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithMetadata.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
@@ -229,9 +204,7 @@ describe('/shared-link', () => {
});
it('should not return metadata for album shared link without metadata', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithoutMetadata.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithoutMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
@@ -247,9 +220,7 @@ describe('/shared-link', () => {
describe('GET /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/shared-link/${linkWithAlbum.id}`,
);
const { status, body } = await request(app).get(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -276,9 +247,7 @@ describe('/shared-link', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Shared link not found' }),
);
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
});
});
@@ -308,9 +277,7 @@ describe('/shared-link', () => {
.send({ type: SharedLinkType.Album });
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Invalid albumId' }),
);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
});
it('should require a valid asset id', async () => {
@@ -320,9 +287,7 @@ describe('/shared-link', () => {
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Invalid assetIds' }),
);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
});
it('should create a shared link', async () => {
@@ -424,9 +389,7 @@ describe('/shared-link', () => {
describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/shared-link/${linkWithAlbum.id}`,
);
const { status, body } = await request(app).delete(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
+2 -8
View File
@@ -18,9 +18,7 @@ describe('/system-config', () => {
describe('GET /system-config/map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
'/system-config/map/style.json'
);
const { status, body } = await request(app).get('/system-config/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -32,11 +30,7 @@ describe('/system-config', () => {
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'theme must be one of the following values: light, dark',
])
);
expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark']));
}
});
+4 -14
View File
@@ -32,24 +32,16 @@ describe('/trash', () => {
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
const before = await getAllAssets(
{},
{ headers: asBearerAuth(admin.accessToken) },
);
const before = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
expect(before.length).toBeGreaterThanOrEqual(1);
const { status } = await request(app)
.post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await wsUtils.waitForEvent({ event: 'delete', assetId });
const after = await getAllAssets(
{},
{ headers: asBearerAuth(admin.accessToken) },
);
const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
expect(after.length).toBe(0);
});
});
@@ -69,9 +61,7 @@ describe('/trash', () => {
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(true);
const { status } = await request(app)
.post('/trash/restore')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+11 -31
View File
@@ -22,10 +22,7 @@ describe('/server-info', () => {
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
await deleteUser(
{ id: deletedUser.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /user', () => {
@@ -36,9 +33,7 @@ describe('/server-info', () => {
});
it('should get users', async () => {
const { status, body } = await request(app)
.get('/user')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get('/user').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(4);
expect(body).toEqual(
@@ -47,7 +42,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
]),
);
});
@@ -63,7 +58,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
]),
);
});
@@ -81,7 +76,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
]),
);
});
});
@@ -112,9 +107,7 @@ describe('/server-info', () => {
});
it('should get my info', async () => {
const { status, body } = await request(app)
.get(`/user/me`)
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get(`/user/me`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
@@ -125,9 +118,7 @@ describe('/server-info', () => {
describe('POST /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post(`/user`)
.send(createUserDto.user1);
const { status, body } = await request(app).post(`/user`).send(createUserDto.user1);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -181,9 +172,7 @@ describe('/server-info', () => {
describe('DELETE /user/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/user/${userToDelete.userId}`
);
const { status, body } = await request(app).delete(`/user/${userToDelete.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -241,10 +230,7 @@ describe('/server-info', () => {
});
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/user`)
@@ -261,10 +247,7 @@ describe('/server-info', () => {
});
it('should update first and last name', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/user`)
@@ -284,10 +267,7 @@ describe('/server-info', () => {
});
it('should update memories enabled', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/user`)
.send({
+4 -14
View File
@@ -1,6 +1,6 @@
import { stat } from 'node:fs/promises';
import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
import { beforeEach, beforeAll, describe, expect, it } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich login-key`, () => {
beforeAll(() => {
@@ -24,25 +24,15 @@ describe(`immich login-key`, () => {
});
it('should require a valid key', async () => {
const { stderr, exitCode } = await immichCli([
'login-key',
app,
'immich-is-so-cool',
]);
expect(stderr).toContain(
'Failed to connect to server http://127.0.0.1:2283/api: Error: 401'
);
const { stderr, exitCode } = await immichCli(['login-key', app, 'immich-is-so-cool']);
expect(stderr).toContain('Failed to connect to server http://127.0.0.1:2283/api: Error: 401');
expect(exitCode).toBe(1);
});
it('should login', async () => {
const admin = await apiUtils.adminSetup();
const key = await apiUtils.createApiKey(admin.accessToken);
const { stdout, stderr, exitCode } = await immichCli([
'login-key',
app,
`${key.secret}`,
]);
const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in...',
'Logged in as admin@immich.cloud',
+10 -43
View File
@@ -1,13 +1,6 @@
import { getAllAlbums, getAllAssets } from '@immich/sdk';
import { mkdir, readdir, rm, symlink } from 'fs/promises';
import {
apiUtils,
asKeyAuth,
cliUtils,
dbUtils,
immichCli,
testAssetDir,
} from 'src/utils';
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
import { apiUtils, asKeyAuth, cliUtils, dbUtils, immichCli, testAssetDir } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich upload`, () => {
@@ -25,16 +18,10 @@ describe(`immich upload`, () => {
describe('immich upload --recursive', () => {
it('should upload a folder recursively', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
]),
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
);
expect(exitCode).toBe(0);
@@ -70,15 +57,9 @@ describe(`immich upload`, () => {
});
it('should add existing assets to albums', async () => {
const response1 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
const response1 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
expect(response1.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
]),
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
);
expect(response1.stderr).toBe('');
expect(response1.exitCode).toBe(0);
@@ -89,17 +70,10 @@ describe(`immich upload`, () => {
const albums1 = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums1.length).toBe(0);
const response2 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album',
]);
const response2 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive', '--album']);
expect(response2.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining(
'All assets were already uploaded, nothing to do.',
),
expect.stringContaining('All assets were already uploaded, nothing to do.'),
expect.stringContaining('Successfully updated 9 assets'),
]),
);
@@ -147,17 +121,10 @@ describe(`immich upload`, () => {
await mkdir(`/tmp/albums/nature`, { recursive: true });
const filesToLink = await readdir(`${testAssetDir}/albums/nature`);
for (const file of filesToLink) {
await symlink(
`${testAssetDir}/albums/nature/${file}`,
`/tmp/albums/nature/${file}`,
);
await symlink(`${testAssetDir}/albums/nature/${file}`, `/tmp/albums/nature/${file}`);
}
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`/tmp/albums/nature`,
'--delete',
]);
const { stderr, stdout, exitCode } = await immichCli(['upload', `/tmp/albums/nature`, '--delete']);
const files = await readdir(`/tmp/albums/nature`);
await rm(`/tmp/albums/nature`, { recursive: true });
+31
View File
@@ -0,0 +1,31 @@
import { PNG } from 'pngjs';
const createPNG = (r: number, g: number, b: number) => {
const image = new PNG({ width: 1, height: 1 });
image.data[0] = r;
image.data[1] = g;
image.data[2] = b;
image.data[3] = 255;
return PNG.sync.write(image);
};
function* newPngFactory() {
for (let r = 0; r < 255; r++) {
for (let g = 0; g < 255; g++) {
for (let b = 0; b < 255; b++) {
yield createPNG(r, g, b);
}
}
}
}
const pngFactory = newPngFactory();
export const makeRandomImage = () => {
const { value } = pngFactory.next();
if (!value) {
throw new Error('Ran out of random asset data');
}
return value;
};
+2 -4
View File
@@ -1,4 +1,4 @@
import { spawn, exec } from 'child_process';
import { exec, spawn } from 'node:child_process';
export default async () => {
let _resolve: () => unknown;
@@ -19,8 +19,6 @@ export default async () => {
await ready;
return async () => {
await new Promise<void>((resolve) =>
exec('docker compose down', () => resolve()),
);
await new Promise<void>((resolve) => exec('docker compose down', () => resolve()));
};
};
+43 -81
View File
@@ -3,11 +3,14 @@ import {
AssetResponseDto,
CreateAlbumDto,
CreateAssetDto,
CreateLibraryDto,
CreateUserDto,
PersonUpdateDto,
SharedLinkCreateDto,
ValidateLibraryDto,
createAlbum,
createApiKey,
createLibrary,
createPerson,
createSharedLink,
createUser,
@@ -18,18 +21,18 @@ import {
setAdminOnboarding,
signUpAdmin,
updatePerson,
validate,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:stream';
import { promisify } from 'node:util';
import pg from 'pg';
import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
const execPromise = promisify(exec);
@@ -43,6 +46,7 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
export const testAssetDirInternal = '/data/assets';
export const tempDir = tmpdir();
const serverContainerName = 'immich-e2e-server';
@@ -70,20 +74,12 @@ let client: pg.Client | null = null;
export const fileUtils = {
reset: async () => {
await execPromise(
`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`,
);
await execPromise(`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
},
};
export const dbUtils = {
createFace: async ({
assetId,
personId,
}: {
assetId: string;
personId: string;
}) => {
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
if (!client) {
return;
}
@@ -91,31 +87,28 @@ export const dbUtils = {
const vector = Array.from({ length: 512 }, Math.random);
const embedding = `[${vector.join(',')}]`;
await client.query(
'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)',
[assetId, personId, embedding],
);
await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [
assetId,
personId,
embedding,
]);
},
setPersonThumbnail: async (personId: string) => {
if (!client) {
return;
}
await client.query(
`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`,
[personId],
);
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
},
reset: async (tables?: string[]) => {
try {
if (!client) {
client = new pg.Client(
'postgres://postgres:postgres@127.0.0.1:5433/immich',
);
client = new pg.Client('postgres://postgres:postgres@127.0.0.1:5433/immich');
await client.connect();
}
tables = tables || [
'libraries',
'shared_links',
'person',
'albums',
@@ -223,12 +216,8 @@ export const wsUtils = {
return new Promise<Socket>((resolve) => {
websocket
.on('connect', () => resolve(websocket))
.on('on_upload_success', (data: AssetResponseDto) =>
onEvent({ event: 'upload', assetId: data.id }),
)
.on('on_asset_delete', (assetId: string) =>
onEvent({ event: 'delete', assetId }),
)
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'upload', assetId: data.id }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'delete', assetId }))
.connect();
});
},
@@ -241,21 +230,14 @@ export const wsUtils = {
set.clear();
}
},
waitForEvent: async ({
event,
assetId,
timeout: ms,
}: WaitOptions): Promise<void> => {
waitForEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise<void> => {
const set = events[event];
if (set.has(assetId)) {
return;
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${event} event`)),
ms || 5000,
);
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 5000);
callbacks[assetId] = () => {
clearTimeout(timeout);
@@ -265,6 +247,8 @@ export const wsUtils = {
},
};
type AssetData = { bytes?: Buffer; filename: string };
export const apiUtils = {
setup: () => {
defaults.baseUrl = app;
@@ -281,86 +265,64 @@ export const apiUtils = {
return response;
},
userSetup: async (accessToken: string, dto: CreateUserDto) => {
await createUser(
{ createUserDto: dto },
{ headers: asBearerAuth(accessToken) },
);
await createUser({ createUserDto: dto }, { headers: asBearerAuth(accessToken) });
return login({
loginCredentialDto: { email: dto.email, password: dto.password },
});
},
createApiKey: (accessToken: string) => {
return createApiKey(
{ apiKeyCreateDto: { name: 'e2e' } },
{ headers: asBearerAuth(accessToken) },
);
return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) });
},
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum(
{ createAlbumDto: dto },
{ headers: asBearerAuth(accessToken) },
),
createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }),
createAsset: async (
accessToken: string,
dto?: Partial<Omit<CreateAssetDto, 'assetData'>>,
data?: {
bytes?: Buffer;
filename?: string;
},
dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData },
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...(dto || {}),
...dto,
};
const _assetData = {
bytes: randomBytes(32),
filename: 'example.jpg',
...(data || {}),
};
const assetData = dto?.assetData?.bytes || makeRandomImage();
const filename = dto?.assetData?.filename || 'example.png';
const builder = request(app)
.post(`/asset/upload`)
.attach('assetData', _assetData.bytes, _assetData.filename)
.attach('assetData', assetData, filename)
.set('Authorization', `Bearer ${accessToken}`);
for (const [key, value] of Object.entries(_dto)) {
builder.field(key, String(value));
void builder.field(key, String(value));
}
const { body } = await builder;
return body as AssetFileUploadResponseDto;
},
getAssetInfo: (accessToken: string, id: string) =>
getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets(
{ assetBulkDeleteDto: { ids } },
{ headers: asBearerAuth(accessToken) },
),
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
createPerson: async (accessToken: string, dto?: PersonUpdateDto) => {
// TODO fix createPerson to accept a body
let person = await createPerson({ headers: asBearerAuth(accessToken) });
const person = await createPerson({ headers: asBearerAuth(accessToken) });
await dbUtils.setPersonThumbnail(person.id);
if (!dto) {
return person;
}
return updatePerson(
{ id: person.id, personUpdateDto: dto },
{ headers: asBearerAuth(accessToken) },
);
return updatePerson({ id: person.id, personUpdateDto: dto }, { headers: asBearerAuth(accessToken) });
},
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
createSharedLink(
{ sharedLinkCreateDto: dto },
{ headers: asBearerAuth(accessToken) },
),
createSharedLink({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }),
createLibrary: (accessToken: string, dto: CreateLibraryDto) =>
createLibrary({ createLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
validateLibrary: (accessToken: string, id: string, dto: ValidateLibraryDto) =>
validate({ id, validateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
};
export const cliUtils = {
@@ -380,7 +342,7 @@ export const webUtils = {
value: accessToken,
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
expires: 1_742_402_728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
@@ -390,7 +352,7 @@ export const webUtils = {
value: 'password',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
expires: 1_742_402_728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
@@ -400,7 +362,7 @@ export const webUtils = {
value: 'true',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
expires: 1_742_402_728,
httpOnly: false,
secure: false,
sameSite: 'Lax',
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiUtils, dbUtils, webUtils } from 'src/utils';
test.describe('Registration', () => {
@@ -68,7 +68,7 @@ test.describe('Registration', () => {
await page.getByRole('button', { name: 'Login' }).click();
// change password
expect(page.getByRole('heading')).toHaveText('Change Password');
await expect(page.getByRole('heading')).toHaveText('Change Password');
await expect(page).toHaveURL('/auth/change-password');
await page.getByLabel('New Password').fill('new-password');
await page.getByLabel('Confirm Password').fill('new-password');
+4 -5
View File
@@ -1,18 +1,17 @@
import {
AlbumResponseDto,
AssetResponseDto,
AssetFileUploadResponseDto,
LoginResponseDto,
SharedLinkResponseDto,
SharedLinkType,
createAlbum,
createSharedLink,
} from '@immich/sdk';
import { test } from '@playwright/test';
import { apiUtils, asBearerAuth, dbUtils } from 'src/utils';
test.describe('Shared Links', () => {
let admin: LoginResponseDto;
let asset: AssetResponseDto;
let asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
@@ -29,7 +28,7 @@ test.describe('Shared Links', () => {
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
);
sharedLink = await apiUtils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
@@ -53,7 +52,7 @@ test.describe('Shared Links', () => {
await page.waitForSelector('#asset-group-by-date svg');
await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING').waitFor();
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
});
test('enter password for a shared link', async ({ page }) => {
+1
View File
@@ -18,5 +18,6 @@
"rootDirs": ["src"],
"baseUrl": "./"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
+9 -1
View File
@@ -1,9 +1,17 @@
import { defineConfig } from 'vitest/config';
// skip `docker compose up` if `make e2e` was already run
const globalSetup: string[] = [];
try {
await fetch('http://127.0.0.1:2283/api/server-info/ping');
} catch {
globalSetup.push('src/setup.ts');
}
export default defineConfig({
test: {
include: ['src/{api,cli}/specs/*.e2e-spec.ts'],
globalSetup: ['src/setup.ts'],
globalSetup,
poolOptions: {
threads: {
singleThread: true,
+1
View File
@@ -63,6 +63,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
npm --prefix server version "$SERVER_PUMP"
npm --prefix web version "$SERVER_PUMP"
npm --prefix open-api/typescript-sdk version "$SERVER_PUMP"
make open-api
poetry --directory machine-learning version "$SERVER_PUMP"
fi
+1 -1
View File
@@ -180,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3
@@ -0,0 +1,39 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
extension LocalAlbumIsarHelper on IsarCollection<LocalAlbum> {
Future<void> store(LocalAlbum a) async {
await put(a);
await a.thumb.save();
await a.assets.save();
}
}
extension RemoteAlbumIsarHelper on IsarCollection<RemoteAlbum> {
Future<void> store(RemoteAlbum a) async {
await put(a);
await a.owner.save();
await a.thumb.save();
await a.sharedUsers.save();
await a.assets.save();
}
}
extension BackupAlbumIsarHelper on IsarCollection<BackupAlbum> {
Future<void> store(BackupAlbum a) async {
await put(a);
await a.album.save();
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}
extension AssetPathEntityHelper on AssetPathEntity {
String get eTagKeyAssetCount => "device-album-$id-asset-count";
}
@@ -0,0 +1,11 @@
extension OptionalCast on Object {
T? tryCast<T>() => this is T ? this as T : null;
}
extension NullUtilities on Object? {
/// Returns true if object is null or is empty
bool get isNullOrEmpty => (this == null || _isIterableAndEmpty);
bool get _isIterableAndEmpty =>
this is Iterable ? (this as Iterable).isEmpty : false;
}
+11 -11
View File
@@ -9,20 +9,19 @@ import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
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';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/ios_device_asset.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -73,14 +72,15 @@ Future<void> initApp() async {
FlutterError.onError = (details) {
FlutterError.presentError(details);
log.severe(
'FlutterError - Catch all',
"${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
details,
details.stack,
);
};
PlatformDispatcher.instance.onError = (error, stack) {
log.severe('PlatformDispatcher - Catch all', error, stack);
log.severe('PlatformDispatcher - Catch all error: $error', error, stack);
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
return true;
};
@@ -94,14 +94,14 @@ Future<Isar> loadDb() async {
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
LocalAlbumSchema,
RemoteAlbumSchema,
UserSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
if (Platform.isAndroid) AndroidDeviceAssetSchema,
if (Platform.isIOS) IOSDeviceAssetSchema,
DeviceAssetSchema,
],
directory: dir.path,
maxSizeMiB: 256,
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/activities/providers/activity.provider.dar
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
@@ -22,15 +23,15 @@ class ActivitiesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Album has to be set in the provider before reaching this page
final album = ref.watch(currentAlbumProvider)!;
// Album has to be set in the provider before reaching this page and has to be a RemoteAlbum
final album = ref.watch(currentAlbumProvider)! as RemoteAlbum;
final asset = ref.watch(currentAssetProvider);
final user = ref.watch(currentUserProvider);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activityNotifier =
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
final activities =
ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
ref.watch(albumActivityProvider(album.id, asset?.remoteId));
final listViewScrollController = useScrollController();
@@ -24,8 +24,8 @@ class ActivityTextField extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activityNotifier =
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
final user = ref.watch(currentUserProvider);
final inputController = useTextEditingController();
final inputFocusNode = useFocusNode();
@@ -0,0 +1,216 @@
// ignore_for_file: add-copy-with
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.model.g.dart';
/// Acts as a common class for RemoteAlbums and LocalAlbums to perform generic album handling irrespective of
/// where the album is from
sealed class Album {
Id get isarId => fastHash(id);
@Index(unique: true, replace: true, type: IndexType.hash)
final String id;
String name;
DateTime modifiedAt;
final IsarLink<Asset> thumb = IsarLink<Asset>();
static const assetsLinkId = 'assets';
final IsarLinks<Asset> assets = IsarLinks<Asset>();
@ignore
int get assetCount => assets.length;
@ignore
Asset? get thumbnail => thumb.value;
Album({
required this.id,
required this.name,
required this.modifiedAt,
});
@override
String toString() {
return 'Album(id: $id, name: $name, assetCount: $assetCount)';
}
@override
bool operator ==(covariant Album other) {
if (identical(this, other)) return true;
return other.id == id &&
other.name == name &&
other.modifiedAt == modifiedAt &&
other.thumb == thumb &&
other.assetCount == assetCount;
}
@override
@ignore
int get hashCode {
return id.hashCode ^
name.hashCode ^
modifiedAt.hashCode ^
thumb.hashCode ^
assetCount.hashCode;
}
}
@Collection()
class LocalAlbum extends Album {
static const isAllId = 'isAll';
@Backlink(to: BackupAlbum.albumLinkId)
final IsarLink<BackupAlbum> backup = IsarLink<BackupAlbum>();
LocalAlbum({
required super.id,
required super.name,
required super.modifiedAt,
});
@override
String toString() {
return 'LocalAlbum(id: $id, name: $name, assetCount: $assetCount)';
}
static LocalAlbum fromAssetPathEntity(
AssetPathEntity ape, {
Asset? thumbnail,
Iterable<Asset>? assets,
}) {
final album = LocalAlbum(
id: ape.id,
name: ape.name,
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
);
if (assets != null) {
album.assets.addAll(assets);
}
album.thumb.value = thumbnail;
return album;
}
}
@Collection()
class RemoteAlbum extends Album {
DateTime createdAt;
DateTime? startDate;
DateTime? endDate;
DateTime? lastModifiedAssetTimestamp;
bool shared;
bool activityEnabled;
final IsarLink<User> owner = IsarLink<User>();
final IsarLinks<User> sharedUsers = IsarLinks<User>();
@ignore
String? get ownerId => owner.value?.id;
@ignore
String? get ownerName {
// Guard null owner
if (owner.value == null) {
return null;
}
final name = <String>[];
if (owner.value?.name != null) {
name.add(owner.value!.name);
}
return name.join(' ');
}
RemoteAlbum({
required super.id,
required super.name,
required super.modifiedAt,
required this.createdAt,
this.startDate,
this.endDate,
this.lastModifiedAssetTimestamp,
this.shared = false,
this.activityEnabled = true,
});
@override
String toString() {
return 'RemoteAlbum(id: $id, name: $name, assetCount: $assetCount, createdAt: $createdAt, startDate: $startDate, endDate: $endDate, lastModifiedAssetTimestamp: $lastModifiedAssetTimestamp, shared: $shared, activityEnabled: $activityEnabled)';
}
@override
bool operator ==(covariant RemoteAlbum other) {
if (identical(this, other)) return true;
final lastModifiedAssetTimestampIsSetAndEqual =
lastModifiedAssetTimestamp != null &&
other.lastModifiedAssetTimestamp != null
? lastModifiedAssetTimestamp!
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
: true;
return super == other &&
other.createdAt == createdAt &&
other.startDate == startDate &&
other.endDate == endDate &&
lastModifiedAssetTimestampIsSetAndEqual &&
other.shared == shared &&
other.activityEnabled == activityEnabled;
}
@override
int get hashCode {
return super.hashCode ^
createdAt.hashCode ^
startDate.hashCode ^
endDate.hashCode ^
lastModifiedAssetTimestamp.hashCode ^
shared.hashCode ^
activityEnabled.hashCode;
}
static Future<RemoteAlbum> fromDto(AlbumResponseDto dto, Isar db) async {
final album = RemoteAlbum(
id: dto.id,
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
album.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {
album.thumb.value = await db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
}
if (dto.sharedUsers.isNotEmpty) {
final users = await db.users
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
album.sharedUsers.addAll(users.cast());
}
if (dto.assets.isNotEmpty) {
final assets =
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
album.assets.addAll(assets);
}
return album;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,74 +1,26 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums
.filter()
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
query.findAll().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
Future<void> getAllAlbums() => Future.wait([
_albumService.refreshDeviceAlbums(),
_albumService.refreshRemoteAlbums(isShared: false),
]);
Future<void> getDeviceAlbums() => _albumService.refreshDeviceAlbums();
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
Future<Album?> createAlbum(
String albumTitle,
Set<Asset> assets,
) =>
_albumService.createAlbum(albumTitle, assets, []);
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final albumProvider =
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
);
});
final albumWatcher =
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
final remoteAlbumWatcher =
StreamProvider.autoDispose.family<RemoteAlbum, int>((ref, albumId) async* {
final db = ref.watch(dbProvider);
final a = await db.albums.get(albumId);
final a = await db.remoteAlbums.get(albumId);
if (a != null) yield a;
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
await for (final a
in db.remoteAlbums.watchObject(albumId, fireImmediately: true)) {
if (a != null) yield a;
}
});
final albumRenderlistProvider =
final remoteAlbumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(albumWatcher(albumId)).value;
final album = ref.watch(remoteAlbumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
@@ -76,3 +28,25 @@ final albumRenderlistProvider =
}
return const Stream.empty();
});
final localAlbumWatcher =
StreamProvider.autoDispose.family<LocalAlbum, int>((ref, albumId) async* {
final db = ref.watch(dbProvider);
final a = await db.localAlbums.get(albumId);
if (a != null) yield a;
await for (final a
in db.localAlbums.watchObject(albumId, fireImmediately: true)) {
if (a != null) yield a;
}
});
final localAlbumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(localAlbumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().localIdIsNotNull().sortByFileCreatedAtDesc();
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
}
return const Stream.empty();
});
@@ -1,7 +1,7 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/modules/album/models/album.model.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/models/album.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'album_sort_by_options.provider.g.dart';
@@ -11,9 +11,13 @@ typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
class _AlbumSortHandlers {
const _AlbumSortHandlers._();
/// Sorts a List<Album> based on their created date.
///
/// ! This is not support for LocalAlbums and they are filtered out from the result
static const AlbumSortFn created = _sortByCreated;
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.createdAt);
final sorted =
albums.whereType<RemoteAlbum>().sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
@@ -36,9 +40,12 @@ class _AlbumSortHandlers {
return (isReverse ? sorted.reversed : sorted).toList();
}
/// Sorts a List<Album> based on the most recent assets.
///
/// ! This is not support for LocalAlbums and they are filtered out from the result
static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
if (a.endDate != null && b.endDate != null) {
return a.endDate!.compareTo(b.endDate!);
}
@@ -49,9 +56,12 @@ class _AlbumSortHandlers {
return (isReverse ? sorted.reversed : sorted).toList();
}
/// Sorts a List<Album> based on the most oldest assets.
///
/// ! This is not support for LocalAlbums and they are filtered out from the result
static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
if (a.startDate != null && b.startDate != null) {
return a.startDate!.compareTo(b.startDate!);
}
@@ -1,8 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/models/album_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
AlbumViewerNotifier(this.ref)
@@ -31,7 +31,7 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
}
Future<bool> changeAlbumTitle(
Album album,
RemoteAlbum album,
String newAlbumTitle,
) async {
AlbumService service = ref.watch(albumServiceProvider);
@@ -1,4 +1,4 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_album.provider.g.dart';
@@ -0,0 +1,22 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/local_album_service.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'local_album.provider.g.dart';
@riverpod
class LocalAlbums extends _$LocalAlbums {
@override
Stream<List<LocalAlbum>> build() async* {
final db = ref.read(dbProvider);
final stream =
db.localAlbums.where().watch().listen((v) => state = AsyncData(v));
ref.onDispose(() => stream.cancel());
yield await db.localAlbums.where().findAll();
}
Future<void> getDeviceAlbums() =>
ref.read(localAlbumServiceProvider).refreshDeviceAlbums();
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumsHash() => r'2142afa2aef7d84062c71d999035f851a9c3978b';
/// See also [LocalAlbums].
@ProviderFor(LocalAlbums)
final localAlbumsProvider =
AutoDisposeStreamNotifierProvider<LocalAlbums, List<LocalAlbum>>.internal(
LocalAlbums.new,
name: r'localAlbumsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$localAlbumsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LocalAlbums = AutoDisposeStreamNotifier<List<LocalAlbum>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,16 @@
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/hash.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'local_album_service.provider.g.dart';
@Riverpod(keepAlive: true)
LocalAlbumService localAlbumService(LocalAlbumServiceRef ref) =>
LocalAlbumService(
ref.watch(dbProvider),
ref.read(hashServiceProvider),
ref.read(syncServiceProvider),
ref,
);
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album_service.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumServiceHash() => r'1b9bb2dfb0ade102f9e7c97e9b8a50c060cdd580';
/// See also [localAlbumService].
@ProviderFor(localAlbumService)
final localAlbumServiceProvider = Provider<LocalAlbumService>.internal(
localAlbumService,
name: r'localAlbumServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$localAlbumServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef LocalAlbumServiceRef = ProviderRef<LocalAlbumService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,32 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'remote_album.provider.g.dart';
@riverpod
class RemoteAlbums extends _$RemoteAlbums {
@override
Stream<List<RemoteAlbum>> build() async* {
final db = ref.read(dbProvider);
final stream =
db.remoteAlbums.where().watch().listen((v) => state = AsyncData(v));
ref.onDispose(() => stream.cancel());
yield await db.remoteAlbums.where().findAll();
}
Future<void> getRemoteAlbums([bool isShared = false]) =>
ref.read(albumServiceProvider).refreshRemoteAlbums(isShared: isShared);
Future<bool> deleteAlbum(RemoteAlbum album) =>
ref.read(albumServiceProvider).deleteAlbum(album);
Future<RemoteAlbum?> createAlbum(
String albumTitle,
Set<Asset> assets,
) =>
ref.read(albumServiceProvider).createAlbum(albumTitle, assets, []);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'remote_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$remoteAlbumsHash() => r'bae71f612bbfcd3e2f193dd63eef53ea1d822af2';
/// See also [RemoteAlbums].
@ProviderFor(RemoteAlbums)
final remoteAlbumsProvider =
AutoDisposeStreamNotifierProvider<RemoteAlbums, List<RemoteAlbum>>.internal(
RemoteAlbums.new,
name: r'remoteAlbumsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$remoteAlbumsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$RemoteAlbums = AutoDisposeStreamNotifier<List<RemoteAlbum>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -2,16 +2,17 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
final query =
db.remoteAlbums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) {
if (mounted) {
state = value;
@@ -21,9 +22,9 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
}
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
late final StreamSubscription<List<RemoteAlbum>> _streamSub;
Future<Album?> createSharedAlbum(
Future<RemoteAlbum?> createSharedAlbum(
String albumName,
Iterable<Asset> assets,
Iterable<User> sharedUsers,
@@ -43,9 +44,10 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
Future<void> getAllSharedAlbums() =>
_albumService.refreshRemoteAlbums(isShared: true);
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
Future<bool> deleteAlbum(RemoteAlbum album) =>
_albumService.deleteAlbum(album);
Future<bool> leaveAlbum(Album album) async {
Future<bool> leaveAlbum(RemoteAlbum album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
@@ -56,11 +58,11 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
}
}
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
Future<bool> removeAssetFromAlbum(RemoteAlbum album, Iterable<Asset> assets) {
return _albumService.removeAssetFromAlbum(album, assets);
}
Future<bool> removeUserFromAlbum(Album album, User user) async {
Future<bool> removeUserFromAlbum(RemoteAlbum album, User user) async {
final result = await _albumService.removeUserFromAlbum(album, user);
if (result && album.sharedUsers.isEmpty) {
@@ -70,7 +72,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
return result;
}
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
Future<bool> setActivityEnabled(RemoteAlbum album, bool activityEnabled) {
return _albumService.setActivityEnabled(album, activityEnabled);
}
@@ -82,7 +84,8 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
}
final sharedAlbumProvider =
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<RemoteAlbum>>(
(ref) {
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
@@ -0,0 +1,12 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'sorted_album.provider.g.dart';
@riverpod
List<Album> sortedAlbum(SortedAlbumRef ref, List<Album> albums) {
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
return albumSortOption.sortFn(albums, albumSortIsReverse);
}
@@ -0,0 +1,158 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sorted_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sortedAlbumHash() => r'350f537324a42ba9e01e50ca3722878ec3a8330c';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [sortedAlbum].
@ProviderFor(sortedAlbum)
const sortedAlbumProvider = SortedAlbumFamily();
/// See also [sortedAlbum].
class SortedAlbumFamily extends Family<List<Album>> {
/// See also [sortedAlbum].
const SortedAlbumFamily();
/// See also [sortedAlbum].
SortedAlbumProvider call(
List<Album> albums,
) {
return SortedAlbumProvider(
albums,
);
}
@override
SortedAlbumProvider getProviderOverride(
covariant SortedAlbumProvider provider,
) {
return call(
provider.albums,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'sortedAlbumProvider';
}
/// See also [sortedAlbum].
class SortedAlbumProvider extends AutoDisposeProvider<List<Album>> {
/// See also [sortedAlbum].
SortedAlbumProvider(
List<Album> albums,
) : this._internal(
(ref) => sortedAlbum(
ref as SortedAlbumRef,
albums,
),
from: sortedAlbumProvider,
name: r'sortedAlbumProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$sortedAlbumHash,
dependencies: SortedAlbumFamily._dependencies,
allTransitiveDependencies:
SortedAlbumFamily._allTransitiveDependencies,
albums: albums,
);
SortedAlbumProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.albums,
}) : super.internal();
final List<Album> albums;
@override
Override overrideWith(
List<Album> Function(SortedAlbumRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SortedAlbumProvider._internal(
(ref) => create(ref as SortedAlbumRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
albums: albums,
),
);
}
@override
AutoDisposeProviderElement<List<Album>> createElement() {
return _SortedAlbumProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SortedAlbumProvider && other.albums == albums;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, albums.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SortedAlbumRef on AutoDisposeProviderRef<List<Album>> {
/// The parameter `albums` of this provider.
List<Album> get albums;
}
class _SortedAlbumProviderElement
extends AutoDisposeProviderElement<List<Album>> with SortedAlbumRef {
_SortedAlbumProviderElement(super.provider);
@override
List<Album> get albums => (origin as SortedAlbumProvider).albums;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -1,14 +1,10 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/album_extensions.dart';
import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -18,9 +14,7 @@ import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
@@ -28,7 +22,6 @@ final albumServiceProvider = Provider(
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
ref.watch(backupServiceProvider),
),
);
@@ -37,9 +30,6 @@ class AlbumService {
final UserService _userService;
final SyncService _syncService;
final Isar _db;
final BackupService _backupService;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
AlbumService(
@@ -47,98 +37,8 @@ class AlbumService {
this._userService,
this._syncService,
this._db,
this._backupService,
);
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future<bool> refreshDeviceAlbums() async {
if (!_localCompleter.isCompleted) {
// guard against concurrent calls
_log.info("refreshDeviceAlbums is already in progress");
return _localCompleter.future;
}
_localCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<String> excludedIds =
await _backupService.excludedAlbumsQuery().idProperty().findAll();
final List<String> selectedIds =
await _backupService.selectedAlbumsQuery().idProperty().findAll();
if (selectedIds.isEmpty) {
final numLocal = await _db.albums.where().localIdIsNotNull().count();
if (numLocal > 0) {
_syncService.removeAllLocalAlbumsAndAssets();
}
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
if (Platform.isIOS) {
// iOS and Android device album working principle differ significantly
// on iOS, an asset can be in multiple albums
// on Android, an asset can only be in exactly one album (folder!) at the same time
// thus, on Android, excluding an album can be done by ignoring that album
// however, on iOS, it it necessary to load the assets from all excluded
// albums and check every asset from any selected album against the set
// of excluded assets
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
_log.info("Found ${excludedAssets.length} assets to exclude");
}
// remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id));
_log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
if (Platform.isAndroid) {
// remove the virtual "Recent" album and keep and individual albums
// on Android, the virtual "Recent" `lastModified` value is always null
onDevice.removeWhere((e) => e.isAll);
_log.info("'Recents' is selected, keeping all individual albums");
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
_log.info("Syncing completed. Changes: $changes");
} finally {
_localCompleter.complete(changes);
}
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) {
if (excludedAlbumIds.contains(a.id)) {
final List<AssetEntity> assets =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
result.addAll(assets.map((e) => e.id));
}
}
return result;
}
/// Checks remote albums (owned if `isShared` is false) for changes,
/// updates the local database and returns `true` if there were any changes
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
@@ -170,7 +70,7 @@ class AlbumService {
return changes;
}
Future<Album?> createAlbum(
Future<RemoteAlbum?> createAlbum(
String albumName,
Iterable<Asset> assets, [
Iterable<User> sharedUsers = const [],
@@ -184,8 +84,8 @@ class AlbumService {
),
);
if (remote != null) {
Album album = await Album.remote(remote);
await _db.writeTxn(() => _db.albums.store(album));
RemoteAlbum album = await RemoteAlbum.fromDto(remote, _db);
await _db.writeTxn(() => _db.remoteAlbums.store(album));
return album;
}
} catch (e) {
@@ -203,13 +103,16 @@ class AlbumService {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (null ==
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
await _db.remoteAlbums
.filter()
.nameEqualTo(proposedName)
.findFirst()) {
return proposedName;
}
}
}
Future<Album?> createAlbumWithGeneratedName(
Future<RemoteAlbum?> createAlbumWithGeneratedName(
Iterable<Asset> assets,
) async {
return createAlbum(
@@ -221,11 +124,17 @@ class AlbumService {
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
Iterable<Asset> assets,
Album album,
RemoteAlbum album,
) async {
try {
final remoteAlbum =
await _db.remoteAlbums.where().idEqualTo(album.id).findFirst();
if (remoteAlbum == null) {
return null;
}
var response = await _apiService.albumApi.addAssetsToAlbum(
album.remoteId!,
remoteAlbum.id,
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
);
@@ -244,10 +153,10 @@ class AlbumService {
}
await _db.writeTxn(() async {
await album.assets.update(link: successAssets);
final a = await _db.albums.get(album.id);
await remoteAlbum.assets.update(link: successAssets);
final a = await _db.remoteAlbums.get(remoteAlbum.isarId);
// trigger watcher
await _db.albums.put(a!);
await _db.remoteAlbums.put(a!);
});
return AddAssetsResponse(
@@ -264,11 +173,11 @@ class AlbumService {
Future<bool> addAdditionalUserToAlbum(
List<String> sharedUserIds,
Album album,
RemoteAlbum album,
) async {
try {
final result = await _apiService.albumApi.addUsersToAlbum(
album.remoteId!,
album.id,
AddUsersDto(sharedUserIds: sharedUserIds),
);
if (result != null) {
@@ -276,7 +185,7 @@ class AlbumService {
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
album.shared = result.shared;
await _db.writeTxn(() async {
await _db.albums.put(album);
await _db.remoteAlbums.put(album);
await album.sharedUsers.save();
});
return true;
@@ -287,15 +196,15 @@ class AlbumService {
return false;
}
Future<bool> setActivityEnabled(Album album, bool enabled) async {
Future<bool> setActivityEnabled(RemoteAlbum album, bool enabled) async {
try {
final result = await _apiService.albumApi.updateAlbumInfo(
album.remoteId!,
album.id,
UpdateAlbumDto(isActivityEnabled: enabled),
);
if (result != null) {
album.activityEnabled = enabled;
await _db.writeTxn(() => _db.albums.put(album));
await _db.writeTxn(() => _db.remoteAlbums.put(album));
return true;
}
} catch (e) {
@@ -304,20 +213,20 @@ class AlbumService {
return false;
}
Future<bool> deleteAlbum(Album album) async {
Future<bool> deleteAlbum(RemoteAlbum album) async {
try {
final userId = Store.get(StoreKey.currentUser).isarId;
if (album.owner.value?.isarId == userId) {
await _apiService.albumApi.deleteAlbum(album.remoteId!);
await _apiService.albumApi.deleteAlbum(album.id);
}
if (album.shared) {
final foreignAssets =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
await _db.writeTxn(() => _db.albums.delete(album.id));
final List<Album> albums =
await _db.albums.filter().sharedEqualTo(true).findAll();
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
final List<RemoteAlbum> albums =
await _db.remoteAlbums.filter().sharedEqualTo(true).findAll();
final List<Asset> existing = [];
for (Album a in albums) {
for (RemoteAlbum a in albums) {
existing.addAll(
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
);
@@ -328,7 +237,7 @@ class AlbumService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
}
} else {
await _db.writeTxn(() => _db.albums.delete(album.id));
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
}
return true;
} catch (e) {
@@ -337,9 +246,9 @@ class AlbumService {
return false;
}
Future<bool> leaveAlbum(Album album) async {
Future<bool> leaveAlbum(RemoteAlbum album) async {
try {
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
await _apiService.albumApi.removeUserFromAlbum(album.id, "me");
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
@@ -348,21 +257,21 @@ class AlbumService {
}
Future<bool> removeAssetFromAlbum(
Album album,
RemoteAlbum album,
Iterable<Asset> assets,
) async {
try {
await _apiService.albumApi.removeAssetFromAlbum(
album.remoteId!,
album.id,
BulkIdsDto(
ids: assets.map((asset) => asset.remoteId!).toList(),
),
);
await _db.writeTxn(() async {
await album.assets.update(unlink: assets);
final a = await _db.albums.get(album.id);
final a = await _db.remoteAlbums.get(album.isarId);
// trigger watcher
await _db.albums.put(a!);
await _db.remoteAlbums.put(a!);
});
return true;
@@ -373,21 +282,21 @@ class AlbumService {
}
Future<bool> removeUserFromAlbum(
Album album,
RemoteAlbum album,
User user,
) async {
try {
await _apiService.albumApi.removeUserFromAlbum(
album.remoteId!,
album.id,
user.id,
);
album.sharedUsers.remove(user);
await _db.writeTxn(() async {
await album.sharedUsers.update(unlink: [user]);
final a = await _db.albums.get(album.id);
final a = await _db.remoteAlbums.get(album.isarId);
// trigger watcher
await _db.albums.put(a!);
await _db.remoteAlbums.put(a!);
});
return true;
@@ -398,18 +307,18 @@ class AlbumService {
}
Future<bool> changeTitleAlbum(
Album album,
RemoteAlbum album,
String newAlbumTitle,
) async {
try {
await _apiService.albumApi.updateAlbumInfo(
album.remoteId!,
album.id,
UpdateAlbumDto(
albumName: newAlbumTitle,
),
);
album.name = newAlbumTitle;
await _db.writeTxn(() => _db.albums.put(album));
await _db.writeTxn(() => _db.remoteAlbums.put(album));
return true;
} catch (e) {
@@ -0,0 +1,363 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/extensions/album_extensions.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/hash.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class LocalAlbumService {
Completer<bool> _localCompleter = Completer()..complete(false);
final Logger _log = Logger('LocalAlbumService');
final Isar _db;
final Ref _ref;
final HashService _hashService;
final SyncService _syncService;
LocalAlbumService(this._db, this._hashService, this._syncService, this._ref);
Future<bool> refreshDeviceAlbums() async =>
SyncService.lock.run(_refreshDeviceAlbums);
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future<bool> _refreshDeviceAlbums() async {
if (!_localCompleter.isCompleted) {
// guard against concurrent calls
_log.info("refreshDeviceAlbums is already in progress");
return _localCompleter.future;
}
_localCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
filterOption: FilterOptionGroup(containsPathModified: true),
);
_log.fine("Found ${onDevice.length} device albums");
changes = await _syncLocalAlbumAssetsToDb(onDevice);
_log.fine("Syncing completed. Changes: $changes");
} finally {
_localCompleter.complete(changes);
}
_log.fine("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
final inDb = await _db.localAlbums.where().sortById().findAll();
final List<Asset> deleteCandidates = [];
final List<Asset> existing = [];
assert(inDb.isSorted((a, b) => a.id.compareTo(b.id)), "sort!");
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, LocalAlbum b) => a.id.compareTo(b.id),
both: (AssetPathEntity ape, LocalAlbum album) =>
_syncAlbumInDbAndOnDevice(ape, album, deleteCandidates, existing),
onlyFirst: (AssetPathEntity ape) => _addAlbumFromDevice(ape, existing),
onlySecond: (LocalAlbum a) => _removeAlbumFromDb(a, deleteCandidates),
);
_log.fine(
"Syncing all local albums almost done. Collected ${deleteCandidates.length} asset candidates to delete",
);
final (toDelete, toUpdate) =
_handleAssetRemoval(deleteCandidates, existing, remote: false);
_log.fine(
"${toDelete.length} assets to delete, ${toUpdate.length} to update",
);
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
await _db.writeTxn(() async {
await _db.assets.deleteAll(toDelete);
await _db.exifInfos.deleteAll(toDelete);
await _db.assets.putAll(toUpdate);
});
_log.info(
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
);
}
return anyChanges;
}
/// Accumulates all suitable album assets to the `deleteCandidates` and
/// removes the album from the database.
Future<void> _removeAlbumFromDb(
LocalAlbum album,
List<Asset> deleteCandidates,
) async {
_log.info("Removing local album $album from DB");
// delete assets in DB unless they are remote or part of some other album
deleteCandidates.addAll(
await album.assets.filter().remoteIdIsNull().findAll(),
);
await album.backup.load();
final backupAlbum = album.backup.value;
try {
final ok = await _db.writeTxn(() async {
return await _db.localAlbums.delete(album.isarId) &&
(backupAlbum == null ||
await _db.backupAlbums.delete(album.isarId));
});
assert(ok);
_log.info("Removed local album $album from DB");
} catch (e, stack) {
_log.severe("Failed to remove local album $album from DB: $e", stack);
}
}
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
(List<int> toDelete, List<Asset> toUpdate) _handleAssetRemoval(
List<Asset> deleteCandidates,
List<Asset> existing, {
bool? remote,
}) {
if (deleteCandidates.isEmpty) {
return const ([], []);
}
deleteCandidates.sort(Asset.compareById);
deleteCandidates.uniqueConsecutive(compare: Asset.compareById);
existing.sort(Asset.compareById);
existing.uniqueConsecutive(compare: Asset.compareById);
final (tooAdd, toUpdate, toRemove) = _syncService.diffAssets(
existing,
deleteCandidates,
compare: Asset.compareById,
remote: remote,
);
assert(tooAdd.isEmpty, "toAdd should be empty in _handleAssetRemoval");
return (toRemove.map((e) => e.id).toList(), toUpdate);
}
/// Syncs the device album to the album in the database
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
LocalAlbum album,
List<Asset> deleteCandidates,
List<Asset> existing, [
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
return false;
}
if (!forceRefresh && await _syncDeviceAlbumFast(ape, album)) {
return true;
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
final inDb = await album.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByChecksum()
.findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final List<Asset> onDevice = await _hashService.getHashedAssets(ape);
// _removeDuplicates sorts `onDevice` by checksum
_removeDuplicates(onDevice);
final (toAdd, toUpdate, toDelete) = _syncService.diffAssets(onDevice, inDb);
_log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
);
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
_log.fine(
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
);
deleteCandidates.addAll(toDelete);
existing.addAll(existingInDb);
album.name = ape.name;
album.modifiedAt = ape.lastModified ?? DateTime.now();
if (album.thumb.value != null && toDelete.contains(album.thumbnail)) {
album.thumb.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate);
await album.assets
.update(link: existingInDb + updated, unlink: toDelete);
album.thumb.value ??= await album.assets.filter().findFirst();
await _db.localAlbums.store(album);
});
_updateETagCount(ape);
_log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e, stack) {
_log.severe("Failed to update synced album ${ape.name} in DB: $e", stack);
}
return true;
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(
AssetPathEntity a,
LocalAlbum b,
) async {
final lastKnownTotal =
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount ?? 0;
final hasSameLastModified = !(Platform.isAndroid && a.isAll) &&
(a.lastModified == null ||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt));
return a.name != b.name ||
hasSameLastModified ||
await a.assetCountAsync != lastKnownTotal;
}
/// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice(
AssetPathEntity ape,
List<Asset> existing,
) async {
_log.info("Syncing a new local album to DB: ${ape.name}");
final assets = await _hashService.getHashedAssets(ape);
_removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info(
"${existingInDb.length} assets already existed in DB, to upsert ${updated.length}",
);
await _syncService.upsertAssetsWithExif(updated);
existing.addAll(existingInDb);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
final LocalAlbum a = LocalAlbum.fromAssetPathEntity(
ape,
thumbnail: thumb,
assets: existingInDb.followedBy(updated),
);
try {
await _db.writeTxn(() => _db.localAlbums.store(a));
_updateETagCount(ape);
_log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e, stack) {
_log.severe("Failed to add new local album ${ape.name} to DB: $e", stack);
}
await _ref.read(backupAlbumsProvider.notifier).syncWithLocalAlbum(a);
}
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(
AssetPathEntity ape,
LocalAlbum album,
) async {
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
return false;
}
final int totalOnDevice = await ape.assetCountAsync;
final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified ?? DateTime.now(),
),
),
)
: null;
if (modified == null) {
return false;
}
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified ?? DateTime.now();
_removeDuplicates(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.localAlbums.put(album);
});
_updateETagCount(ape);
_log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e, stack) {
_log.severe(
"Failed to fast sync local album ${ape.name} to DB: $e",
stack,
);
return false;
}
return true;
}
Future<void> _updateETagCount(AssetPathEntity ape) async {
final assetCountOnDevice = await ape.assetCountAsync;
return _db.writeTxn(
() => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
),
);
}
List<Asset> _removeDuplicates(List<Asset> assets) {
final int before = assets.length;
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
assets.uniqueConsecutive(
compare: Asset.compareByOwnerChecksum,
onDuplicate: (a, b) =>
_log.fine("Ignoring duplicate assets on device:\n$a\n$b"),
);
final int duplicates = before - assets.length;
if (duplicates > 0) {
_log.warning("Ignored $duplicates duplicate assets on device");
}
return assets;
}
/// Returns a tuple (existing, updated)
Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(
List<Asset> assets,
) async {
if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
final List<Asset?> inDb = await _db.assets.getAllByOwnerIdChecksum(
assets.map((a) => a.ownerId).toInt64List(),
assets.map((a) => a.checksum).toList(growable: false),
);
assert(inDb.length == assets.length);
final List<Asset> existing = [], toUpsert = [];
for (int i = 0; i < assets.length; i++) {
final Asset? b = inDb[i];
if (b == null) {
toUpsert.add(assets[i]);
continue;
}
if (b.canUpdate(assets[i])) {
final updated = b.updatedCopy(assets[i]);
assert(updated.id != Isar.autoIncrement);
toUpsert.add(updated);
} else {
existing.add(b);
}
}
assert(existing.length + toUpsert.length == assets.length);
return (existing, toUpsert);
}
}
@@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
@@ -25,14 +25,14 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final albums = ref.watch(remoteAlbumsProvider).valueOrNull ?? [];
final albumService = ref.watch(albumServiceProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect(
() {
// Fetch album updates, e.g., cover image
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return null;
@@ -40,7 +40,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
[],
);
void addToAlbum(Album album) async {
void addToAlbum(RemoteAlbum album) async {
final result = await albumService.addAdditionalAssetToAlbum(
assets,
album,
@@ -1,15 +1,15 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AddToAlbumSliverList extends HookConsumerWidget {
/// The asset to add to an album
final List<Album> albums;
final List<Album> sharedAlbums;
final void Function(Album) onAddToAlbum;
final List<RemoteAlbum> albums;
final List<RemoteAlbum> sharedAlbums;
final void Function(RemoteAlbum) onAddToAlbum;
final bool enabled;
const AddToAlbumSliverList({
@@ -46,9 +46,11 @@ class AddToAlbumSliverList extends HookConsumerWidget {
physics: const ClampingScrollPhysics(),
itemCount: sortedSharedAlbums.length,
itemBuilder: (context, index) => AlbumThumbnailListTile(
album: sortedSharedAlbums[index],
album: sortedSharedAlbums[index] as RemoteAlbum,
onTap: enabled
? () => onAddToAlbum(sortedSharedAlbums[index])
? () => onAddToAlbum(
sortedSharedAlbums[index] as RemoteAlbum,
)
: () {},
),
),
@@ -59,7 +61,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = sortedAlbums[offset];
final album = sortedAlbums[offset] as RemoteAlbum;
return AlbumThumbnailListTile(
album: album,
onTap: enabled ? () => onAddToAlbum(album) : () {},
@@ -0,0 +1,82 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
class AlbumSortSelector extends ConsumerWidget {
final List<AlbumSortMode> sortModes;
const AlbumSortSelector({required this.sortModes, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortMode = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
List<PopupMenuEntry<AlbumSortMode>> buildOptions(BuildContext ctx) {
{
return sortModes.map((option) {
final selected = albumSortMode == option;
return PopupMenuItem(
value: option,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Icon(
Icons.check,
color: selected ? context.primaryColor : Colors.transparent,
),
),
Text(
option.label.tr(),
style: TextStyle(
color: selected ? context.primaryColor : null,
fontSize: 14.0,
),
),
],
),
);
}).toList();
}
}
void onSelected(AlbumSortMode mode) {
final isAlreadySelected = albumSortMode == mode;
// Switch direction
if (isAlreadySelected) {
return ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
}
return ref.read(albumSortByOptionsProvider.notifier).changeSortMode(mode);
}
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: buildOptions,
onSelected: onSelected,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(
albumSortIsReverse
? Icons.arrow_downward_rounded
: Icons.arrow_upward_rounded,
size: 14,
color: context.primaryColor,
),
),
Text(
albumSortMode.label.tr(),
style: context.textTheme.labelLarge
?.copyWith(color: context.primaryColor),
),
],
),
);
}
}
@@ -1,12 +1,16 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/extensions/object_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
class AlbumThumbnailCard extends StatelessWidget {
final Album album;
final Function()? onTap;
final bool showAssetCount;
final Icon? emptyThumbnailPlaceholder;
/// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album
@@ -16,11 +20,11 @@ class AlbumThumbnailCard extends StatelessWidget {
super.key,
required this.album,
this.onTap,
this.showAssetCount = true,
this.showOwner = false,
this.emptyThumbnailPlaceholder,
});
final Album album;
@override
Widget build(BuildContext context) {
var isDarkTheme = context.isDarkTheme;
@@ -29,62 +33,6 @@ class AlbumThumbnailCard extends StatelessWidget {
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
buildEmptyThumbnail() {
return Container(
height: cardSize,
width: cardSize,
decoration: BoxDecoration(
color: isDarkTheme ? Colors.grey[800] : Colors.grey[200],
),
child: Center(
child: Icon(
Icons.no_photography,
size: cardSize * .15,
),
),
);
}
buildAlbumThumbnail() => ImmichThumbnail(
asset: album.thumbnail.value,
width: cardSize,
height: cardSize,
);
buildAlbumTextRow() {
// Add the owner name to the subtitle
String? owner;
if (showOwner) {
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
owner = 'album_thumbnail_owned'.tr();
} else if (album.ownerName != null) {
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
}
}
return RichText(
overflow: TextOverflow.fade,
text: TextSpan(
children: [
TextSpan(
text: album.assetCount == 1
? 'album_thumbnail_card_item'
.tr(args: ['${album.assetCount}'])
: 'album_thumbnail_card_items'
.tr(args: ['${album.assetCount}']),
style: context.textTheme.bodyMedium,
),
if (owner != null) const TextSpan(text: ' · '),
if (owner != null)
TextSpan(
text: owner,
style: context.textTheme.bodyMedium,
),
],
),
);
}
return GestureDetector(
onTap: onTap,
child: Flex(
@@ -98,14 +46,46 @@ class AlbumThumbnailCard extends StatelessWidget {
width: cardSize,
height: cardSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
borderRadius:
const BorderRadius.all(Radius.circular(20)),
child: album.thumbnail == null
// Empty placeholder
? Container(
height: cardSize,
width: cardSize,
decoration: BoxDecoration(
border: Border.all(
color: isDarkTheme
? const Color.fromARGB(255, 53, 53, 53)
: const Color.fromARGB(
255,
203,
203,
203,
),
),
color: isDarkTheme
? Colors.grey[900]
: Colors.grey[50],
),
child: Center(
child: emptyThumbnailPlaceholder ??
Icon(
Icons.no_photography,
size: cardSize * .15,
),
),
)
// Thumbnail image
: ImmichThumbnail(
asset: album.thumbnail,
width: cardSize,
height: cardSize,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
padding: const EdgeInsets.only(top: 8.0, left: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
@@ -118,7 +98,14 @@ class AlbumThumbnailCard extends StatelessWidget {
),
),
),
buildAlbumTextRow(),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: _AlbumTextRow(
album: album,
showAssetCount: showAssetCount,
showOwner: showOwner,
),
),
],
),
),
@@ -129,3 +116,55 @@ class AlbumThumbnailCard extends StatelessWidget {
);
}
}
class _AlbumTextRow extends StatelessWidget {
final Album album;
final bool showAssetCount;
/// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album
final bool showOwner;
const _AlbumTextRow({
required this.album,
required this.showAssetCount,
required this.showOwner,
});
@override
Widget build(BuildContext context) {
String? owner;
if (showOwner) {
if (album.tryCast<RemoteAlbum>()?.ownerId ==
Store.get(StoreKey.currentUser).id) {
owner = 'album_thumbnail_owned'.tr();
} else if (album.tryCast<RemoteAlbum>()?.ownerName != null) {
owner = 'album_thumbnail_shared_by'
.tr(args: [(album as RemoteAlbum).ownerName!]);
}
}
return Text.rich(
overflow: TextOverflow.fade,
TextSpan(
children: [
if (showAssetCount)
TextSpan(
text: album.assetCount == 1
? 'album_thumbnail_card_item'
.tr(args: ['${album.assetCount}'])
: 'album_thumbnail_card_items'
.tr(args: ['${album.assetCount}']),
style: context.textTheme.bodyMedium,
),
if (owner != null) const TextSpan(text: ' · '),
TextSpan(
text: owner,
style: context.textTheme.bodyMedium,
),
],
),
);
}
}
@@ -3,8 +3,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -16,7 +16,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
this.onTap,
});
final Album album;
final RemoteAlbum album;
final void Function()? onTap;
@override
@@ -61,7 +61,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
behavior: HitTestBehavior.opaque,
onTap: onTap ??
() {
context.pushRoute(AlbumViewerRoute(albumId: album.id));
context.pushRoute(RemoteAlbumViewerRoute(albumId: album.isarId));
},
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
@@ -70,7 +70,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: album.thumbnail.value == null
child: album.thumbnail == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AlbumViewerEditableTitle extends HookConsumerWidget {
final Album album;
final RemoteAlbum album;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({
super.key,
@@ -0,0 +1,50 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class LibraryNavButton extends StatelessWidget {
final String label;
final IconData icon;
final Function() onClick;
const LibraryNavButton({
super.key,
required this.label,
required this.icon,
required this.onClick,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 180.0,
child: OutlinedButton.icon(
onPressed: onClick,
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
label,
style: TextStyle(
color: context.isDarkTheme
? Colors.white
: Colors.black.withAlpha(200),
),
).tr(),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
backgroundColor:
context.isDarkTheme ? Colors.grey[900] : Colors.grey[50],
side: BorderSide(
color: context.isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
),
alignment: Alignment.centerLeft,
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
}
@@ -5,17 +5,17 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumViewerAppbar extends HookConsumerWidget
class RemoteAlbumViewerAppbar extends HookConsumerWidget
implements PreferredSizeWidget {
const AlbumViewerAppbar({
const RemoteAlbumViewerAppbar({
super.key,
required this.album,
required this.userId,
@@ -25,21 +25,20 @@ class AlbumViewerAppbar extends HookConsumerWidget
required this.onActivities,
});
final Album album;
final RemoteAlbum album;
final String userId;
final FocusNode titleFocusNode;
final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers;
final Function(Album album) onActivities;
final Function(RemoteAlbum album)? onAddPhotos;
final Function(RemoteAlbum album)? onAddUsers;
final Function() onActivities;
@override
Widget build(BuildContext context, WidgetRef ref) {
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
final isProcessing = useProcessingOverlay();
final comments = album.shared
? ref.watch(activityStatisticsProvider(album.remoteId!))
: 0;
final comments =
album.shared ? ref.watch(activityStatisticsProvider(album.id)) : 0;
deleteAlbum() async {
isProcessing.value = true;
@@ -51,7 +50,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
context
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
} else {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
success =
await ref.watch(remoteAlbumsProvider.notifier).deleteAlbum(album);
context
.navigateTo(const TabControllerRoute(children: [LibraryRoute()]));
}
@@ -172,7 +172,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
ListTile(
leading: const Icon(Icons.share_rounded),
onTap: () {
context.pushRoute(SharedLinkEditRoute(albumId: album.remoteId));
context.pushRoute(SharedLinkEditRoute(albumId: album.id));
context.pop();
},
title: const Text(
@@ -228,9 +228,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
Widget buildActivitiesButton() {
return IconButton(
onPressed: () {
onActivities(album);
},
onPressed: () => onActivities(),
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -291,12 +289,11 @@ class AlbumViewerAppbar extends HookConsumerWidget
actions: [
if (album.shared && (album.activityEnabled || comments != 0))
buildActivitiesButton(),
if (album.isRemote)
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],
);
}
@@ -5,10 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@@ -16,7 +16,7 @@ import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@RoutePage()
class AlbumOptionsPage extends HookConsumerWidget {
final Album album;
final RemoteAlbum album;
const AlbumOptionsPage({super.key, required this.album});
@@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
@@ -194,17 +194,17 @@ class CreateAlbumPage extends HookConsumerWidget {
}
createNonSharedAlbum() async {
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
var newAlbum = await ref.watch(remoteAlbumsProvider.notifier).createAlbum(
ref.watch(albumTitleProvider),
selectedAssets.value,
);
if (newAlbum != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(remoteAlbumsProvider.notifier).getRemoteAlbums();
selectedAssets.value = {};
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id));
context.replaceRoute(RemoteAlbumViewerRoute(albumId: newAlbum.isarId));
}
}
+170 -291
View File
@@ -1,12 +1,19 @@
// ignore_for_file: prefer-sliver-prefix
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/sorted_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_sort_selector.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/modules/album/ui/library_nav_button.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_app_bar.dart';
@@ -17,180 +24,37 @@ class LibraryPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider);
final isDarkTheme = context.isDarkTheme;
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
useEffect(
() {
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
return null;
},
[],
);
Widget buildSortButton() {
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: (BuildContext context) {
return AlbumSortMode.values
.map<PopupMenuEntry<AlbumSortMode>>((option) {
final selected = albumSortOption == option;
return PopupMenuItem(
value: option,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Icon(
Icons.check,
color:
selected ? context.primaryColor : Colors.transparent,
),
),
Text(
option.label.tr(),
style: TextStyle(
color: selected ? context.primaryColor : null,
fontSize: 14.0,
),
),
],
),
);
}).toList();
},
onSelected: (AlbumSortMode value) {
final selected = albumSortOption == value;
// Switch direction
if (selected) {
ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
} else {
ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value);
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(
albumSortIsReverse
? Icons.arrow_downward_rounded
: Icons.arrow_upward_rounded,
size: 14,
color: context.primaryColor,
),
),
Text(
albumSortOption.label.tr(),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
],
),
);
}
return Scaffold(
appBar: _LibraryAppBar(),
body: CustomScrollView(
slivers: [
_SilverLibraryNavigationButtons(),
_SilverLibraryRemoteAlbumHeader(),
_SilverLibraryRemoteAlbumGrid(),
_SilverLibraryLocalAlbumHeader(),
_SilverLibraryLocalAlbumGrid(),
],
),
);
}
}
Widget buildCreateAlbumButton() {
return LayoutBuilder(
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
return GestureDetector(
onTap: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
child: Padding(
padding:
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: cardSize,
height: cardSize,
decoration: BoxDecoration(
border: Border.all(
color: isDarkTheme
? const Color.fromARGB(255, 53, 53, 53)
: const Color.fromARGB(255, 203, 203, 203),
),
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 16,
),
child: Text(
'library_page_new_album',
style: context.textTheme.labelLarge,
).tr(),
),
],
),
),
);
},
);
}
Widget buildLibraryNavButton(
String label,
IconData icon,
Function() onClick,
) {
return Expanded(
child: OutlinedButton.icon(
onPressed: onClick,
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
label,
style: TextStyle(
color: context.isDarkTheme
? Colors.white
: Colors.black.withAlpha(200),
),
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
backgroundColor: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
side: BorderSide(
color: isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
),
alignment: Alignment.centerLeft,
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
final remote = albums.where((a) => a.isRemote).toList();
final sorted = albumSortOption.sortFn(remote, albumSortIsReverse);
final local = albums.where((a) => a.isLocal).toList();
Widget? shareTrashButton() {
return trashEnabled
class _LibraryAppBar extends ImmichAppBar {
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
return ImmichAppBar(
action: trashEnabled
? InkWell(
onTap: () => context.pushRoute(const TrashRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
@@ -199,133 +63,148 @@ class LibraryPage extends HookConsumerWidget {
size: 25,
),
)
: null;
}
: null,
);
}
}
return Scaffold(
appBar: ImmichAppBar(
action: shareTrashButton(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildLibraryNavButton(
"library_page_favorites".tr(), Icons.favorite_border, () {
context.navigateTo(const FavoritesRoute());
}),
const SizedBox(width: 12.0),
buildLibraryNavButton(
"library_page_archive".tr(), Icons.archive_outlined, () {
context.navigateTo(const ArchiveRoute());
}),
],
),
class _SilverLibraryNavigationButtons extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
LibraryNavButton(
label: "library_page_favorites",
icon: Icons.favorite_border,
onClick: () => context.navigateTo(const FavoritesRoute()),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
buildSortButton(),
],
),
LibraryNavButton(
label: "library_page_archive",
icon: Icons.archive_outlined,
onClick: () => context.navigateTo(const ArchiveRoute()),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: sorted.length + 1,
(context, index) {
if (index == 0) {
return buildCreateAlbumButton();
}
return AlbumThumbnailCard(
album: sorted[index - 1],
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: sorted[index - 1].id,
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_device_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: local.length,
(context, index) => AlbumThumbnailCard(
album: local[index],
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: local[index].id,
),
),
),
),
),
),
],
],
),
),
);
}
}
class _SilverLibraryRemoteAlbumHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
const AlbumSortSelector(sortModes: AlbumSortMode.values),
],
),
),
);
}
}
class _SilverLibraryRemoteAlbumGrid extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final remoteAlbums = ref.watch(remoteAlbumsProvider);
final remoteSorted =
ref.watch(sortedAlbumProvider(remoteAlbums.valueOrNull ?? []));
return SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
itemCount: remoteSorted.length + 1,
itemBuilder: (ctx, index) {
if (index == 0) {
Album placeholder = LocalAlbum(
id: 'Placeholder',
name: 'library_page_new_album'.tr(),
modifiedAt: DateTime.now(),
);
return AlbumThumbnailCard(
album: placeholder,
showAssetCount: false,
emptyThumbnailPlaceholder: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
onTap: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
);
}
final remoteAlbum = remoteSorted[index - 1];
return AlbumThumbnailCard(
album: remoteAlbum,
onTap: () => context
.pushRoute(RemoteAlbumViewerRoute(albumId: remoteAlbum.isarId)),
);
},
),
);
}
}
class _SilverLibraryLocalAlbumHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
child: Text(
'library_page_device_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
);
}
}
class _SilverLibraryLocalAlbumGrid extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final localAlbums = ref.watch(localAlbumsProvider).valueOrNull ?? [];
final localWithoutRecents =
localAlbums.where((e) => e.id != LocalAlbum.isAllId).toList();
return SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
itemCount: localWithoutRecents.length,
itemBuilder: (ctx, index) => AlbumThumbnailCard(
album: localWithoutRecents[index],
onTap: () => context.pushRoute(
LocalAlbumViewerRoute(album: localWithoutRecents[index]),
),
),
),
);
}
@@ -0,0 +1,37 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
@RoutePage()
class LocalAlbumViewerPage extends HookConsumerWidget {
final LocalAlbum album;
final bool selectEnabled;
const LocalAlbumViewerPage({
super.key,
required this.album,
this.selectEnabled = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(),
body: MultiselectGrid(
topWidget: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
child: Text(
album.name,
style: context.textTheme.headlineMedium,
),
),
renderListProvider: localAlbumRenderlistProvider(album.isarId),
selectedEnabled: selectEnabled,
),
);
}
}
@@ -8,6 +8,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
@@ -17,9 +18,8 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/modules/album/ui/remote_album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
@@ -28,15 +28,15 @@ import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@RoutePage()
class AlbumViewerPage extends HookConsumerWidget {
class RemoteAlbumViewerPage extends HookConsumerWidget {
final int albumId;
const AlbumViewerPage({super.key, required this.albumId});
const RemoteAlbumViewerPage({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(albumWatcher(albumId));
final album = ref.watch(remoteAlbumWatcher(albumId));
// Listen provider to prevent autoDispose when navigating to other routes from within the viewer page
ref.listen(currentAlbumProvider, (_, __) {});
album.whenData(
@@ -67,7 +67,7 @@ class AlbumViewerPage extends HookConsumerWidget {
/// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected.
void onAddPhotosPressed(Album albumInfo) async {
void onAddPhotosPressed(RemoteAlbum albumInfo) async {
AssetSelectionPageResult? returnPayload =
await context.pushRoute<AssetSelectionPageResult?>(
AssetSelectionRoute(
@@ -90,7 +90,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
void onAddUsersPressed(Album album) async {
void onAddUsersPressed(RemoteAlbum album) async {
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
SelectAdditionalUserForSharingRoute(album: album),
);
@@ -106,7 +106,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
Widget buildControlButton(Album album) {
Widget buildControlButton(RemoteAlbum album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
child: SizedBox(
@@ -131,16 +131,16 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildTitle(Album album) {
Widget buildTitle(RemoteAlbum album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
child: userId == album.ownerId && album.isRemote
child: userId == album.ownerId
? AlbumViewerEditableTitle(
album: album,
titleFocusNode: titleFocusNode,
)
: Padding(
padding: const EdgeInsets.only(left: 8.0),
padding: const EdgeInsets.only(left: 8.0, bottom: 24),
child: Text(
album.name,
style: context.textTheme.headlineMedium,
@@ -149,7 +149,7 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildAlbumDateRange(Album album) {
Widget buildAlbumDateRange(RemoteAlbum album) {
final DateTime? startDate = album.startDate;
final DateTime? endDate = album.endDate;
@@ -183,7 +183,7 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildSharedUserIconsRow(Album album) {
Widget buildSharedUserIconsRow(RemoteAlbum album) {
return GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
child: SizedBox(
@@ -207,49 +207,42 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildHeader(Album album) {
Widget buildHeader(RemoteAlbum album) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
if (album.shared) buildSharedUserIconsRow(album),
if (album.assets.isNotEmpty) buildAlbumDateRange(album),
if (album.shared && album.sharedUsers.isNotEmpty)
buildSharedUserIconsRow(album),
],
);
}
onActivitiesPressed(Album album) {
if (album.remoteId != null) {
context.pushRoute(
const ActivitiesRoute(),
);
}
}
return Scaffold(
appBar: ref.watch(multiselectProvider)
? null
: album.when(
data: (data) => AlbumViewerAppbar(
data: (data) => RemoteAlbumViewerAppbar(
titleFocusNode: titleFocusNode,
album: data,
userId: userId,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
onActivities: () => context.pushRoute(const ActivitiesRoute()),
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
body: album.widgetWhen(
onData: (data) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
renderListProvider: remoteAlbumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
buildControlButton(data),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
@@ -5,14 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@RoutePage<List<String>?>()
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
final Album album;
final RemoteAlbum album;
const SelectAdditionalUserForSharingPage({super.key, required this.album});
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
@@ -51,7 +52,9 @@ class SharingPage extends HookConsumerWidget {
album: sharedAlbums[index],
showOwner: true,
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sharedAlbums[index].id),
RemoteAlbumViewerRoute(
albumId: sharedAlbums[index].isarId,
),
),
);
},
@@ -65,7 +68,7 @@ class SharingPage extends HookConsumerWidget {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final album = sharedAlbums[index];
final album = sharedAlbums[index] as RemoteAlbum;
final isOwner = album.ownerId == userId;
return ListTile(
@@ -73,7 +76,7 @@ class SharingPage extends HookConsumerWidget {
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichThumbnail(
asset: album.thumbnail.value,
asset: album.thumbnail,
width: 60,
height: 60,
),
@@ -99,8 +102,11 @@ class SharingPage extends HookConsumerWidget {
style: context.textTheme.bodyMedium,
)
: null,
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)),
onTap: () => context.pushRoute(
RemoteAlbumViewerRoute(
albumId: sharedAlbums[index].isarId,
),
),
);
},
childCount: sharedAlbums.length,
@@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/providers/local_album_service.provider.dart';
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -14,7 +15,7 @@ import 'package:immich_mobile/shared/ui/share_dialog.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService;
final ShareService _shareService;
final AlbumService _albumService;
final LocalAlbumService _albumService;
ImageViewerStateNotifier(
this._imageViewerService,
@@ -83,6 +84,6 @@ final imageViewerStateProvider =
((ref) => ImageViewerStateNotifier(
ref.watch(imageViewerServiceProvider),
ref.watch(shareServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(localAlbumServiceProvider),
)),
);
@@ -1,7 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/object_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
@@ -39,11 +41,15 @@ class TopControlAppBar extends HookConsumerWidget {
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider);
final comments = album != null &&
album.remoteId != null &&
asset.remoteId != null
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
: 0;
final comments =
album != null && album is RemoteAlbum && asset.remoteId != null
? ref.watch(
activityStatisticsProvider(
album.tryCast<RemoteAlbum>()!.id,
asset.remoteId,
),
)
: 0;
Widget buildFavoriteButton(a) {
return IconButton(
@@ -171,7 +177,8 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(),
if (album != null && album.shared) buildActivitiesButton(),
if (album != null && album is RemoteAlbum && album.shared)
buildActivitiesButton(),
buildMoreInfoButton(),
],
);
@@ -10,6 +10,7 @@ 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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
@@ -351,7 +352,7 @@ class GalleryViewerPage extends HookConsumerWidget {
}
handleActivities() {
if (album != null && album.shared && album.remoteId != null) {
if (album != null && album is RemoteAlbum && album.shared) {
context.pushRoute(const ActivitiesRoute());
}
}
@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -19,7 +18,6 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -364,30 +362,17 @@ class BackgroundService {
);
if (backupOk) {
await Store.delete(StoreKey.backupFailedSince);
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
backupAlbums.sortBy((e) => e.id);
db.writeTxnSync(() {
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
? a.lastBackup
: b.lastBackup;
toUpsert.add(a);
return true;
},
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
);
db.backupAlbums.deleteAllSync(toDelete);
db.backupAlbums.putAllSync(toUpsert);
});
// TODO: update album specific last backup time
final backupAlbums = await db.backupAlbums
.filter()
.not()
.selectionEqualTo(BackupSelection.none)
.findAll();
List<BackupAlbum> selectedAlbums = backupAlbums.map((e) {
e.lastBackup = DateTime.now();
return e;
}).toList();
await db.writeTxn(() => db.backupAlbums.putAll(selectedAlbums));
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
Store.put(StoreKey.backupFailedSince, DateTime.now());
return false;
@@ -1,48 +0,0 @@
import 'dart:typed_data';
import 'package:photo_manager/photo_manager.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final DateTime? lastBackup;
final Uint8List? thumbnailData;
AvailableAlbum({
required this.albumEntity,
this.lastBackup,
this.thumbnailData,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
DateTime? lastBackup,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
lastBackup: lastBackup ?? this.lastBackup,
thumbnailData: thumbnailData ?? this.thumbnailData,
);
}
String get name => albumEntity.name;
Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => albumEntity.id;
bool get isAll => albumEntity.isAll;
@override
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum && other.albumEntity == albumEntity;
}
@override
int get hashCode => albumEntity.hashCode;
}
@@ -1,22 +1,58 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'backup_album.model.g.dart';
@Collection(inheritance: false)
class BackupAlbum {
String id;
DateTime lastBackup;
@Enumerated(EnumType.ordinal)
BackupSelection selection;
BackupAlbum(this.id, this.lastBackup, this.selection);
Id get isarId => fastHash(id);
}
enum BackupSelection {
none,
select,
exclude;
}
@Collection(inheritance: false)
class BackupAlbum {
Id get isarId => fastHash(id);
String id;
DateTime lastBackup;
@enumerated
BackupSelection selection;
static const albumLinkId = 'album';
final album = IsarLink<LocalAlbum>();
BackupAlbum({
required this.id,
required this.lastBackup,
this.selection = BackupSelection.none,
});
BackupAlbum copyWith({
String? id,
DateTime? lastBackup,
BackupSelection? selection,
}) {
return BackupAlbum(
id: id ?? this.id,
lastBackup: lastBackup ?? this.lastBackup,
selection: selection ?? this.selection,
);
}
@override
String toString() =>
'BackupAlbum(id: $id, lastBackup: $lastBackup, selection: $selection)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BackupAlbum &&
other.id == id &&
other.lastBackup == lastBackup &&
other.selection == selection;
}
@override
int get hashCode => id.hashCode ^ lastBackup.hashCode ^ selection.hashCode;
}
+138 -16
View File
@@ -17,18 +17,23 @@ const BackupAlbumSchema = CollectionSchema(
name: r'BackupAlbum',
id: 8308487201128361847,
properties: {
r'id': PropertySchema(
r'hashCode': PropertySchema(
id: 0,
name: r'hashCode',
type: IsarType.long,
),
r'id': PropertySchema(
id: 1,
name: r'id',
type: IsarType.string,
),
r'lastBackup': PropertySchema(
id: 1,
id: 2,
name: r'lastBackup',
type: IsarType.dateTime,
),
r'selection': PropertySchema(
id: 2,
id: 3,
name: r'selection',
type: IsarType.byte,
enumMap: _BackupAlbumselectionEnumValueMap,
@@ -40,7 +45,14 @@ const BackupAlbumSchema = CollectionSchema(
deserializeProp: _backupAlbumDeserializeProp,
idName: r'isarId',
indexes: {},
links: {},
links: {
r'album': LinkSchema(
id: 4803574038667272895,
name: r'album',
target: r'LocalAlbum',
single: true,
)
},
embeddedSchemas: {},
getId: _backupAlbumGetId,
getLinks: _backupAlbumGetLinks,
@@ -64,9 +76,10 @@ void _backupAlbumSerialize(
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.id);
writer.writeDateTime(offsets[1], object.lastBackup);
writer.writeByte(offsets[2], object.selection.index);
writer.writeLong(offsets[0], object.hashCode);
writer.writeString(offsets[1], object.id);
writer.writeDateTime(offsets[2], object.lastBackup);
writer.writeByte(offsets[3], object.selection.index);
}
BackupAlbum _backupAlbumDeserialize(
@@ -76,10 +89,11 @@ BackupAlbum _backupAlbumDeserialize(
Map<Type, List<int>> allOffsets,
) {
final object = BackupAlbum(
reader.readString(offsets[0]),
reader.readDateTime(offsets[1]),
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
BackupSelection.none,
id: reader.readString(offsets[1]),
lastBackup: reader.readDateTime(offsets[2]),
selection:
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[3])] ??
BackupSelection.none,
);
return object;
}
@@ -92,10 +106,12 @@ P _backupAlbumDeserializeProp<P>(
) {
switch (propertyId) {
case 0:
return (reader.readString(offset)) as P;
return (reader.readLong(offset)) as P;
case 1:
return (reader.readDateTime(offset)) as P;
return (reader.readString(offset)) as P;
case 2:
return (reader.readDateTime(offset)) as P;
case 3:
return (_BackupAlbumselectionValueEnumMap[
reader.readByteOrNull(offset)] ??
BackupSelection.none) as P;
@@ -120,11 +136,13 @@ Id _backupAlbumGetId(BackupAlbum object) {
}
List<IsarLinkBase<dynamic>> _backupAlbumGetLinks(BackupAlbum object) {
return [];
return [object.album];
}
void _backupAlbumAttach(
IsarCollection<dynamic> col, Id id, BackupAlbum object) {}
IsarCollection<dynamic> col, Id id, BackupAlbum object) {
object.album.attach(col, col.isar.collection<LocalAlbum>(), r'album', id);
}
extension BackupAlbumQueryWhereSort
on QueryBuilder<BackupAlbum, BackupAlbum, QWhere> {
@@ -209,6 +227,61 @@ extension BackupAlbumQueryWhere
extension BackupAlbumQueryFilter
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> hashCodeEqualTo(
int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'hashCode',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
hashCodeGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'hashCode',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
hashCodeLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'hashCode',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> hashCodeBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'hashCode',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEqualTo(
String value, {
bool caseSensitive = true,
@@ -510,10 +583,35 @@ extension BackupAlbumQueryObject
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
extension BackupAlbumQueryLinks
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> album(
FilterQuery<LocalAlbum> q) {
return QueryBuilder.apply(this, (query) {
return query.link(q, r'album');
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> albumIsNull() {
return QueryBuilder.apply(this, (query) {
return query.linkLength(r'album', 0, true, 0, true);
});
}
}
extension BackupAlbumQuerySortBy
on QueryBuilder<BackupAlbum, BackupAlbum, QSortBy> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByHashCode() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByHashCodeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
@@ -553,6 +651,18 @@ extension BackupAlbumQuerySortBy
extension BackupAlbumQuerySortThenBy
on QueryBuilder<BackupAlbum, BackupAlbum, QSortThenBy> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByHashCode() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByHashCodeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
@@ -604,6 +714,12 @@ extension BackupAlbumQuerySortThenBy
extension BackupAlbumQueryWhereDistinct
on QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> {
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByHashCode() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'hashCode');
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctById(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -632,6 +748,12 @@ extension BackupAlbumQueryProperty
});
}
QueryBuilder<BackupAlbum, int, QQueryOperations> hashCodeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'hashCode');
});
}
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
@@ -0,0 +1,39 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
class BackupAlbumState {
final List<BackupAlbum> selectedBackupAlbums;
final List<BackupAlbum> excludedBackupAlbums;
const BackupAlbumState({
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
});
BackupAlbumState copyWith({
List<BackupAlbum>? selectedBackupAlbums,
List<BackupAlbum>? excludedBackupAlbums,
}) {
return BackupAlbumState(
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
);
}
@override
String toString() =>
'BackupAlbumState(selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums)';
@override
bool operator ==(covariant BackupAlbumState other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
listEquals(other.excludedBackupAlbums, excludedBackupAlbums);
}
@override
int get hashCode =>
selectedBackupAlbums.hashCode ^ excludedBackupAlbums.hashCode;
}
@@ -2,9 +2,8 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
@@ -30,16 +29,11 @@ class BackUpState {
final bool backupRequireCharging;
final int backupTriggerDelay;
/// All available albums on the device
final List<AvailableAlbum> availableAlbums;
final Set<AvailableAlbum> selectedBackupAlbums;
final Set<AvailableAlbum> excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums
final Set<AssetEntity> allUniqueAssets;
final Set<Asset> allUniqueAssets;
/// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds;
final int backedUpAssetsCount;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
@@ -56,11 +50,8 @@ class BackUpState {
required this.backupRequireWifi,
required this.backupRequireCharging,
required this.backupTriggerDelay,
required this.availableAlbums,
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
required this.allUniqueAssets,
required this.selectedAlbumsBackupAssetsIds,
required this.backedUpAssetsCount,
required this.currentUploadAsset,
});
@@ -76,11 +67,8 @@ class BackUpState {
bool? backupRequireWifi,
bool? backupRequireCharging,
int? backupTriggerDelay,
List<AvailableAlbum>? availableAlbums,
Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
Set<Asset>? allUniqueAssets,
int? backedUpAssetsCount,
CurrentUploadAsset? currentUploadAsset,
}) {
return BackUpState(
@@ -97,19 +85,15 @@ class BackUpState {
backupRequireCharging:
backupRequireCharging ?? this.backupRequireCharging,
backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay,
availableAlbums: availableAlbums ?? this.availableAlbums,
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
selectedAlbumsBackupAssetsIds:
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
backedUpAssetsCount: backedUpAssetsCount ?? this.backedUpAssetsCount,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
);
}
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, allUniqueAssets: $allUniqueAssets, backedUpAssetsCount: $backedUpAssetsCount, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -128,14 +112,8 @@ class BackUpState {
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
other.backupTriggerDelay == backupTriggerDelay &&
collectionEquals(other.availableAlbums, availableAlbums) &&
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
collectionEquals(
other.selectedAlbumsBackupAssetsIds,
selectedAlbumsBackupAssetsIds,
) &&
other.backedUpAssetsCount == backedUpAssetsCount &&
other.currentUploadAsset == currentUploadAsset;
}
@@ -152,11 +130,8 @@ class BackUpState {
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
backupTriggerDelay.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^
allUniqueAssets.hashCode ^
selectedAlbumsBackupAssetsIds.hashCode ^
backedUpAssetsCount.hashCode ^
currentUploadAsset.hashCode;
}
}
@@ -0,0 +1,32 @@
import 'package:collection/collection.dart';
class DeviceAssetState {
final List<String> assetIdsForBackup;
const DeviceAssetState({
required this.assetIdsForBackup,
});
DeviceAssetState copyWith({
List<String>? assetIdsForBackup,
}) {
return DeviceAssetState(
assetIdsForBackup: assetIdsForBackup ?? this.assetIdsForBackup,
);
}
@override
String toString() =>
'DeviceAssetState(assetIdsForBackup: $assetIdsForBackup)';
@override
bool operator ==(covariant DeviceAssetState other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assetIdsForBackup, assetIdsForBackup);
}
@override
int get hashCode => assetIdsForBackup.hashCode;
}
@@ -1,26 +1,25 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -53,11 +52,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
diskUse: "0",
diskUsagePercentage: 0,
),
availableAlbums: const [],
selectedBackupAlbums: const {},
excludedBackupAlbums: const {},
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
backedUpAssetsCount: 0,
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
@@ -78,61 +74,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final Isar _db;
final Ref ref;
///
/// UI INTERACTION
///
/// Album selection
/// Due to the overlapping assets across multiple albums on the device
/// We have method to include and exclude albums
/// The total unique assets will be used for backing mechanism
///
void addAlbumForBackup(AvailableAlbum album) {
if (state.excludedBackupAlbums.contains(album)) {
removeExcludedAlbumForBackup(album);
}
state = state
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
}
void addExcludedAlbumForBackup(AvailableAlbum album) {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
state = state
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
}
void removeAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
currentSelectedAlbums.removeWhere((a) => a == album);
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
}
void removeExcludedAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
currentExcludedAlbums.removeWhere((a) => a == album);
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
}
Future<void> backupAlbumSelectionDone() {
if (state.selectedBackupAlbums.isEmpty) {
// disable any backup
cancelBackup();
setAutoBackup(false);
configureBackgroundBackup(
enabled: false,
onError: (msg) {},
onBatteryInfo: () {},
);
}
return _updateBackupAssetCount();
}
void setAutoBackup(bool enabled) {
Store.put(StoreKey.autoBackup, enabled);
state = state.copyWith(autoBackup: enabled);
@@ -204,102 +145,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
///
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
/// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
///
Future<void> _getBackupAlbumsInfo() async {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true,
type: RequestType.common,
);
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
final assetCountInAlbum = await album.assetCountAsync;
if (assetCountInAlbum > 0) {
final assetList = await album.getAssetListPaged(page: 0, size: 1);
// 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,
stack,
);
}
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
}
}
state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums =
await _backupService.excludedAlbumsQuery().findAll();
final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll();
// Generate AssetPathEntity from id to add to local state
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Selected album not found');
}
}
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Excluded album not found');
}
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
log.info(
"_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums",
);
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}
///
/// From all the selected and albums assets
/// Find the assets that are not overlapping between the two sets
@@ -307,26 +152,19 @@ class BackupNotifier extends StateNotifier<BackUpState> {
///
Future<void> _updateBackupAssetCount() async {
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {};
final Set<AssetEntity> assetsFromExcludedAlbums = {};
final backupAlbums = await ref.read(backupAlbumsProvider.future);
final Set<Asset> assetsFromSelectedAlbums = {};
final Set<Asset> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromSelectedAlbums.addAll(assets);
for (final selected in backupAlbums.selectedBackupAlbums) {
assetsFromSelectedAlbums.addAll(selected.album.value?.assets ?? []);
}
for (final album in state.excludedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets);
for (final excluded in backupAlbums.excludedBackupAlbums) {
assetsFromExcludedAlbums.addAll(excluded.album.value?.assets ?? []);
}
final Set<AssetEntity> allUniqueAssets =
final Set<Asset> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
@@ -336,34 +174,30 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
allUniqueAssets.map((e) => e.localId).nonNulls.toSet();
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
);
allUniqueAssets
.removeWhere((asset) => duplicatedAssetIds.contains(asset.localId));
if (allUniqueAssets.isEmpty) {
log.info("No assets are selected for back up");
log.fine("No assets are selected for back up");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
backedUpAssetsCount: selectedAlbumsBackupAssets.length,
);
} else {
state = state.copyWith(
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
backedUpAssetsCount: selectedAlbumsBackupAssets.length,
);
}
// Save to persistent storage
await _updatePersistentAlbumsSelection();
}
/// Get all necessary information for calculating the available albums,
@@ -378,7 +212,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await updateServerInfo();
await _updateBackupAssetCount();
} else {
@@ -386,40 +219,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
/// Save user selection of selected albums and excluded albums to database
Future<void> _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
final selected = state.selectedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
);
final excluded = state.excludedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
);
final backupAlbums = selected.followedBy(excluded).toList();
backupAlbums.sortBy((e) => e.id);
return _db.writeTxn(() async {
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
b.lastBackup =
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
toUpsert.add(b);
return true;
},
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
onlySecond: (BackupAlbum b) => toUpsert.add(b),
);
await _db.backupAlbums.deleteAll(toDelete);
await _db.backupAlbums.putAll(toUpsert);
});
}
/// Invoke backup process
Future<void> startBackupProcess() async {
debugPrint("Start backup process");
@@ -473,12 +272,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
void setAvailableAlbums(availableAlbums) {
state = state.copyWith(
availableAlbums: availableAlbums,
);
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
@@ -506,39 +299,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (isDuplicated) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.where((asset) => asset.localId != deviceAssetId)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId,
},
backedUpAssetsCount: state.backedUpAssetsCount + 1,
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);
}
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup =
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
(v, e) => e.isAfter(v) ? e : v,
);
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
excludedBackupAlbums: state.excludedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
backupProgress: BackUpProgressEnum.done,
progressInPercentage: 0.0,
);
_updatePersistentAlbumsSelection();
}
updateServerInfo();
}
@@ -595,35 +365,19 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
Future<void> resumeBackup() async {
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll();
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.exclude)
.findAll();
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (selectedAlbums.isNotEmpty) {
selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums,
selectedBackupAlbums,
);
}
if (excludedAlbums.isNotEmpty) {
excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums,
excludedBackupAlbums,
);
}
final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(
backupProgress: BackUpProgressEnum.inBackground,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
// TODO: update album specific last backup time
final backupAlbums = await ref.read(backupAlbumsProvider.future);
List<BackupAlbum> selectedAlbums = backupAlbums.selectedBackupAlbums
.followedBy(backupAlbums.excludedBackupAlbums)
.map((e) {
e.lastBackup = DateTime.now();
return e;
}).toList();
await _db.writeTxn(() => _db.backupAlbums.putAll(selectedAlbums));
// assumes the background service is currently running
// if true, waits until it has stopped to start the backup
final bool hasLock = await _backgroundService.acquireLock();
@@ -633,26 +387,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return _resumeBackup();
}
Set<AvailableAlbum> _updateAlbumsBackupTime(
Set<AvailableAlbum> albums,
List<BackupAlbum> backupAlbums,
) {
Set<AvailableAlbum> result = {};
for (BackupAlbum ba in backupAlbums) {
try {
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
result.add(a.copyWith(lastBackup: ba.lastBackup));
} on StateError {
log.severe(
"[_updateAlbumBackupTime] failed to find album in state",
"State Error",
StackTrace.current,
);
}
}
return result;
}
Future<void> notifyBackgroundServiceCanRun() async {
const allowedStates = [
AppStateEnum.inactive,
@@ -0,0 +1,202 @@
import 'package:immich_mobile/extensions/album_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'backup_album.provider.g.dart';
@riverpod
class BackupAlbums extends _$BackupAlbums {
final Logger _logger = Logger("BackupAlbumsProvider");
@override
Future<BackupAlbumState> build() async {
final db = ref.read(dbProvider);
return BackupAlbumState(
selectedBackupAlbums: await db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll(),
excludedBackupAlbums: await db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.exclude)
.findAll(),
);
}
Future<void> addBackupAlbum(
LocalAlbum album,
BackupSelection selection,
) async {
final db = ref.read(dbProvider);
final albumInDB =
await db.backupAlbums.filter().idEqualTo(album.id).findFirst();
final backupAlbum = albumInDB ??
BackupAlbum(
id: album.id,
lastBackup: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
selection: selection,
);
backupAlbum.selection = selection;
backupAlbum.album.value = album;
final assets = await _updateDeviceAssetsToSelection(album, selection);
await db.writeTxn(() async {
await db.backupAlbums.store(backupAlbum);
await db.deviceAssets.putAll(assets);
});
ref.invalidateSelf();
}
Future<void> syncWithLocalAlbum(LocalAlbum album) async {
final db = ref.read(dbProvider);
final albumInDB =
await db.backupAlbums.filter().idEqualTo(album.id).findFirst();
if (albumInDB == null) {
_logger.fine("No backup album for local album - ${album.name}");
return;
}
albumInDB.album.value = album;
final assets =
await _updateDeviceAssetsToSelection(album, albumInDB.selection);
await db.writeTxn(() async {
await db.backupAlbums.store(albumInDB);
await db.deviceAssets.putAll(assets);
});
ref.invalidateSelf();
}
Future<void> _updateAlbumSelection(
LocalAlbum localAlbum,
BackupSelection selection,
) async {
await localAlbum.backup.load();
final backupAlbum = localAlbum.backup.value;
if (backupAlbum == null) {
return addBackupAlbum(localAlbum, selection);
}
final db = ref.read(dbProvider);
backupAlbum.selection = selection;
final assets = await _updateDeviceAssetsToSelection(localAlbum, selection);
await db.writeTxn(() async {
await db.backupAlbums.store(backupAlbum);
await db.deviceAssets.putAll(assets);
});
ref.invalidateSelf();
}
Future<void> selectAlbumForBackup(LocalAlbum album) =>
_updateAlbumSelection(album, BackupSelection.select);
Future<void> excludeAlbumFromBackup(LocalAlbum album) =>
_updateAlbumSelection(album, BackupSelection.exclude);
Future<void> deSelectAlbum(LocalAlbum album) =>
_updateAlbumSelection(album, BackupSelection.none);
Future<List<LocalAlbum>> _getAllLocalAlbumWithAsset(Asset asset) async {
return await ref
.read(dbProvider)
.localAlbums
.filter()
.assets((q) => q.idEqualTo(asset.id))
.findAll();
}
Future<List<DeviceAsset>> _updateDeviceAssetsToSelection(
LocalAlbum album,
BackupSelection selection,
) async {
await album.assets.load();
final assets = album.assets.toList();
final updatedAssets = <DeviceAsset>[];
for (final asset in assets) {
if (!asset.isLocal) {
_logger.warning("Local id not available for asset ID - ${asset.id}");
continue;
}
final deviceAsset = await ref
.read(dbProvider)
.deviceAssets
.where()
.idEqualTo(asset.localId!)
.findFirst();
if (deviceAsset == null) {
_logger.warning(
"Device asset not available for local asset ID - ${asset.id}",
);
continue;
}
// Exclude takes priority
if (selection == BackupSelection.exclude) {
deviceAsset.backupSelection = selection;
} else if (selection == BackupSelection.select) {
bool shouldExclude = false;
final localAlbums = await _getAllLocalAlbumWithAsset(asset);
for (final a in localAlbums) {
await a.backup.load();
// Check if there is any other excluded albums in which the asset is present
if (a.backup.value?.selection == BackupSelection.exclude &&
a.id != album.id) {
shouldExclude = true;
break;
}
}
// Force exclude ignoring selection if asset is part of another excluded album
deviceAsset.backupSelection =
shouldExclude ? BackupSelection.exclude : BackupSelection.select;
} else if (selection == BackupSelection.none) {
bool setToNone = true;
BackupSelection? oldSelection;
final localAlbums = await _getAllLocalAlbumWithAsset(asset);
for (final a in localAlbums) {
await a.backup.load();
// Check if there is any other albums in which the asset is present
if (a.backup.value?.selection != BackupSelection.none &&
a.id != album.id) {
setToNone = false;
oldSelection = a.backup.value?.selection;
break;
}
}
// Only set to none when the asset is not part of any other selected or excluded albums
if (setToNone) {
deviceAsset.backupSelection = BackupSelection.none;
} else if (oldSelection != null) {
deviceAsset.backupSelection = oldSelection;
}
}
updatedAssets.add(deviceAsset);
}
if (updatedAssets.isNotEmpty) {
ref.invalidate(deviceAssetsProvider);
}
return updatedAssets;
}
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$backupAlbumsHash() => r'b4fcbf7b0bb6c6ee3323165470cf8d4758185487';
/// See also [BackupAlbums].
@ProviderFor(BackupAlbums)
final backupAlbumsProvider =
AutoDisposeAsyncNotifierProvider<BackupAlbums, BackupAlbumState>.internal(
BackupAlbums.new,
name: r'backupAlbumsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$backupAlbumsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$BackupAlbums = AutoDisposeAsyncNotifier<BackupAlbumState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,23 @@
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/device_album_state.model.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'device_assets.provider.g.dart';
@riverpod
class DeviceAssets extends _$DeviceAssets {
@override
Future<DeviceAssetState> build() async {
final db = ref.read(dbProvider);
return DeviceAssetState(
assetIdsForBackup: await db.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.idProperty()
.findAll(),
);
}
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'device_assets.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$deviceAssetsHash() => r'e6409a8971bbaed64e18672cb1ff8e980dd3fda5';
/// See also [DeviceAssets].
@ProviderFor(DeviceAssets)
final deviceAssetsProvider =
AutoDisposeAsyncNotifierProvider<DeviceAssets, DeviceAssetState>.internal(
DeviceAssets.new,
name: r'deviceAssetsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$deviceAssetsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$DeviceAssets = AutoDisposeAsyncNotifier<DeviceAssetState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -1,223 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData;
final AvailableAlbum albumInfo;
const AlbumInfoCard({super.key, this.imageData, required this.albumInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
final isDarkTheme = context.isDarkTheme;
ColorFilter selectedFilter = ColorFilter.mode(
context.primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
buildSelectedTextBox() {
if (isSelected) {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: Text(
"album_info_card_backup_album_included",
style: TextStyle(
fontSize: 10,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: context.primaryColor,
);
} else if (isExcluded) {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: Text(
"album_info_card_backup_album_excluded",
style: TextStyle(
fontSize: 10,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: Colors.red[300],
);
}
return const SizedBox();
}
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
return GestureDetector(
onTap: () {
HapticFeedback.selectionClick();
if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
}
},
onDoubleTap: () {
HapticFeedback.selectionClick();
if (isExcluded) {
// Remove from exclude album list
ref
.read(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else {
// Add to exclude album list
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref
.read(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
}
},
child: Card(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // if you need this
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 37, 35, 35)
: const Color(0xFFC9C9C9),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
ColorFiltered(
colorFilter: buildImageFilter(),
child: Image(
width: double.infinity,
height: double.infinity,
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
'assets/immich-logo-no-outline.png',
) as ImageProvider,
fit: BoxFit.cover,
),
),
Positioned(
bottom: 10,
right: 25,
child: buildSelectedTextBox(),
),
],
),
),
Padding(
padding: const EdgeInsets.only(
left: 25,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
albumInfo.name,
style: TextStyle(
fontSize: 14,
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data.toString() +
(albumInfo.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: albumInfo.assetCount,
),
),
],
),
),
IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},
icon: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 24,
),
splashRadius: 25,
),
],
),
),
],
),
),
);
}
}
@@ -1,151 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class AlbumInfoListTile extends HookConsumerWidget {
final Uint8List? imageData;
final AvailableAlbum albumInfo;
const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
ColorFilter selectedFilter = ColorFilter.mode(
context.primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
var assetCount = useState(0);
useEffect(
() {
albumInfo.assetCount.then((value) => assetCount.value = value);
return null;
},
[albumInfo],
);
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
buildTileColor() {
if (isSelected) {
return context.isDarkTheme
? context.primaryColor.withAlpha(100)
: context.primaryColor.withAlpha(25);
} else if (isExcluded) {
return context.isDarkTheme
? Colors.red[300]?.withAlpha(150)
: Colors.red[100]?.withAlpha(150);
} else {
return Colors.transparent;
}
}
return GestureDetector(
onDoubleTap: () {
HapticFeedback.selectionClick();
if (isExcluded) {
// Remove from exclude album list
ref
.read(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else {
// Add to exclude album list
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref
.read(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
}
},
child: ListTile(
tileColor: buildTileColor(),
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
onTap: () {
HapticFeedback.selectionClick();
if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
}
},
leading: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 80,
width: 80,
child: ColorFiltered(
colorFilter: buildImageFilter(),
child: Image(
width: double.infinity,
height: double.infinity,
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
'assets/immich-logo-no-outline.png',
) as ImageProvider,
fit: BoxFit.cover,
),
),
),
),
title: Text(
albumInfo.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(assetCount.value.toString()),
trailing: IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},
icon: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 24,
),
splashRadius: 25,
),
),
);
}
}
@@ -0,0 +1,324 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupAlbumInfoListItem extends ConsumerWidget {
final LocalAlbum album;
const BackupAlbumInfoListItem({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupAlbums = ref.watch(backupAlbumsProvider);
final backupAlbumNotifier = ref.read(backupAlbumsProvider.notifier);
final isSelected =
backupAlbums.value?.selectedBackupAlbums.any((a) => a.id == album.id) ??
false;
final isExcluded =
backupAlbums.value?.excludedBackupAlbums.any((a) => a.id == album.id) ??
false;
final backupSelection = isSelected
? BackupSelection.select
: isExcluded
? BackupSelection.exclude
: BackupSelection.none;
void onTap() {
HapticFeedback.selectionClick();
if (isSelected || isExcluded) {
backupAlbumNotifier.deSelectAlbum(album);
} else {
backupAlbumNotifier.selectAlbumForBackup(album);
}
}
void onDoubleTap() {
HapticFeedback.selectionClick();
if (isExcluded) {
backupAlbumNotifier.deSelectAlbum(album);
} else {
if (album.id == LocalAlbum.isAllId || album.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
backupAlbumNotifier.excludeAlbumFromBackup(album);
}
}
return GestureDetector(
onTap: onTap,
onDoubleTap: onDoubleTap,
child: context.isMobile
? _AlbumDetailListTile(album, backupSelection)
: _AlbumDetailCard(album, backupSelection),
);
}
}
class _AlbumFilteredThumbnail extends StatelessWidget {
final Asset? thumbnail;
final BackupSelection selection;
const _AlbumFilteredThumbnail(this.thumbnail, this.selection);
@override
Widget build(BuildContext context) {
ColorFilter selectedFilter = ColorFilter.mode(
context.primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
return ColorFiltered(
colorFilter: switch (selection) {
BackupSelection.select => selectedFilter,
BackupSelection.exclude => excludedFilter,
BackupSelection.none => unselectedFilter,
},
child: ImmichImage(thumbnail),
);
}
}
/// Portrait list components
class _AlbumDetailListTile extends StatelessWidget {
final LocalAlbum album;
final BackupSelection selection;
const _AlbumDetailListTile(this.album, this.selection);
@override
Widget build(BuildContext context) {
// Wrapped ListTile with Material to prevent tileColor overflow
// https://github.com/flutter/flutter/issues/86584
return Material(
type: MaterialType.transparency,
child: ListTile(
tileColor: switch (selection) {
BackupSelection.select => context.isDarkTheme
? context.primaryColor.withAlpha(100)
: context.primaryColor.withAlpha(25),
BackupSelection.exclude => context.isDarkTheme
? Colors.red[300]?.withAlpha(150)
: Colors.red[100]?.withAlpha(150),
BackupSelection.none => Colors.transparent,
},
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: SizedBox(
height: 80,
width: 80,
child: _AlbumFilteredThumbnail(album.thumbnail, selection),
),
),
title: Text(
album.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(album.assetCount.toString()),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_AlbumDetailCardChip(selection),
_AlbumPreviewButton(album),
],
),
),
);
}
}
/// Landscape card components
class _AlbumDetailCard extends StatelessWidget {
final LocalAlbum album;
final BackupSelection selection;
const _AlbumDetailCard(this.album, this.selection);
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius:
const BorderRadius.all(Radius.circular(12)), // if you need this
side: BorderSide(
color: context.isDarkTheme
? const Color.fromARGB(255, 37, 35, 35)
: const Color(0xFFC9C9C9),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
_AlbumFilteredThumbnail(
album.thumbnail,
selection,
),
if (selection != BackupSelection.none)
Positioned(
bottom: 10,
right: 25,
child: _AlbumDetailCardChip(selection),
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 25),
child: _AlbumDetailCardDetails(album),
),
],
),
);
}
}
class _AlbumDetailCardChip extends StatelessWidget {
final BackupSelection selection;
const _AlbumDetailCardChip(this.selection);
@override
Widget build(BuildContext context) {
if (selection == BackupSelection.none) {
return const SizedBox();
}
return Chip(
visualDensity: VisualDensity.compact,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
),
label: Text(
selection == BackupSelection.select
? "album_info_card_backup_album_included"
: "album_info_card_backup_album_excluded",
style: TextStyle(
fontSize: 10,
color: context.isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: selection == BackupSelection.select
? context.primaryColor
: Colors.red[300],
);
}
}
class _AlbumDetailCardDetails extends StatelessWidget {
final LocalAlbum album;
const _AlbumDetailCardDetails(this.album);
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
album.name,
style: TextStyle(
fontSize: 14,
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
album.assetCount.toString() +
(snapshot.data!.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: AssetPathEntity.fromId(album.id),
),
),
],
),
),
_AlbumPreviewButton(album),
],
);
}
}
class _AlbumPreviewButton extends StatelessWidget {
final LocalAlbum album;
const _AlbumPreviewButton(this.album);
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => context.pushRoute(
LocalAlbumViewerRoute(
album: album,
selectEnabled: false,
),
),
icon: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 24,
),
splashRadius: 25,
);
}
}
@@ -1,98 +0,0 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
@RoutePage()
class AlbumPreviewPage extends HookConsumerWidget {
final AssetPathEntity album;
const AlbumPreviewPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]);
getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(
start: 0,
end: await album.assetCountAsync,
);
}
useEffect(
() {
getAssetsInAlbum();
return null;
},
[],
);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Column(
children: [
Text(
album.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
"ID ${album.id}",
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
fontWeight: FontWeight.bold,
),
),
),
],
),
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
Future<Uint8List?> thumbData =
assets.value[index].thumbnailDataWithSize(
const ThumbnailSize(200, 200),
quality: 50,
);
return FutureBuilder<Uint8List?>(
future: thumbData,
builder: ((context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
width: 100,
height: 100,
fit: BoxFit.cover,
);
}
return const SizedBox(
width: 100,
height: 100,
child: ImmichLoadingIndicator(),
);
}),
);
},
),
);
}
}
@@ -3,11 +3,12 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_list_tile.dart';
import 'package:immich_mobile/modules/backup/ui/backup_album_info_list_item.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@RoutePage()
@@ -15,194 +16,19 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = context.isDarkTheme;
final albums = ref.watch(backupProvider).availableAlbums;
final localAlbums = ref.watch(localAlbumsProvider);
final searchValue = useValueNotifier('');
useEffect(
() {
ref.watch(backupProvider.notifier).getBackupInfo();
return null;
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
return ref.read(backupProvider.notifier).getBackupInfo;
},
[],
);
buildAlbumSelectionList() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoListTile(
imageData: thumbnailData,
albumInfo: albums[index],
);
}),
childCount: albums.length,
),
),
);
}
buildAlbumSelectionGrid() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.length,
itemBuilder: ((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoCard(
imageData: thumbnailData,
albumInfo: albums[index],
);
}),
),
);
}
buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() =>
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: context.primaryColor,
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref
.watch(backupProvider.notifier)
.removeExcludedAlbumForBackup(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : immichBackgroundColor,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.red[300],
deleteIconColor:
isDarkTheme ? Colors.black : immichBackgroundColor,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
// buildSearchBar() {
// return Padding(
// padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
// child: TextFormField(
// onChanged: (searchValue) {
// // if (searchValue.isEmpty) {
// // albums = availableAlbums;
// // } else {
// // albums.value = availableAlbums
// // .where(
// // (album) => album.name
// // .toLowerCase()
// // .contains(searchValue.toLowerCase()),
// // )
// // .toList();
// // }
// },
// decoration: InputDecoration(
// contentPadding: const EdgeInsets.symmetric(
// horizontal: 8.0,
// vertical: 8.0,
// ),
// hintText: "Search",
// hintStyle: TextStyle(
// color: isDarkTheme ? Colors.white : Colors.grey,
// fontSize: 14.0,
// ),
// prefixIcon: const Icon(
// Icons.search,
// color: Colors.grey,
// ),
// border: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// borderSide: BorderSide.none,
// ),
// filled: true,
// fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
// ),
// ),
// );
// }
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(
"backup_album_selection_page_select_albums",
).tr(),
elevation: 0,
),
appBar: _AppBar(),
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
@@ -210,105 +36,26 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
child: Text(
"backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall,
).tr(),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
...buildSelectedAlbumNameChip(),
...buildExcludedAlbumNameChip(),
],
),
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
args: [
ref
.watch(backupProvider)
.availableAlbums
.length
.toString(),
],
),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: context.primaryColor,
),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).tr(),
],
),
),
);
},
);
},
),
),
// buildSearchBar(),
_AlbumBackupInfoRow(localAlbums.valueOrNull?.length ?? 0),
_AlbumSearchBar(onSearch: (value) => searchValue.value = value),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid();
} else {
return buildAlbumSelectionList();
}
ValueListenableBuilder(
valueListenable: searchValue,
builder: (ctx, search, _) {
final filteredAlbums = searchValue.value.isEmpty
? localAlbums
: localAlbums.whenData(
(albums) => albums
.where(
(a) => a.name
.toLowerCase()
.contains(searchValue.value.toLowerCase()),
)
.toList(),
);
return _SilverLocalAlbumSelectionList(albums: filteredAlbums);
},
),
],
@@ -316,3 +63,172 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
);
}
}
class _AppBar extends ImmichAppBar {
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppBar(
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(
"backup_album_selection_page_select_albums",
).tr(),
elevation: 0,
);
}
}
class _AlbumBackupInfoRow extends StatelessWidget {
final int albumCount;
const _AlbumBackupInfoRow(this.albumCount);
@override
Widget build(BuildContext context) {
void showBackupSelectionInfoDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).tr(),
],
),
),
);
},
);
}
return ListTile(
title: Text(
"backup_album_selection_page_albums_device"
.tr(args: [albumCount.toString()]),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: context.primaryColor,
),
onPressed: showBackupSelectionInfoDialog,
),
);
}
}
class _AlbumSearchBar extends StatelessWidget {
final Function(String) onSearch;
const _AlbumSearchBar({required this.onSearch});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: TextFormField(
onChanged: onSearch,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(8.0),
hintText: "Search",
hintStyle: TextStyle(
color: context.isDarkTheme ? Colors.white : Colors.grey,
fontSize: 14.0,
),
prefixIcon: const Icon(
Icons.search,
color: Colors.grey,
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide: BorderSide.none,
),
filled: true,
fillColor: context.isDarkTheme ? Colors.white30 : Colors.grey[200],
),
),
);
}
}
// ignore: prefer-sliver-prefix
class _SilverLocalAlbumSelectionList extends StatelessWidget {
final AsyncValue<List<LocalAlbum>> albums;
const _SilverLocalAlbumSelectionList({required this.albums});
@override
Widget build(BuildContext context) {
if (albums.isLoading) {
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
if (albums.hasError) {
return SliverToBoxAdapter(child: Text("Error occured: ${albums.error}"));
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: context.isMobile
? SliverList(
delegate: SliverChildBuilderDelegate(
((context, index) {
return BackupAlbumInfoListItem(
album: albums.requireValue.elementAt(index),
);
}),
childCount: albums.requireValue.length,
),
)
: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.requireValue.length,
itemBuilder: ((context, index) {
return BackupAlbumInfoListItem(
album: albums.requireValue.elementAt(index),
);
}),
),
);
}
}

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