1
0
forked from Cutlery/immich

Compare commits

..

1 Commits

Author SHA1 Message Date
mertalev 4e730da137 set limits 2024-02-28 00:13:34 -05:00
334 changed files with 30056 additions and 12189 deletions
+12 -13
View File
@@ -1,31 +1,30 @@
.vscode/
.github/
.git/
design/
docker/
docs/
e2e/
fastlane/
machine-learning/
misc/
mobile/
cli/coverage/
cli/dist/
cli/node_modules/
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
server/coverage/
server/node_modules/
server/coverage/
server/.reverse-geocoding-dump/
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 = single
quote_type = double
+2
View File
@@ -8,6 +8,8 @@ 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
+9 -24
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,40 +194,25 @@ 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 chromium
if: ${{ !cancelled() }}
run: npx playwright install --with-deps
- 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
@@ -237,8 +222,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
@@ -256,7 +241,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
@@ -294,7 +279,7 @@ jobs:
- name: Run API generation
run: make open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@v19
uses: tj-actions/verify-changed-files@v18
id: verify-changed-files
with:
files: |
@@ -349,7 +334,7 @@ jobs:
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@v19
uses: tj-actions/verify-changed-files@v18
id: verify-changed-files
with:
files: |
@@ -367,7 +352,7 @@ jobs:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@v19
uses: tj-actions/verify-changed-files@v18
id: verify-changed-sql-files
with:
files: |
+8
View File
@@ -54,6 +54,14 @@
"@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
+5 -5
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
@@ -60,12 +60,12 @@ services:
redis:
container_name: immich_redis
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
restart: always
database:
container_name: immich_postgres
image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
+1 -3
View File
@@ -67,11 +67,9 @@ 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 |
+4 -1
View File
@@ -88,7 +88,10 @@ Some basic examples:
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button.
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
If your photos are on a network drive you will likely have to enable filesystem polling. The performance hit for polling large libraries is currently unknown, feel free to test this feature and report back. In addition to the boolean feature flag, the configuration file allows customization of the following parameters, please see the [chokidar documentation](https://github.com/paulmillr/chokidar?tab=readme-ov-file#performance) for reference.
- `usePolling` (default: `false`).
- `interval`. (default: 10000). When using polling, this is how often (in milliseconds) the filesystem is polled.
### Nightly job
+3 -6
View File
@@ -95,16 +95,13 @@ 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,
"mobileOverrideEnabled": false,
"mobileRedirectUri": ""
"autoLaunch": false
},
"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_NAME` | Database Name | `immich` | server, microservices |
| `DB_DATABASE` | 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
@@ -1,31 +0,0 @@
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
@@ -1,16 +0,0 @@
.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
@@ -1,8 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true,
"organizeImportsSkipDestructiveCodeActions": true,
"plugins": ["prettier-plugin-organize-imports"]
}
+3 -4
View File
@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
name: immich-e2e
@@ -16,7 +16,6 @@ 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
@@ -24,14 +23,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:
+12 -2271
View File
File diff suppressed because it is too large Load Diff
+1 -16
View File
@@ -7,11 +7,7 @@
"scripts": {
"test": "vitest --config vitest.config.ts",
"test:web": "npx playwright test",
"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"
"start:web": "npx playwright test --ui"
},
"keywords": [],
"author": "",
@@ -23,21 +19,10 @@
"@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",
+37 -11
View File
@@ -20,7 +20,10 @@ 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();
@@ -53,9 +56,13 @@ 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 () => {
@@ -64,7 +71,9 @@ 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 () => {
@@ -73,7 +82,9 @@ 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 () => {
@@ -149,7 +160,9 @@ 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')
@@ -202,7 +215,9 @@ 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 () => {
@@ -211,7 +226,12 @@ 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 () => {
@@ -251,7 +271,9 @@ 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')
@@ -334,7 +356,9 @@ 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);
});
@@ -396,7 +420,9 @@ 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 () => {
+38 -26
View File
@@ -41,7 +41,7 @@ describe('/album', () => {
]);
[user1Asset1, user1Asset2] = await Promise.all([
apiUtils.createAsset(user1.accessToken, { isFavorite: true }),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
]);
@@ -93,7 +93,10 @@ describe('/album', () => {
}),
]);
await deleteUser({ id: user3.userId }, { headers: asBearerAuth(admin.accessToken) });
await deleteUser(
{ id: user3.userId },
{ headers: asBearerAuth(admin.accessToken) },
);
});
describe('GET /album', () => {
@@ -108,7 +111,9 @@ 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 () => {
@@ -119,17 +124,6 @@ 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')
@@ -159,7 +153,9 @@ 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(
@@ -254,7 +250,9 @@ 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);
});
@@ -267,7 +265,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
});
});
@@ -279,7 +277,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [expect.objectContaining({ id: user2Albums[0].assets[0].id })],
assets: [expect.objectContaining(user2Albums[0].assets[0])],
});
});
@@ -291,7 +289,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
});
});
@@ -328,7 +326,9 @@ 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,7 +360,9 @@ 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);
});
@@ -373,7 +375,9 @@ 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 () => {
@@ -384,7 +388,9 @@ 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 }),
]);
});
});
@@ -467,7 +473,9 @@ 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 () => {
@@ -477,7 +485,9 @@ 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 }),
]);
});
});
@@ -491,7 +501,9 @@ 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);
+87 -355
View File
@@ -1,33 +1,16 @@
import {
AssetFileUploadResponseDto,
AssetResponseDto,
AssetTypeEnum,
LoginResponseDto,
SharedLinkType,
} from '@immich/sdk';
import { exiftool } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
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 } 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 readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename);
await writeFile(filepath, bytes);
return exiftool.read(filepath);
};
import { beforeAll, describe, expect, it } from 'vitest';
const today = DateTime.fromObject({
year: 2023,
@@ -41,81 +24,67 @@ describe('/asset', () => {
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let userStats: LoginResponseDto;
let user1Assets: AssetFileUploadResponseDto[];
let user2Assets: AssetFileUploadResponseDto[];
let assetLocation: AssetFileUploadResponseDto;
let asset1: AssetFileUploadResponseDto;
let asset2: AssetFileUploadResponseDto;
let asset3: AssetFileUploadResponseDto;
let asset4: AssetFileUploadResponseDto; // user2 asset
let asset5: AssetFileUploadResponseDto;
let asset6: AssetFileUploadResponseDto;
let ws: Socket;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
[ws, user1, user2, userStats] = await Promise.all([
wsUtils.connect(admin.accessToken),
[user1, user2, userStats] = await Promise.all([
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
// asset location
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([
[asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken, {
isFavorite: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
apiUtils.createAsset(
user1.accessToken,
{
isFavorite: true,
isExternal: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
},
{ filename: 'example.mp4' },
),
apiUtils.createAsset(user2.accessToken),
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,
assetData: { filename: 'example.mp4' },
}),
apiUtils.createAsset(
userStats.accessToken,
{
isArchived: true,
isFavorite: true,
},
{ filename: 'example.mp4' },
),
]);
const person1 = await apiUtils.createPerson(user1.accessToken, {
name: 'Test Person',
});
await dbUtils.createFace({
assetId: user1Assets[0].id,
personId: person1.id,
});
}, 30_000);
afterAll(() => {
wsUtils.disconnect(ws);
await dbUtils.createFace({ assetId: asset1.id, personId: person1.id });
});
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);
});
@@ -130,7 +99,7 @@ describe('/asset', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/asset/${user2Assets[0].id}`)
.get(`/asset/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
@@ -138,31 +107,33 @@ describe('/asset', () => {
it('should get the asset info', async () => {
const { status, body } = await request(app)
.get(`/asset/${user1Assets[0].id}`)
.get(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id });
expect(body).toMatchObject({ id: asset1.id });
});
it('should work with a shared link', async () => {
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [user1Assets[0].id],
assetIds: [asset1.id],
});
const { status, body } = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
const { status, body } = await request(app).get(
`/asset/${asset1.id}?key=${sharedLink.key}`,
);
expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id });
expect(body).toMatchObject({ id: asset1.id });
});
it('should not send people data for shared links for un-authenticated users', async () => {
const { status, body } = await request(app)
.get(`/asset/${user1Assets[0].id}`)
.get(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(body).toMatchObject({
id: user1Assets[0].id,
id: asset1.id,
isFavorite: false,
people: [
{
@@ -177,10 +148,12 @@ describe('/asset', () => {
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [user1Assets[0].id],
assetIds: [asset1.id],
});
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
const data = await request(app).get(
`/asset/${asset1.id}?key=${sharedLink.key}`,
);
expect(data.status).toBe(200);
expect(data.body).toMatchObject({ people: [] });
});
@@ -263,7 +236,7 @@ describe('/asset', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it.each(TEN_TIMES)('should return 1 random assets', async () => {
it.each(Array(10))('should return 1 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -273,9 +246,14 @@ describe('/asset', () => {
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
//
// assets owned by user2
expect(assets[0].id).not.toBe(asset4.id);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id);
});
it.each(TEN_TIMES)('should return 2 random assets', async () => {
it.each(Array(10))('should return 2 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -287,18 +265,22 @@ describe('/asset', () => {
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id);
// assets owned by user2
expect(asset.id).not.toBe(asset4.id);
}
});
it.each(TEN_TIMES)(
it.each(Array(10))(
'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: asset4.id })]);
},
);
@@ -313,7 +295,9 @@ 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);
});
@@ -328,44 +312,44 @@ describe('/asset', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/asset/${user2Assets[0].id}`)
.put(`/asset/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should favorite an asset', async () => {
const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id);
expect(before.isFavorite).toBe(false);
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
expect(body).toMatchObject({ id: asset1.id, isFavorite: true });
expect(status).toEqual(200);
});
it('should archive an asset', async () => {
const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id);
expect(before.isArchived).toBe(false);
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
expect(body).toMatchObject({ id: asset1.id, isArchived: true });
expect(status).toEqual(200);
});
it('should update date time original', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
expect(body).toMatchObject({
id: user1Assets[0].id,
id: asset1.id,
exifInfo: expect.objectContaining({
dateTimeOriginal: '2023-11-20T01:11:00.000Z',
}),
@@ -387,7 +371,7 @@ describe('/asset', () => {
{ latitude: 12, longitude: 181 },
]) {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.put(`/asset/${asset1.id}`)
.send(test)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
@@ -397,12 +381,12 @@ describe('/asset', () => {
it('should update gps data', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ latitude: 12, longitude: 12 });
expect(body).toMatchObject({
id: user1Assets[0].id,
id: asset1.id,
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
});
expect(status).toEqual(200);
@@ -410,11 +394,11 @@ describe('/asset', () => {
it('should set the description', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'Test asset description' });
expect(body).toMatchObject({
id: user1Assets[0].id,
id: asset1.id,
exifInfo: expect.objectContaining({
description: 'Test asset description',
}),
@@ -424,12 +408,12 @@ describe('/asset', () => {
it('should return tagged people', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(status).toEqual(200);
expect(body).toMatchObject({
id: user1Assets[0].id,
id: asset1.id,
isFavorite: true,
people: [
{
@@ -461,7 +445,9 @@ 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 () => {
@@ -471,7 +457,9 @@ 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 () => {
@@ -490,260 +478,4 @@ describe('/asset', () => {
expect(after.isTrashed).toBe(true);
});
});
describe('POST /asset/upload', () => {
const tests = [
{
input: 'formats/jpg/el_torcal_rocks.jpg',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'el_torcal_rocks',
resized: true,
exifInfo: {
dateTimeOriginal: '2012-08-05T11:39:59.000Z',
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
focalLength: 75,
iso: 200,
fNumber: 11,
exposureTime: '1/160',
fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
description: 'SONY DSC',
},
},
},
{
input: 'formats/heic/IMG_2682.heic',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'IMG_2682',
resized: true,
fileCreatedAt: '2019-03-21T16:04:22.348Z',
exifInfo: {
dateTimeOriginal: '2019-03-21T16:04:22.348Z',
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
fileSizeInByte: 880_703,
exposureTime: '1/887',
iso: 20,
focalLength: 3.99,
fNumber: 1.8,
timeZone: 'America/Chicago',
},
},
},
{
input: 'formats/png/density_plot.png',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'density_plot',
resized: true,
exifInfo: {
exifImageWidth: 800,
exifImageHeight: 800,
latitude: null,
longitude: null,
fileSizeInByte: 25_408,
},
},
},
{
input: 'formats/raw/Nikon/D80/glarus.nef',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'glarus',
resized: true,
fileCreatedAt: '2010-07-20T17:27:12.000Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D80',
exposureTime: '1/200',
fNumber: 10,
focalLength: 18,
iso: 100,
fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Nikon/D700/philadelphia.nef',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'philadelphia',
resized: true,
fileCreatedAt: '2016-09-22T22:10:29.060Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D700',
exposureTime: '1/400',
fNumber: 11,
focalLength: 85,
iso: 200,
fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
orientation: '1',
timeZone: 'UTC-5',
},
},
},
];
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, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
});
expect(duplicate).toBe(false);
await wsUtils.waitForEvent({ event: 'upload', assetId: id });
const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo).toMatchObject(expected.exifInfo);
expect(asset).toMatchObject(expected);
});
}
it('should handle a duplicate', async () => {
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
const { duplicate } = await apiUtils.createAsset(admin.accessToken, {
assetData: {
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
});
expect(duplicate).toBe(true);
});
// These hashes were created by copying the image files to a Samsung phone,
// exporting the video from Samsung's stock Gallery app, and hashing them locally.
// This ensures that immich+exiftool are extracting the videos the same way Samsung does.
// DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
// into the test here.
const motionTests = [
{
filepath: 'formats/motionphoto/Samsung One UI 5.jpg',
checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=',
},
{
filepath: 'formats/motionphoto/Samsung One UI 6.jpg',
checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=',
},
{
filepath: 'formats/motionphoto/Samsung One UI 6.heic',
checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=',
},
];
for (const { filepath, checksum } of motionTests) {
it(`should extract motionphoto video from ${filepath}`, async () => {
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);
expect(asset.livePhotoVideoId).toBeDefined();
const video = await apiUtils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string);
expect(video.checksum).toStrictEqual(checksum);
});
}
});
describe('GET /asset/thumbnail/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not include gps data for webp thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
.set('Authorization', `Bearer ${admin.accessToken}`);
await wsUtils.waitForEvent({
event: 'upload',
assetId: assetLocation.id,
});
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/webp');
const exifData = await readTags(body, 'thumbnail.webp');
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
it('should not include gps data for jpeg thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const exifData = await readTags(body, 'thumbnail.jpg');
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
});
describe('GET /asset/file/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should download the original', async () => {
const { status, body, type } = await request(app)
.get(`/asset/file/${assetLocation.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const asset = await apiUtils.getAssetInfo(admin.accessToken, assetLocation.id);
const original = await readFile(locationAssetFilepath);
const originalChecksum = sha1(original);
const downloadChecksum = sha1(body);
expect(originalChecksum).toBe(downloadChecksum);
expect(downloadChecksum).toBe(asset.checksum);
});
});
});
+12 -4
View File
@@ -1,4 +1,9 @@
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';
@@ -15,20 +20,23 @@ 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,
updateAssetDto: { isArchived: true },
},
{ headers: asBearerAuth(admin.accessToken) },
{ headers: asBearerAuth(admin.accessToken) }
),
]);
+51 -15
View File
@@ -1,6 +1,16 @@
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';
@@ -38,14 +48,18 @@ 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);
});
@@ -72,7 +86,9 @@ 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);
@@ -91,7 +107,9 @@ 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);
});
@@ -107,7 +125,9 @@ 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);
@@ -116,9 +136,15 @@ 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;'
);
});
});
@@ -150,12 +176,18 @@ 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 () => {
@@ -163,7 +195,9 @@ 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 () => {
@@ -185,7 +219,9 @@ 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);
});
+4 -2
View File
@@ -42,7 +42,9 @@ 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);
@@ -54,7 +56,7 @@ describe('/download', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toEqual('image/png');
expect(response.headers['content-type']).toEqual('image/jpeg');
});
});
});
-456
View File
@@ -1,456 +0,0 @@
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`,
});
});
});
});
+9 -2
View File
@@ -15,9 +15,16 @@ 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',
])
);
});
});
});
+26 -8
View File
@@ -24,8 +24,14 @@ 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) }
),
]);
});
@@ -60,7 +66,9 @@ 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);
@@ -81,13 +89,17 @@ 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);
@@ -100,13 +112,17 @@ 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);
@@ -126,7 +142,9 @@ 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' })
);
});
});
});
+10 -4
View File
@@ -65,7 +65,9 @@ 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({
@@ -78,7 +80,9 @@ 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);
@@ -105,7 +109,9 @@ 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);
});
@@ -133,7 +139,7 @@ describe('/activity', () => {
birthDate: '123567',
response: 'Not found or no person.write access',
},
{ birthDate: 123_567, response: 'Not found or no person.write access' },
{ birthDate: 123567, response: 'Not found or no person.write access' },
]) {
const { status, body } = await request(app)
.put(`/person/${uuidDto.notFound}`)
+6 -2
View File
@@ -97,7 +97,9 @@ 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);
});
@@ -143,7 +145,9 @@ 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'],
+81 -44
View File
@@ -46,8 +46,14 @@ 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: {
@@ -59,38 +65,47 @@ 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', () => {
@@ -131,13 +146,17 @@ 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(
@@ -159,14 +178,18 @@ 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);
@@ -188,7 +211,9 @@ 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);
@@ -204,7 +229,9 @@ 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);
@@ -220,7 +247,9 @@ 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);
@@ -247,7 +276,9 @@ 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' }),
);
});
});
@@ -277,7 +308,9 @@ 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 () => {
@@ -287,7 +320,9 @@ 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 () => {
@@ -389,7 +424,9 @@ 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);
+8 -2
View File
@@ -18,7 +18,9 @@ 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);
});
@@ -30,7 +32,11 @@ 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',
])
);
}
});
+15 -5
View File
@@ -32,16 +32,24 @@ 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 });
await wsUtils.once(ws, 'on_asset_delete');
const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
const after = await getAllAssets(
{},
{ headers: asBearerAuth(admin.accessToken) },
);
expect(after.length).toBe(0);
});
});
@@ -61,7 +69,9 @@ 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);
+31 -11
View File
@@ -22,7 +22,10 @@ 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', () => {
@@ -33,7 +36,9 @@ 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(
@@ -42,7 +47,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
]),
])
);
});
@@ -58,7 +63,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
]),
])
);
});
@@ -76,7 +81,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
]),
])
);
});
});
@@ -107,7 +112,9 @@ 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,
@@ -118,7 +125,9 @@ 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);
});
@@ -172,7 +181,9 @@ 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);
});
@@ -230,7 +241,10 @@ 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`)
@@ -247,7 +261,10 @@ 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`)
@@ -267,7 +284,10 @@ 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({
+14 -4
View File
@@ -1,6 +1,6 @@
import { stat } from 'node:fs/promises';
import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, beforeAll, describe, expect, it } from 'vitest';
describe(`immich login-key`, () => {
beforeAll(() => {
@@ -24,15 +24,25 @@ 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',
+43 -10
View File
@@ -1,6 +1,13 @@
import { getAllAlbums, getAllAssets } from '@immich/sdk';
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
import { apiUtils, asKeyAuth, cliUtils, dbUtils, immichCli, testAssetDir } from 'src/utils';
import { mkdir, readdir, rm, symlink } from 'fs/promises';
import {
apiUtils,
asKeyAuth,
cliUtils,
dbUtils,
immichCli,
testAssetDir,
} from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich upload`, () => {
@@ -18,10 +25,16 @@ 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);
@@ -57,9 +70,15 @@ 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);
@@ -70,10 +89,17 @@ 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'),
]),
);
@@ -121,10 +147,17 @@ 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 });
+2
View File
@@ -44,6 +44,7 @@ export const userDto = {
email: signupDto.admin.email,
password: signupDto.admin.password,
storageLabel: 'admin',
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -62,6 +63,7 @@ export const userDto = {
email: createUserDto.user1.email,
password: createUserDto.user1.password,
storageLabel: null,
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
-31
View File
@@ -1,31 +0,0 @@
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;
};
+1
View File
@@ -65,6 +65,7 @@ export const signupResponseDto = {
name: 'Immich Admin',
email: 'admin@immich.cloud',
storageLabel: 'admin',
externalPath: null,
profileImagePath: '',
// why? lol
shouldChangePassword: true,
+7 -5
View File
@@ -1,24 +1,26 @@
import { exec, spawn } from 'node:child_process';
import { spawn, exec } from 'child_process';
export default async () => {
let _resolve: () => unknown;
const ready = new Promise<void>((resolve) => (_resolve = resolve));
const promise = new Promise<void>((resolve) => (_resolve = resolve));
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
child.stdout.on('data', (data) => {
const input = data.toString();
console.log(input);
if (input.includes('Immich Microservices is listening')) {
if (input.includes('Immich Server is listening')) {
_resolve();
}
});
child.stderr.on('data', (data) => console.log(data.toString()));
await ready;
await promise;
return async () => {
await new Promise<void>((resolve) => exec('docker compose down', () => resolve()));
await new Promise<void>((resolve) =>
exec('docker compose down', () => resolve())
);
};
};
+75 -94
View File
@@ -1,16 +1,12 @@
import {
AssetFileUploadResponseDto,
AssetResponseDto,
CreateAlbumDto,
CreateAssetDto,
CreateLibraryDto,
CreateUserDto,
PersonUpdateDto,
SharedLinkCreateDto,
ValidateLibraryDto,
createAlbum,
createApiKey,
createLibrary,
createPerson,
createSharedLink,
createUser,
@@ -21,18 +17,16 @@ import {
setAdminOnboarding,
signUpAdmin,
updatePerson,
validate,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process';
import { exec, spawn } from 'child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
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);
@@ -46,8 +40,6 @@ 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';
const mediaDir = '/usr/src/app/upload';
@@ -55,7 +47,6 @@ const dirs = [
`"${mediaDir}/thumbs"`,
`"${mediaDir}/upload"`,
`"${mediaDir}/library"`,
`"${mediaDir}/encoded-video"`,
].join(' ');
if (!(await directoryExists(`${testAssetDir}/albums`))) {
@@ -74,12 +65,20 @@ 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;
}
@@ -87,28 +86,31 @@ 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',
@@ -175,80 +177,37 @@ export interface AdminSetupOptions {
onboarding?: boolean;
}
export enum SocketEvent {
UPLOAD = 'upload',
DELETE = 'delete',
}
export type EventType = 'upload' | 'delete';
export interface WaitOptions {
event: EventType;
assetId: string;
timeout?: number;
}
const events: Record<EventType, Set<string>> = {
upload: new Set<string>(),
delete: new Set<string>(),
};
const callbacks: Record<string, () => void> = {};
const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
events[event].add(assetId);
const callback = callbacks[assetId];
if (callback) {
callback();
delete callbacks[assetId];
}
};
export const wsUtils = {
connect: async (accessToken: string) => {
const websocket = io('http://127.0.0.1:2283', {
path: '/api/socket.io',
transports: ['websocket'],
extraHeaders: { Authorization: `Bearer ${accessToken}` },
autoConnect: true,
autoConnect: false,
forceNew: true,
});
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 }))
.connect();
websocket.on('connect', () => resolve(websocket));
websocket.connect();
});
},
disconnect: (ws: Socket) => {
if (ws?.connected) {
ws.disconnect();
}
for (const set of Object.values(events)) {
set.clear();
}
},
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);
callbacks[assetId] = () => {
once: <T = any>(ws: Socket, event: string): Promise<T> => {
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 4000);
ws.once(event, (data: T) => {
clearTimeout(timeout);
resolve();
};
resolve(data);
});
});
},
};
type AssetData = { bytes?: Buffer; filename: string };
export const apiUtils = {
setup: () => {
defaults.baseUrl = app;
@@ -265,64 +224,86 @@ 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'>> & { assetData?: AssetData },
dto?: Partial<Omit<CreateAssetDto, 'assetData'>>,
data?: {
bytes?: Buffer;
filename?: string;
},
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...dto,
...(dto || {}),
};
const assetData = dto?.assetData?.bytes || makeRandomImage();
const filename = dto?.assetData?.filename || 'example.png';
const _assetData = {
bytes: randomBytes(32),
filename: 'example.jpg',
...(data || {}),
};
const builder = request(app)
.post(`/asset/upload`)
.attach('assetData', assetData, filename)
.attach('assetData', _assetData.bytes, _assetData.filename)
.set('Authorization', `Bearer ${accessToken}`);
for (const [key, value] of Object.entries(_dto)) {
void builder.field(key, String(value));
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
const person = await createPerson({ headers: asBearerAuth(accessToken) });
let 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) }),
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) }),
createSharedLink(
{ sharedLinkCreateDto: dto },
{ headers: asBearerAuth(accessToken) },
),
};
export const cliUtils = {
@@ -342,7 +323,7 @@ export const webUtils = {
value: accessToken,
domain: '127.0.0.1',
path: '/',
expires: 1_742_402_728,
expires: 1742402728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
@@ -352,7 +333,7 @@ export const webUtils = {
value: 'password',
domain: '127.0.0.1',
path: '/',
expires: 1_742_402_728,
expires: 1742402728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
@@ -362,7 +343,7 @@ export const webUtils = {
value: 'true',
domain: '127.0.0.1',
path: '/',
expires: 1_742_402_728,
expires: 1742402728,
httpOnly: false,
secure: false,
sameSite: 'Lax',
+2 -2
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { test, expect } 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
await expect(page.getByRole('heading')).toHaveText('Change Password');
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');
+5 -4
View File
@@ -1,17 +1,18 @@
import {
AlbumResponseDto,
AssetFileUploadResponseDto,
AssetResponseDto,
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: AssetFileUploadResponseDto;
let asset: AssetResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
@@ -28,7 +29,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,
@@ -52,7 +53,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', { exact: true }).waitFor();
await page.getByText('DOWNLOADING').waitFor();
});
test('enter password for a shared link', async ({ page }) => {
-1
View File
@@ -18,6 +18,5 @@
"rootDirs": ["src"],
"baseUrl": "./"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
+1 -9
View File
@@ -1,17 +1,9 @@
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,
globalSetup: ['src/setup.ts'],
poolOptions: {
threads: {
singleThread: true,
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.97.0"
version = "1.96.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
-1
View File
@@ -63,7 +63,6 @@ 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
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 125,
"android.injected.version.name" => "1.97.0",
"android.injected.version.code" => 124,
"android.injected.version.name" => "1.96.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+3 -3
View File
@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000266">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000271">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.342186">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="74.334294">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="48.746195">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.507669">
</testcase>
+1 -1
View File
@@ -180,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1
+3 -3
View File
@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
+2 -2
View File
@@ -55,11 +55,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.97.0</string>
<string>1.96.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>141</string>
<string>140</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.97.0"
version_number: "1.96.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
+6 -6
View File
@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000304">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.272646">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.190055">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.560896">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.109364">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.235745">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.15926">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="114.820395">
<testcase classname="fastlane.lanes" name="4: build_app" time="80.90681">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.950812">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="71.634559">
</testcase>
@@ -1,39 +0,0 @@
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";
}
@@ -1,11 +0,0 @@
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;
}
+13 -11
View File
@@ -9,18 +9,20 @@ 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';
@@ -71,15 +73,14 @@ Future<void> initApp() async {
FlutterError.onError = (details) {
FlutterError.presentError(details);
log.severe(
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
details,
'FlutterError - Catch all',
"${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
details.stack,
);
};
PlatformDispatcher.instance.onError = (error, stack) {
log.severe('PlatformDispatcher - Catch all error: $error', error, stack);
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
log.severe('PlatformDispatcher - Catch all', error, stack);
return true;
};
@@ -93,13 +94,14 @@ Future<Isar> loadDb() async {
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
BackupAlbumSchema,
LocalAlbumSchema,
RemoteAlbumSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
DeviceAssetSchema,
if (Platform.isAndroid) AndroidDeviceAssetSchema,
if (Platform.isIOS) IOSDeviceAssetSchema,
],
directory: dir.path,
maxSizeMiB: 256,
@@ -10,7 +10,6 @@ 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';
@@ -23,15 +22,15 @@ class ActivitiesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 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;
// Album has to be set in the provider before reaching this page
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final user = ref.watch(currentUserProvider);
final activityNotifier =
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activities =
ref.watch(albumActivityProvider(album.id, asset?.remoteId));
ref.watch(albumActivityProvider(album.remoteId!, 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.id, asset?.remoteId).notifier);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final user = ref.watch(currentUserProvider);
final inputController = useTextEditingController();
final inputFocusNode = useFocusNode();
@@ -1,216 +0,0 @@
// 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,26 +1,74 @@
import 'dart:async';
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/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';
final remoteAlbumWatcher =
StreamProvider.autoDispose.family<RemoteAlbum, int>((ref, albumId) async* {
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 db = ref.watch(dbProvider);
final a = await db.remoteAlbums.get(albumId);
final a = await db.albums.get(albumId);
if (a != null) yield a;
await for (final a
in db.remoteAlbums.watchObject(albumId, fireImmediately: true)) {
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
if (a != null) yield a;
}
});
final remoteAlbumRenderlistProvider =
final albumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(remoteAlbumWatcher(albumId)).value;
final album = ref.watch(albumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
@@ -28,25 +76,3 @@ final remoteAlbumRenderlistProvider =
}
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,13 +11,9 @@ 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.whereType<RemoteAlbum>().sortedBy((album) => album.createdAt);
final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
@@ -40,12 +36,9 @@ 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.whereType<RemoteAlbum>().sorted((a, b) {
final sorted = albums.sorted((a, b) {
if (a.endDate != null && b.endDate != null) {
return a.endDate!.compareTo(b.endDate!);
}
@@ -56,12 +49,9 @@ 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.whereType<RemoteAlbum>().sorted((a, b) {
final sorted = albums.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(
RemoteAlbum album,
Album album,
String newAlbumTitle,
) async {
AlbumService service = ref.watch(albumServiceProvider);
@@ -1,4 +1,4 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_album.provider.g.dart';
@@ -1,30 +0,0 @@
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/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.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() async {
final hasChanges =
await ref.read(localAlbumServiceProvider).refreshDeviceAlbums();
if (hasChanges) {
ref.read(backupAlbumsProvider.notifier).refreshAlbumAssetsState();
ref.invalidate(deviceAssetsProvider);
}
}
}
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumsHash() => r'15274162ef40aa87f498a5cf6f8f8a5e0d4c69a8';
/// 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
@@ -1,17 +0,0 @@
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/backup/services/backup_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.read(backupAlbumServiceProvider),
);
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album_service.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumServiceHash() => r'362ac6700cf340ace2d6de4c691e1e900d3a28c8';
/// 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
@@ -1,32 +0,0 @@
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, []);
}
@@ -1,25 +0,0 @@
// 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,17 +2,16 @@ 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<RemoteAlbum>> {
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
final query =
db.remoteAlbums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) {
if (mounted) {
state = value;
@@ -22,9 +21,9 @@ class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
}
final AlbumService _albumService;
late final StreamSubscription<List<RemoteAlbum>> _streamSub;
late final StreamSubscription<List<Album>> _streamSub;
Future<RemoteAlbum?> createSharedAlbum(
Future<Album?> createSharedAlbum(
String albumName,
Iterable<Asset> assets,
Iterable<User> sharedUsers,
@@ -44,10 +43,9 @@ class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
Future<void> getAllSharedAlbums() =>
_albumService.refreshRemoteAlbums(isShared: true);
Future<bool> deleteAlbum(RemoteAlbum album) =>
_albumService.deleteAlbum(album);
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
Future<bool> leaveAlbum(RemoteAlbum album) async {
Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
@@ -58,11 +56,11 @@ class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
}
}
Future<bool> removeAssetFromAlbum(RemoteAlbum album, Iterable<Asset> assets) {
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
return _albumService.removeAssetFromAlbum(album, assets);
}
Future<bool> removeUserFromAlbum(RemoteAlbum album, User user) async {
Future<bool> removeUserFromAlbum(Album album, User user) async {
final result = await _albumService.removeUserFromAlbum(album, user);
if (result && album.sharedUsers.isEmpty) {
@@ -72,7 +70,7 @@ class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
return result;
}
Future<bool> setActivityEnabled(RemoteAlbum album, bool activityEnabled) {
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
return _albumService.setActivityEnabled(album, activityEnabled);
}
@@ -84,8 +82,7 @@ class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
}
final sharedAlbumProvider =
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<RemoteAlbum>>(
(ref) {
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
@@ -1,12 +0,0 @@
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);
}
@@ -1,158 +0,0 @@
// 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,10 +1,14 @@
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/album/models/album.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/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -14,7 +18,9 @@ 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(
@@ -22,6 +28,7 @@ final albumServiceProvider = Provider(
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
ref.watch(backupServiceProvider),
),
);
@@ -30,6 +37,9 @@ 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(
@@ -37,8 +47,98 @@ 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 {
@@ -70,7 +170,7 @@ class AlbumService {
return changes;
}
Future<RemoteAlbum?> createAlbum(
Future<Album?> createAlbum(
String albumName,
Iterable<Asset> assets, [
Iterable<User> sharedUsers = const [],
@@ -84,8 +184,8 @@ class AlbumService {
),
);
if (remote != null) {
RemoteAlbum album = await RemoteAlbum.fromDto(remote, _db);
await _db.writeTxn(() => _db.remoteAlbums.store(album));
Album album = await Album.remote(remote);
await _db.writeTxn(() => _db.albums.store(album));
return album;
}
} catch (e) {
@@ -103,16 +203,13 @@ class AlbumService {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (null ==
await _db.remoteAlbums
.filter()
.nameEqualTo(proposedName)
.findFirst()) {
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
return proposedName;
}
}
}
Future<RemoteAlbum?> createAlbumWithGeneratedName(
Future<Album?> createAlbumWithGeneratedName(
Iterable<Asset> assets,
) async {
return createAlbum(
@@ -124,17 +221,11 @@ class AlbumService {
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
Iterable<Asset> assets,
RemoteAlbum album,
Album album,
) async {
try {
final remoteAlbum =
await _db.remoteAlbums.where().idEqualTo(album.id).findFirst();
if (remoteAlbum == null) {
return null;
}
var response = await _apiService.albumApi.addAssetsToAlbum(
remoteAlbum.id,
album.remoteId!,
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
);
@@ -153,10 +244,10 @@ class AlbumService {
}
await _db.writeTxn(() async {
await remoteAlbum.assets.update(link: successAssets);
final a = await _db.remoteAlbums.get(remoteAlbum.isarId);
await album.assets.update(link: successAssets);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.remoteAlbums.put(a!);
await _db.albums.put(a!);
});
return AddAssetsResponse(
@@ -173,11 +264,11 @@ class AlbumService {
Future<bool> addAdditionalUserToAlbum(
List<String> sharedUserIds,
RemoteAlbum album,
Album album,
) async {
try {
final result = await _apiService.albumApi.addUsersToAlbum(
album.id,
album.remoteId!,
AddUsersDto(sharedUserIds: sharedUserIds),
);
if (result != null) {
@@ -185,7 +276,7 @@ class AlbumService {
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
album.shared = result.shared;
await _db.writeTxn(() async {
await _db.remoteAlbums.put(album);
await _db.albums.put(album);
await album.sharedUsers.save();
});
return true;
@@ -196,15 +287,15 @@ class AlbumService {
return false;
}
Future<bool> setActivityEnabled(RemoteAlbum album, bool enabled) async {
Future<bool> setActivityEnabled(Album album, bool enabled) async {
try {
final result = await _apiService.albumApi.updateAlbumInfo(
album.id,
album.remoteId!,
UpdateAlbumDto(isActivityEnabled: enabled),
);
if (result != null) {
album.activityEnabled = enabled;
await _db.writeTxn(() => _db.remoteAlbums.put(album));
await _db.writeTxn(() => _db.albums.put(album));
return true;
}
} catch (e) {
@@ -213,20 +304,20 @@ class AlbumService {
return false;
}
Future<bool> deleteAlbum(RemoteAlbum album) async {
Future<bool> deleteAlbum(Album album) async {
try {
final userId = Store.get(StoreKey.currentUser).isarId;
if (album.owner.value?.isarId == userId) {
await _apiService.albumApi.deleteAlbum(album.id);
await _apiService.albumApi.deleteAlbum(album.remoteId!);
}
if (album.shared) {
final foreignAssets =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
final List<RemoteAlbum> albums =
await _db.remoteAlbums.filter().sharedEqualTo(true).findAll();
await _db.writeTxn(() => _db.albums.delete(album.id));
final List<Album> albums =
await _db.albums.filter().sharedEqualTo(true).findAll();
final List<Asset> existing = [];
for (RemoteAlbum a in albums) {
for (Album a in albums) {
existing.addAll(
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
);
@@ -237,7 +328,7 @@ class AlbumService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
}
} else {
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
await _db.writeTxn(() => _db.albums.delete(album.id));
}
return true;
} catch (e) {
@@ -246,9 +337,9 @@ class AlbumService {
return false;
}
Future<bool> leaveAlbum(RemoteAlbum album) async {
Future<bool> leaveAlbum(Album album) async {
try {
await _apiService.albumApi.removeUserFromAlbum(album.id, "me");
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
@@ -257,21 +348,21 @@ class AlbumService {
}
Future<bool> removeAssetFromAlbum(
RemoteAlbum album,
Album album,
Iterable<Asset> assets,
) async {
try {
await _apiService.albumApi.removeAssetFromAlbum(
album.id,
album.remoteId!,
BulkIdsDto(
ids: assets.map((asset) => asset.remoteId!).toList(),
),
);
await _db.writeTxn(() async {
await album.assets.update(unlink: assets);
final a = await _db.remoteAlbums.get(album.isarId);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.remoteAlbums.put(a!);
await _db.albums.put(a!);
});
return true;
@@ -282,21 +373,21 @@ class AlbumService {
}
Future<bool> removeUserFromAlbum(
RemoteAlbum album,
Album album,
User user,
) async {
try {
await _apiService.albumApi.removeUserFromAlbum(
album.id,
album.remoteId!,
user.id,
);
album.sharedUsers.remove(user);
await _db.writeTxn(() async {
await album.sharedUsers.update(unlink: [user]);
final a = await _db.remoteAlbums.get(album.isarId);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.remoteAlbums.put(a!);
await _db.albums.put(a!);
});
return true;
@@ -307,18 +398,18 @@ class AlbumService {
}
Future<bool> changeTitleAlbum(
RemoteAlbum album,
Album album,
String newAlbumTitle,
) async {
try {
await _apiService.albumApi.updateAlbumInfo(
album.id,
album.remoteId!,
UpdateAlbumDto(
albumName: newAlbumTitle,
),
);
album.name = newAlbumTitle;
await _db.writeTxn(() => _db.remoteAlbums.put(album));
await _db.writeTxn(() => _db.albums.put(album));
return true;
} catch (e) {
@@ -1,373 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.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/services/backup_album.service.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 HashService _hashService;
final SyncService _syncService;
final BackupAlbumService _backupAlbumService;
LocalAlbumService(
this._db,
this._hashService,
this._syncService,
this._backupAlbumService,
);
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,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: 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 _backupAlbumService.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/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/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(remoteAlbumsProvider).valueOrNull ?? [];
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final albumService = ref.watch(albumServiceProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect(
() {
// Fetch album updates, e.g., cover image
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return null;
@@ -40,7 +40,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
[],
);
void addToAlbum(RemoteAlbum album) async {
void addToAlbum(Album 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<RemoteAlbum> albums;
final List<RemoteAlbum> sharedAlbums;
final void Function(RemoteAlbum) onAddToAlbum;
final List<Album> albums;
final List<Album> sharedAlbums;
final void Function(Album) onAddToAlbum;
final bool enabled;
const AddToAlbumSliverList({
@@ -46,11 +46,9 @@ class AddToAlbumSliverList extends HookConsumerWidget {
physics: const ClampingScrollPhysics(),
itemCount: sortedSharedAlbums.length,
itemBuilder: (context, index) => AlbumThumbnailListTile(
album: sortedSharedAlbums[index] as RemoteAlbum,
album: sortedSharedAlbums[index],
onTap: enabled
? () => onAddToAlbum(
sortedSharedAlbums[index] as RemoteAlbum,
)
? () => onAddToAlbum(sortedSharedAlbums[index])
: () {},
),
),
@@ -61,7 +59,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = sortedAlbums[offset] as RemoteAlbum;
final album = sortedAlbums[offset];
return AlbumThumbnailListTile(
album: album,
onTap: enabled ? () => onAddToAlbum(album) : () {},
@@ -1,82 +0,0 @@
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,16 +1,12 @@
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/extensions/object_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/album.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
@@ -20,11 +16,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;
@@ -33,6 +29,62 @@ 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(
@@ -46,46 +98,14 @@ class AlbumThumbnailCard extends StatelessWidget {
width: cardSize,
height: cardSize,
child: ClipRRect(
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,
),
borderRadius: BorderRadius.circular(20),
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0, left: 8.0),
padding: const EdgeInsets.only(top: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
@@ -98,14 +118,7 @@ class AlbumThumbnailCard extends StatelessWidget {
),
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: _AlbumTextRow(
album: album,
showAssetCount: showAssetCount,
showOwner: showOwner,
),
),
buildAlbumTextRow(),
],
),
),
@@ -116,55 +129,3 @@ 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 RemoteAlbum album;
final Album album;
final void Function()? onTap;
@override
@@ -61,7 +61,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
behavior: HitTestBehavior.opaque,
onTap: onTap ??
() {
context.pushRoute(RemoteAlbumViewerRoute(albumId: album.isarId));
context.pushRoute(AlbumViewerRoute(albumId: album.id));
},
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 == null
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
@@ -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/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.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 RemoteAlbumViewerAppbar extends HookConsumerWidget
class AlbumViewerAppbar extends HookConsumerWidget
implements PreferredSizeWidget {
const RemoteAlbumViewerAppbar({
const AlbumViewerAppbar({
super.key,
required this.album,
required this.userId,
@@ -25,20 +25,21 @@ class RemoteAlbumViewerAppbar extends HookConsumerWidget
required this.onActivities,
});
final RemoteAlbum album;
final Album album;
final String userId;
final FocusNode titleFocusNode;
final Function(RemoteAlbum album)? onAddPhotos;
final Function(RemoteAlbum album)? onAddUsers;
final Function() onActivities;
final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers;
final Function(Album album) 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.id)) : 0;
final comments = album.shared
? ref.watch(activityStatisticsProvider(album.remoteId!))
: 0;
deleteAlbum() async {
isProcessing.value = true;
@@ -50,8 +51,7 @@ class RemoteAlbumViewerAppbar extends HookConsumerWidget
context
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
} else {
success =
await ref.watch(remoteAlbumsProvider.notifier).deleteAlbum(album);
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
context
.navigateTo(const TabControllerRoute(children: [LibraryRoute()]));
}
@@ -172,7 +172,7 @@ class RemoteAlbumViewerAppbar extends HookConsumerWidget
ListTile(
leading: const Icon(Icons.share_rounded),
onTap: () {
context.pushRoute(SharedLinkEditRoute(albumId: album.id));
context.pushRoute(SharedLinkEditRoute(albumId: album.remoteId));
context.pop();
},
title: const Text(
@@ -228,7 +228,9 @@ class RemoteAlbumViewerAppbar extends HookConsumerWidget
Widget buildActivitiesButton() {
return IconButton(
onPressed: () => onActivities(),
onPressed: () {
onActivities(album);
},
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -289,11 +291,12 @@ class RemoteAlbumViewerAppbar extends HookConsumerWidget
actions: [
if (album.shared && (album.activityEnabled || comments != 0))
buildActivitiesButton(),
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
if (album.isRemote)
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],
);
}
@@ -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 RemoteAlbum album;
final Album album;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({
super.key,
@@ -1,50 +0,0 @@
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,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 RemoteAlbum album;
final Album album;
const AlbumOptionsPage({super.key, required this.album});
@@ -8,7 +8,6 @@ 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';
@@ -18,8 +17,9 @@ 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/remote_album_viewer_appbar.dart';
import 'package:immich_mobile/modules/album/ui/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 RemoteAlbumViewerPage extends HookConsumerWidget {
class AlbumViewerPage extends HookConsumerWidget {
final int albumId;
const RemoteAlbumViewerPage({super.key, required this.albumId});
const AlbumViewerPage({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(remoteAlbumWatcher(albumId));
final album = ref.watch(albumWatcher(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 RemoteAlbumViewerPage 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(RemoteAlbum albumInfo) async {
void onAddPhotosPressed(Album albumInfo) async {
AssetSelectionPageResult? returnPayload =
await context.pushRoute<AssetSelectionPageResult?>(
AssetSelectionRoute(
@@ -90,7 +90,7 @@ class RemoteAlbumViewerPage extends HookConsumerWidget {
}
}
void onAddUsersPressed(RemoteAlbum album) async {
void onAddUsersPressed(Album album) async {
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
SelectAdditionalUserForSharingRoute(album: album),
);
@@ -106,7 +106,7 @@ class RemoteAlbumViewerPage extends HookConsumerWidget {
}
}
Widget buildControlButton(RemoteAlbum album) {
Widget buildControlButton(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
child: SizedBox(
@@ -131,16 +131,16 @@ class RemoteAlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildTitle(RemoteAlbum album) {
Widget buildTitle(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
child: userId == album.ownerId
child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle(
album: album,
titleFocusNode: titleFocusNode,
)
: Padding(
padding: const EdgeInsets.only(left: 8.0, bottom: 24),
padding: const EdgeInsets.only(left: 8.0),
child: Text(
album.name,
style: context.textTheme.headlineMedium,
@@ -149,7 +149,7 @@ class RemoteAlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildAlbumDateRange(RemoteAlbum album) {
Widget buildAlbumDateRange(Album album) {
final DateTime? startDate = album.startDate;
final DateTime? endDate = album.endDate;
@@ -183,7 +183,7 @@ class RemoteAlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildSharedUserIconsRow(RemoteAlbum album) {
Widget buildSharedUserIconsRow(Album album) {
return GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
child: SizedBox(
@@ -207,42 +207,49 @@ class RemoteAlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildHeader(RemoteAlbum album) {
Widget buildHeader(Album album) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTitle(album),
if (album.assets.isNotEmpty) buildAlbumDateRange(album),
if (album.shared && album.sharedUsers.isNotEmpty)
buildSharedUserIconsRow(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
if (album.shared) buildSharedUserIconsRow(album),
],
);
}
onActivitiesPressed(Album album) {
if (album.remoteId != null) {
context.pushRoute(
const ActivitiesRoute(),
);
}
}
return Scaffold(
appBar: ref.watch(multiselectProvider)
? null
: album.when(
data: (data) => RemoteAlbumViewerAppbar(
data: (data) => AlbumViewerAppbar(
titleFocusNode: titleFocusNode,
album: data,
userId: userId,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: () => context.pushRoute(const ActivitiesRoute()),
onActivities: onActivitiesPressed,
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
body: album.widgetWhen(
onData: (data) => MultiselectGrid(
renderListProvider: remoteAlbumRenderlistProvider(albumId),
renderListProvider: albumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
buildControlButton(data),
if (data.isRemote) buildControlButton(data),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
@@ -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(remoteAlbumsProvider.notifier).createAlbum(
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
ref.watch(albumTitleProvider),
selectedAssets.value,
);
if (newAlbum != null) {
ref.watch(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.watch(albumProvider.notifier).getAllAlbums();
selectedAssets.value = {};
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
context.replaceRoute(RemoteAlbumViewerRoute(albumId: newAlbum.isarId));
context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id));
}
}
+288 -167
View File
@@ -1,19 +1,12 @@
// 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/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.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';
@@ -24,37 +17,180 @@ 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(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
ref.read(albumProvider.notifier).getAllAlbums();
return null;
},
[],
);
return Scaffold(
appBar: _LibraryAppBar(),
body: CustomScrollView(
slivers: [
_SilverLibraryNavigationButtons(),
_SilverLibraryRemoteAlbumHeader(),
_SilverLibraryRemoteAlbumGrid(),
_SilverLibraryLocalAlbumHeader(),
_SilverLibraryLocalAlbumGrid(),
],
),
);
}
}
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,
),
),
],
),
);
}
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
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
? InkWell(
onTap: () => context.pushRoute(const TrashRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
@@ -63,148 +199,133 @@ class _LibraryAppBar extends ImmichAppBar {
size: 25,
),
)
: null,
);
}
}
: null;
}
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()),
),
LibraryNavButton(
label: "library_page_archive",
icon: Icons.archive_outlined,
onClick: () => context.navigateTo(const ArchiveRoute()),
),
],
),
return Scaffold(
appBar: ImmichAppBar(
action: shareTrashButton(),
),
);
}
}
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,
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
).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,
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());
}),
],
),
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]),
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(),
],
),
),
),
),
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,
),
),
),
),
),
),
],
),
);
}
@@ -1,37 +0,0 @@
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,
),
);
}
}
@@ -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 RemoteAlbum album;
final Album album;
const SelectAdditionalUserForSharingPage({super.key, required this.album});
@@ -4,7 +4,6 @@ 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';
@@ -52,9 +51,7 @@ class SharingPage extends HookConsumerWidget {
album: sharedAlbums[index],
showOwner: true,
onTap: () => context.pushRoute(
RemoteAlbumViewerRoute(
albumId: sharedAlbums[index].isarId,
),
AlbumViewerRoute(albumId: sharedAlbums[index].id),
),
);
},
@@ -68,7 +65,7 @@ class SharingPage extends HookConsumerWidget {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final album = sharedAlbums[index] as RemoteAlbum;
final album = sharedAlbums[index];
final isOwner = album.ownerId == userId;
return ListTile(
@@ -76,7 +73,7 @@ class SharingPage extends HookConsumerWidget {
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichThumbnail(
asset: album.thumbnail,
asset: album.thumbnail.value,
width: 60,
height: 60,
),
@@ -102,11 +99,8 @@ class SharingPage extends HookConsumerWidget {
style: context.textTheme.bodyMedium,
)
: null,
onTap: () => context.pushRoute(
RemoteAlbumViewerRoute(
albumId: sharedAlbums[index].isarId,
),
),
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)),
);
},
childCount: sharedAlbums.length,
@@ -3,8 +3,7 @@ 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/providers/local_album_service.provider.dart';
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/album/services/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';
@@ -15,7 +14,7 @@ import 'package:immich_mobile/shared/ui/share_dialog.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService;
final ShareService _shareService;
final LocalAlbumService _albumService;
final AlbumService _albumService;
ImageViewerStateNotifier(
this._imageViewerService,
@@ -84,6 +83,6 @@ final imageViewerStateProvider =
((ref) => ImageViewerStateNotifier(
ref.watch(imageViewerServiceProvider),
ref.watch(shareServiceProvider),
ref.watch(localAlbumServiceProvider),
ref.watch(albumServiceProvider),
)),
);
@@ -1,9 +1,7 @@
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';
@@ -41,15 +39,11 @@ 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 is RemoteAlbum && asset.remoteId != null
? ref.watch(
activityStatisticsProvider(
album.tryCast<RemoteAlbum>()!.id,
asset.remoteId,
),
)
: 0;
final comments = album != null &&
album.remoteId != null &&
asset.remoteId != null
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
: 0;
Widget buildFavoriteButton(a) {
return IconButton(
@@ -177,8 +171,7 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(),
if (album != null && album is RemoteAlbum && album.shared)
buildActivitiesButton(),
if (album != null && album.shared) buildActivitiesButton(),
buildMoreInfoButton(),
],
);
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
@@ -10,7 +9,6 @@ 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';
@@ -134,7 +132,7 @@ class GalleryViewerPage extends HookConsumerWidget {
void toggleFavorite(Asset asset) =>
ref.read(assetProvider.notifier).toggleFavorite([asset]);
Future<void> precacheNextImage(int index) async {
void precacheNextImage(int index) {
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
debugPrint('Error precaching next image: $exception, $stackTrace');
@@ -142,7 +140,7 @@ class GalleryViewerPage extends HookConsumerWidget {
if (index < totalAssets && index >= 0) {
final asset = loadAsset(index);
await precacheImage(
precacheImage(
ImmichImage.imageProvider(asset: asset),
context,
onError: onError,
@@ -352,7 +350,7 @@ class GalleryViewerPage extends HookConsumerWidget {
}
handleActivities() {
if (album != null && album is RemoteAlbum && album.shared) {
if (album != null && album.shared && album.remoteId != null) {
context.pushRoute(const ActivitiesRoute());
}
}
@@ -713,21 +711,6 @@ class GalleryViewerPage extends HookConsumerWidget {
[],
);
useEffect(
() {
// No need to await this
unawaited(
// Delay this a bit so we can finish loading the page
Future.delayed(const Duration(milliseconds: 400)).then(
// Precache the next image
(_) => precacheNextImage(currentIndex.value + 1),
),
);
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) {
if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -752,21 +735,14 @@ class GalleryViewerPage extends HookConsumerWidget {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
},
loadingBuilder: (context, event, index) => ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
),
ImmichThumbnail(
asset: asset(),
fit: BoxFit.contain,
),
],
loadingBuilder: (context, event, index) => ImageFiltered(
imageFilter: ui.ImageFilter.blur(
sigmaX: 1,
sigmaY: 1,
),
child: ImmichThumbnail(
asset: asset(),
fit: BoxFit.contain,
),
),
pageController: controller,
@@ -778,16 +754,12 @@ class GalleryViewerPage extends HookConsumerWidget {
),
itemCount: totalAssets,
scrollDirection: Axis.horizontal,
onPageChanged: (value) async {
onPageChanged: (value) {
final next = currentIndex.value < value ? value + 1 : value - 1;
HapticFeedback.selectionClick();
precacheNextImage(next);
currentIndex.value = value;
stackIndex.value = -1;
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// Then precache the next image
unawaited(precacheNextImage(next));
HapticFeedback.selectionClick();
},
builder: (context, index) {
final a =
@@ -846,7 +818,7 @@ class GalleryViewerPage extends HookConsumerWidget {
isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image(
image: provider,
fit: BoxFit.contain,
fit: BoxFit.fitWidth,
height: context.height,
width: context.width,
alignment: Alignment.center,
@@ -40,7 +40,7 @@ class VideoViewerPage extends HookWidget {
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
placeholder: placeholder,
placeholder: SizedBox.expand(child: placeholder),
showControls: showControls && !isMotionVideo,
hideControlsTimer: hideControlsTimer,
customControls: const VideoPlayerControls(),
@@ -58,13 +58,9 @@ class VideoViewerPage extends HookWidget {
if (controller == null) {
return Stack(
children: [
if (placeholder != null) placeholder!,
const Positioned.fill(
child: Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
),
if (placeholder != null) SizedBox.expand(child: placeholder!),
const DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
],
);
@@ -4,26 +4,22 @@ 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';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.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/services/backup.service.dart';
import 'package:immich_mobile/modules/backup/services/backup_album.service.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/hash.service.dart';
import 'package:immich_mobile/shared/services/sync.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';
@@ -350,43 +346,48 @@ class BackgroundService {
AppSettingsService settingService = AppSettingsService();
BackupService backupService = BackupService(apiService, db, settingService);
AppSettingsService settingsService = AppSettingsService();
HashService hashService = HashService(db, this);
SyncService syncService = SyncService(db);
BackupAlbumService backupAlbumService = BackupAlbumService(db);
LocalAlbumService localAlbumService =
LocalAlbumService(db, hashService, syncService, backupAlbumService);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
if (selectedAlbums.isEmpty) {
return true;
}
await PhotoManager.setIgnorePermissionCheck(true);
do {
await localAlbumService.refreshDeviceAlbums();
final idsToBackup = await db.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.idProperty()
.findAll();
final localAssetsToBackup = await db.assets
.where()
.remoteIdIsNull()
.filter()
.anyOf(idsToBackup, (q, id) => q.localIdEqualTo(id))
.findAll();
final toUpload =
await backupService.remoteAlreadyUploaded(localAssetsToBackup);
if (toUpload.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process");
return false;
}
final bool backupOk = await _runBackup(
backupService,
settingsService,
toUpload.toList(),
selectedAlbums,
excludedAlbums,
);
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);
});
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
Store.put(StoreKey.backupFailedSince, DateTime.now());
return false;
@@ -401,7 +402,8 @@ class BackgroundService {
Future<bool> _runBackup(
BackupService backupService,
AppSettingsService settingsService,
List<Asset> toUpload,
List<BackupAlbum> selectedAlbums,
List<BackupAlbum> excludedAlbums,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
final bool notifyTotalProgress = settingsService
@@ -409,10 +411,32 @@ class BackgroundService {
final bool notifySingleProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
if (_canceledBySystem || toUpload.isEmpty) {
if (_canceledBySystem) {
return false;
}
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
selectedAlbums,
excludedAlbums,
);
try {
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
} catch (e) {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_connection_failed_message".tr(),
);
return false;
}
if (_canceledBySystem) {
return false;
}
if (toUpload.isEmpty) {
return true;
}
_assetsToUploadCount = toUpload.length;
_uploadedAssetsCount = 0;
_updateNotification(
@@ -0,0 +1,48 @@
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,50 +1,22 @@
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;
@enumerated
BackupSelection selection;
static const albumLinkId = 'album';
final album = IsarLink<LocalAlbum>();
BackupAlbum({
required this.id,
this.selection = BackupSelection.none,
});
BackupAlbum copyWith({
String? id,
BackupSelection? selection,
}) {
return BackupAlbum(
id: id ?? this.id,
selection: selection ?? this.selection,
);
}
@override
String toString() => 'BackupAlbum(id: $id, selection: $selection)';
@override
bool operator ==(covariant BackupAlbum other) {
if (identical(this, other)) return true;
return other.id == id && other.selection == selection;
}
@override
int get hashCode => id.hashCode ^ selection.hashCode;
}
+110 -131
View File
@@ -17,16 +17,16 @@ const BackupAlbumSchema = CollectionSchema(
name: r'BackupAlbum',
id: 8308487201128361847,
properties: {
r'hashCode': PropertySchema(
id: 0,
name: r'hashCode',
type: IsarType.long,
),
r'id': PropertySchema(
id: 1,
id: 0,
name: r'id',
type: IsarType.string,
),
r'lastBackup': PropertySchema(
id: 1,
name: r'lastBackup',
type: IsarType.dateTime,
),
r'selection': PropertySchema(
id: 2,
name: r'selection',
@@ -40,14 +40,7 @@ const BackupAlbumSchema = CollectionSchema(
deserializeProp: _backupAlbumDeserializeProp,
idName: r'isarId',
indexes: {},
links: {
r'album': LinkSchema(
id: 4803574038667272895,
name: r'album',
target: r'LocalAlbum',
single: true,
)
},
links: {},
embeddedSchemas: {},
getId: _backupAlbumGetId,
getLinks: _backupAlbumGetLinks,
@@ -71,8 +64,8 @@ void _backupAlbumSerialize(
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeLong(offsets[0], object.hashCode);
writer.writeString(offsets[1], object.id);
writer.writeString(offsets[0], object.id);
writer.writeDateTime(offsets[1], object.lastBackup);
writer.writeByte(offsets[2], object.selection.index);
}
@@ -83,10 +76,10 @@ BackupAlbum _backupAlbumDeserialize(
Map<Type, List<int>> allOffsets,
) {
final object = BackupAlbum(
id: reader.readString(offsets[1]),
selection:
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
BackupSelection.none,
reader.readString(offsets[0]),
reader.readDateTime(offsets[1]),
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
BackupSelection.none,
);
return object;
}
@@ -99,9 +92,9 @@ P _backupAlbumDeserializeProp<P>(
) {
switch (propertyId) {
case 0:
return (reader.readLong(offset)) as P;
case 1:
return (reader.readString(offset)) as P;
case 1:
return (reader.readDateTime(offset)) as P;
case 2:
return (_BackupAlbumselectionValueEnumMap[
reader.readByteOrNull(offset)] ??
@@ -127,13 +120,11 @@ Id _backupAlbumGetId(BackupAlbum object) {
}
List<IsarLinkBase<dynamic>> _backupAlbumGetLinks(BackupAlbum object) {
return [object.album];
return [];
}
void _backupAlbumAttach(
IsarCollection<dynamic> col, Id id, BackupAlbum object) {
object.album.attach(col, col.isar.collection<LocalAlbum>(), r'album', id);
}
IsarCollection<dynamic> col, Id id, BackupAlbum object) {}
extension BackupAlbumQueryWhereSort
on QueryBuilder<BackupAlbum, BackupAlbum, QWhere> {
@@ -218,61 +209,6 @@ 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,
@@ -457,6 +393,62 @@ extension BackupAlbumQueryFilter
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupEqualTo(DateTime value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupGreaterThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupLessThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupBetween(
DateTime lower,
DateTime upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'lastBackup',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectionEqualTo(BackupSelection value) {
return QueryBuilder.apply(this, (query) {
@@ -518,35 +510,10 @@ extension BackupAlbumQueryObject
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
extension BackupAlbumQueryLinks
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);
});
}
}
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
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);
@@ -559,6 +526,18 @@ extension BackupAlbumQuerySortBy
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackupDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.asc);
@@ -574,18 +553,6 @@ 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);
@@ -610,6 +577,18 @@ extension BackupAlbumQuerySortThenBy
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackupDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.asc);
@@ -625,12 +604,6 @@ 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) {
@@ -638,6 +611,12 @@ extension BackupAlbumQueryWhereDistinct
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'lastBackup');
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'selection');
@@ -653,18 +632,18 @@ 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');
});
}
QueryBuilder<BackupAlbum, DateTime, QQueryOperations> lastBackupProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'lastBackup');
});
}
QueryBuilder<BackupAlbum, BackupSelection, QQueryOperations>
selectionProperty() {
return QueryBuilder.apply(this, (query) {
@@ -1,39 +0,0 @@
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;
}
@@ -1,57 +0,0 @@
class BackupSetting {
final bool autoBackup;
final bool backgroundBackup;
final bool backupRequireWifi;
final bool backupRequireCharging;
final int backupTriggerDelay;
const BackupSetting({
required this.autoBackup,
required this.backgroundBackup,
required this.backupRequireWifi,
required this.backupRequireCharging,
required this.backupTriggerDelay,
});
BackupSetting copyWith({
bool? autoBackup,
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
int? backupTriggerDelay,
}) {
return BackupSetting(
autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
backupRequireCharging:
backupRequireCharging ?? this.backupRequireCharging,
backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay,
);
}
@override
String toString() {
return 'BackupSettings(autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay)';
}
@override
bool operator ==(covariant BackupSetting other) {
if (identical(this, other)) return true;
return other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup &&
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
other.backupTriggerDelay == backupTriggerDelay;
}
@override
int get hashCode {
return autoBackup.hashCode ^
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
backupTriggerDelay.hashCode;
}
}

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