forked from Cutlery/immich
Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b560249eed | |||
| 14978ece65 | |||
| fd3503e77d | |||
| ebe7a14c14 | |||
| f03381a5b1 | |||
| 8d44afe915 | |||
| db455060f0 | |||
| b63b42d3d7 | |||
| a4e6c43823 | |||
| 7303fab9d9 | |||
| 8b02f18e99 | |||
| 670a3838a3 | |||
| 3e06062974 | |||
| 3b772a772c | |||
| 55ecfafa82 | |||
| c89d91e006 | |||
| 15a4a4aaaa | |||
| 3d25d91e77 | |||
| efa6efd200 | |||
| 369acc7bea | |||
| 100363c7be | |||
| af0de1a768 | |||
| 09a7291527 | |||
| bb3d81bfc5 | |||
| f1331905f0 | |||
| 7eb8e2ff9c | |||
| dc7a329cc8 | |||
| 11de526bcf | |||
| 2e56e777ce | |||
| 6f53e83d49 | |||
| b1a896ba61 | |||
| d28abaad7b | |||
| 79442fc8a1 | |||
| 93f0a866a3 | |||
| 84fe41df31 | |||
| e4f32a045d | |||
| 784d92dbb3 | |||
| c88184673a | |||
| 74d431f881 | |||
| e2c0945bc1 | |||
| a02a24f349 | |||
| 87a7825cbc | |||
| f0ea99cea9 | |||
| 0d2a656aa1 | |||
| 6d91c23f65 | |||
| df02a9f5ed | |||
| 2702bcc407 | |||
| 807cd245f4 | |||
| dc0f8756f5 | |||
| df9ab8943d | |||
| 79409438a7 | |||
| b15eec7ca7 | |||
| 908104299d | |||
| c94874296c | |||
| 8361130351 | |||
| 907a95a746 | |||
| 57f25855d3 | |||
| e02964ca0d | |||
| fd301a3261 | |||
| d76baee50d | |||
| 5e485e35e9 | |||
| cfb49c8be0 | |||
| 2f121af9ec | |||
| 21feb69083 | |||
| fb18129843 | |||
| 9fa2424652 | |||
| 7d1edddd51 | |||
| 3f18a936b2 | |||
| e20d3048cd | |||
| 8965c25f54 | |||
| 7e18e69c1c | |||
| d799bf7910 | |||
| 4272b496ff | |||
| 99a002b947 | |||
| c8bdeb8fec | |||
| 8a05ff51e9 | |||
| 3e8af16270 | |||
| 45ecb629a1 | |||
| 943105ea20 | |||
| 289194a356 | |||
| 2a75f884d9 | |||
| 912d723281 | |||
| 038e69fd02 | |||
| 9d3ed719e0 | |||
| 6ec4c5874b | |||
| 57758293e5 | |||
| bc3979029d | |||
| 878932f87e | |||
| a2934b8830 | |||
| 719dbcc4d0 | |||
| cc6de7d1f1 | |||
| 78ece4ced9 | |||
| ee58569b42 | |||
| 07d747221e | |||
| 3cd3411c1f | |||
| b3b6426695 | |||
| 6bb30291de | |||
| 2c9dd18f1b | |||
| 07ef008b40 | |||
| 3f93fda4e4 | |||
| 108e10f175 | |||
| 7023143bce | |||
| e4a8e77c67 | |||
| 8e5ce7f684 | |||
| f86f073d50 | |||
| d7580a3413 | |||
| 324890e182 | |||
| 7cccd216c2 | |||
| c98a8e1bb1 | |||
| 989a406721 | |||
| 7f20d689ea | |||
| f168f9e117 | |||
| 8bd90669c0 | |||
| 8f7e06bebd | |||
| eac150114f | |||
| 296ae54335 |
+13
-12
@@ -1,30 +1,31 @@
|
||||
.vscode/
|
||||
.github/
|
||||
.git/
|
||||
|
||||
design/
|
||||
docker/
|
||||
docs/
|
||||
e2e/
|
||||
fastlane/
|
||||
machine-learning/
|
||||
misc/
|
||||
mobile/
|
||||
|
||||
server/node_modules/
|
||||
cli/coverage/
|
||||
cli/dist/
|
||||
cli/node_modules/
|
||||
|
||||
open-api/typescript-sdk/build/
|
||||
open-api/typescript-sdk/node_modules/
|
||||
|
||||
server/coverage/
|
||||
server/.reverse-geocoding-dump/
|
||||
server/node_modules/
|
||||
server/upload/
|
||||
server/dist/
|
||||
server/www/
|
||||
server/test/assets/
|
||||
|
||||
web/node_modules/
|
||||
web/coverage/
|
||||
web/.svelte-kit
|
||||
web/build/
|
||||
|
||||
cli/node_modules/
|
||||
cli/.reverse-geocoding-dump/
|
||||
cli/upload/
|
||||
cli/dist/
|
||||
|
||||
e2e/
|
||||
|
||||
open-api/typescript-sdk/node_modules/
|
||||
open-api/typescript-sdk/build/
|
||||
|
||||
+1
-1
@@ -16,4 +16,4 @@ max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
quote_type = double
|
||||
quote_type = single
|
||||
|
||||
@@ -8,8 +8,6 @@ mobile/openapi/.openapi-generator/FILES linguist-generated=true
|
||||
mobile/lib/**/*.g.dart -diff -merge
|
||||
mobile/lib/**/*.g.dart linguist-generated=true
|
||||
|
||||
open-api/typescript-sdk/axios-client/**/* -diff -merge
|
||||
open-api/typescript-sdk/axios-client/**/* linguist-generated=true
|
||||
open-api/typescript-sdk/fetch-client.ts -diff -merge
|
||||
open-api/typescript-sdk/fetch-client.ts linguist-generated=true
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
uses: docker/setup-buildx-action@v3.1.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
uses: docker/setup-buildx-action@v3.1.0
|
||||
# Workaround to fix error:
|
||||
# failed to push: failed to copy: io: read/write on closed pipe
|
||||
# See https://github.com/docker/build-push-action/issues/761
|
||||
|
||||
+26
-11
@@ -12,7 +12,7 @@ concurrency:
|
||||
jobs:
|
||||
server-e2e-api:
|
||||
name: Server (e2e-api)
|
||||
runs-on: mich
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
@@ -29,13 +29,13 @@ jobs:
|
||||
|
||||
server-e2e-jobs:
|
||||
name: Server (e2e-jobs)
|
||||
runs-on: mich
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Run e2e tests
|
||||
run: make server-e2e-jobs
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -194,25 +194,40 @@ jobs:
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run setup cli
|
||||
run: npm ci && npm run build
|
||||
working-directory: ./cli
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run formatter
|
||||
run: npm run format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Docker build
|
||||
run: docker compose build
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (api & cli)
|
||||
run: npm run test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (web)
|
||||
run: npx playwright test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
mobile-unit-tests:
|
||||
name: Mobile
|
||||
@@ -222,8 +237,8 @@ jobs:
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.16.9"
|
||||
channel: 'stable'
|
||||
flutter-version: '3.16.9'
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test -j 1
|
||||
@@ -241,7 +256,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
cache: "poetry"
|
||||
cache: 'poetry'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install --with dev --with cpu
|
||||
@@ -279,7 +294,7 @@ jobs:
|
||||
- name: Run API generation
|
||||
run: make open-api
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@v18
|
||||
uses: tj-actions/verify-changed-files@v19
|
||||
id: verify-changed-files
|
||||
with:
|
||||
files: |
|
||||
@@ -334,7 +349,7 @@ jobs:
|
||||
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@v18
|
||||
uses: tj-actions/verify-changed-files@v19
|
||||
id: verify-changed-files
|
||||
with:
|
||||
files: |
|
||||
@@ -352,7 +367,7 @@ jobs:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/immich
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@v18
|
||||
uses: tj-actions/verify-changed-files@v19
|
||||
id: verify-changed-sql-files
|
||||
with:
|
||||
files: |
|
||||
|
||||
Generated
+414
-412
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
||||
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||
image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
|
||||
@@ -38,12 +38,6 @@ services:
|
||||
- /dev/dri:/dev/dri
|
||||
- /dev/dma_heap:/dev/dma_heap
|
||||
- /dev/mpp_service:/dev/mpp_service
|
||||
volumes:
|
||||
- /usr/bin/ffmpeg:/usr/bin/ffmpeg_mpp:ro
|
||||
- /lib/aarch64-linux-gnu:/lib/ffmpeg-mpp:ro
|
||||
- /lib/aarch64-linux-gnu/libblas.so.3:/lib/ffmpeg-mpp/libblas.so.3:ro # symlink is resolved by mounting
|
||||
- /lib/aarch64-linux-gnu/liblapack.so.3:/lib/ffmpeg-mpp/liblapack.so.3:ro # symlink is resolved by mounting
|
||||
- /lib/aarch64-linux-gnu/pulseaudio/libpulsecommon-15.99.so:/lib/ffmpeg-mpp/libpulsecommon-15.99.so:ro
|
||||
|
||||
vaapi:
|
||||
devices:
|
||||
|
||||
@@ -67,9 +67,11 @@ Once you have a new OAuth client application configured, Immich can be configure
|
||||
| Client Secret | string | (required) | Required. Client Secret (previous step) |
|
||||
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
||||
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
||||
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label |
|
||||
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage |
|
||||
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
|
||||
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
|
||||
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
|
||||
| Storage Claim | string | preferred_username | Claim mapping for the user's storage label |
|
||||
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
|
||||
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |
|
||||
|
||||
|
||||
@@ -44,22 +44,13 @@ Below is an example config for Apache2 site configuration.
|
||||
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ServerName <snip>
|
||||
ServerName <snip>
|
||||
ProxyRequests Off
|
||||
ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
|
||||
ProxyPassReverse / http://127.0.0.1:2283/
|
||||
ProxyPreserveHost On
|
||||
|
||||
ProxyRequests off
|
||||
ProxyVia on
|
||||
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_URI} ^/api/socket.io [NC]
|
||||
RewriteCond %{QUERY_STRING} transport=websocket [NC]
|
||||
RewriteRule /(.*) ws://localhost:2283/$1 [P,L]
|
||||
|
||||
ProxyPass /api/socket.io ws://localhost:2283/api/socket.io
|
||||
ProxyPassReverse /api/socket.io ws://localhost:2283/api/socket.io
|
||||
|
||||
<Location />
|
||||
ProxyPass http://localhost:2283/
|
||||
ProxyPassReverse http://localhost:2283/
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
**timeout:** is measured in seconds, and it is particularly useful when long operations are triggered (i.e. Repair), so the server doesn't return an error.
|
||||
|
||||
@@ -88,10 +88,7 @@ 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 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.
|
||||
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.
|
||||
|
||||
### Nightly job
|
||||
|
||||
|
||||
@@ -95,13 +95,16 @@ The default configuration looks like this:
|
||||
"issuerUrl": "",
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"mobileOverrideEnabled": false,
|
||||
"mobileRedirectUri": "",
|
||||
"scope": "openid email profile",
|
||||
"signingAlgorithm": "RS256",
|
||||
"storageLabelClaim": "preferred_username",
|
||||
"storageQuotaClaim": "immich_quota",
|
||||
"defaultStorageQuota": 0,
|
||||
"buttonText": "Login with OAuth",
|
||||
"autoRegister": true,
|
||||
"autoLaunch": false
|
||||
"autoLaunch": false,
|
||||
"mobileOverrideEnabled": false,
|
||||
"mobileRedirectUri": ""
|
||||
},
|
||||
"passwordLogin": {
|
||||
"enabled": true
|
||||
|
||||
@@ -67,7 +67,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
| `DB_PORT` | Database Port | `5432` | server, microservices |
|
||||
| `DB_USERNAME` | Database User | `postgres` | server, microservices |
|
||||
| `DB_PASSWORD` | Database Password | `postgres` | server, microservices |
|
||||
| `DB_DATABASE` | Database Name | `immich` | server, microservices |
|
||||
| `DB_DATABASE_NAME` | Database Name | `immich` | server, microservices |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*1</sup> | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server, microservices |
|
||||
|
||||
\*1: This setting cannot be changed after the server has successfully started up
|
||||
|
||||
@@ -50,12 +50,22 @@ import {
|
||||
mdiVectorCombine,
|
||||
mdiVideo,
|
||||
mdiWeb,
|
||||
mdiScaleBalance,
|
||||
} from '@mdi/js';
|
||||
import Layout from '@theme/Layout';
|
||||
import React from 'react';
|
||||
import Timeline, { DateType, Item } from '../components/timeline';
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
icon: mdiScaleBalance,
|
||||
description: 'Immich switches to AGPLv3 license',
|
||||
title: 'AGPL License',
|
||||
release: 'v1.95.0',
|
||||
tag: 'v1.95.0',
|
||||
date: new Date(2024, 1, 20),
|
||||
dateType: DateType.RELEASE,
|
||||
},
|
||||
{
|
||||
icon: mdiEyeRefreshOutline,
|
||||
description: 'Automatically import files in external libraries when the operating system detects changes.',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'unicorn/prefer-module': 'off',
|
||||
curly: 2,
|
||||
'prettier/prettier': 0,
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'unicorn/filename-case': 'off',
|
||||
'unicorn/no-null': 'off',
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
'unicorn/prefer-event-target': 'off',
|
||||
'unicorn/no-thenable': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
*.json
|
||||
coverage
|
||||
dist
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"organizeImportsSkipDestructiveCodeActions": true,
|
||||
"plugins": ["prettier-plugin-organize-imports"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
version: "3.8"
|
||||
version: '3.8'
|
||||
|
||||
name: immich-e2e
|
||||
|
||||
@@ -16,20 +16,23 @@ 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
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
command: [ "./start.sh", "immich" ]
|
||||
container_name: immich-e2e-server
|
||||
command: ['./start.sh', 'immich']
|
||||
<<: *server-common
|
||||
ports:
|
||||
- 2283:3001
|
||||
|
||||
# immich-microservices:
|
||||
# command: [ "./start.sh", "microservices" ]
|
||||
# <<: *server-common
|
||||
immich-microservices:
|
||||
container_name: immich-e2e-microservices
|
||||
command: ['./start.sh', 'microservices']
|
||||
<<: *server-common
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
||||
|
||||
Generated
+2417
-54
File diff suppressed because it is too large
Load Diff
+19
-1
@@ -7,7 +7,11 @@
|
||||
"scripts": {
|
||||
"test": "vitest --config vitest.config.ts",
|
||||
"test:web": "npx playwright test",
|
||||
"start:web": "npx playwright test --ui"
|
||||
"start:web": "npx playwright test --ui",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -16,11 +20,25 @@
|
||||
"@immich/cli": "file:../cli",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@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",
|
||||
"vitest": "^1.3.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ActivityCreateDto,
|
||||
AlbumResponseDto,
|
||||
AssetResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
LoginResponseDto,
|
||||
ReactionType,
|
||||
createActivity as create,
|
||||
@@ -16,14 +16,11 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
describe('/activity', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let nonOwner: LoginResponseDto;
|
||||
let asset: AssetResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
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();
|
||||
@@ -40,7 +37,7 @@ describe('/activity', () => {
|
||||
sharedWithUserIds: [nonOwner.userId],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -56,13 +53,9 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should require an albumId', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
const { status, body } = await request(app).get('/activity').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should reject an invalid albumId', async () => {
|
||||
@@ -71,9 +64,7 @@ describe('/activity', () => {
|
||||
.query({ albumId: uuidDto.invalid })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId', async () => {
|
||||
@@ -82,9 +73,7 @@ describe('/activity', () => {
|
||||
.query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID']))
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should start off empty', async () => {
|
||||
@@ -104,7 +93,7 @@ describe('/activity', () => {
|
||||
assetIds: [asset.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
|
||||
const [reaction] = await Promise.all([
|
||||
@@ -160,9 +149,7 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
]);
|
||||
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
|
||||
|
||||
const response1 = await request(app)
|
||||
.get('/activity')
|
||||
@@ -215,9 +202,7 @@ describe('/activity', () => {
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: uuidDto.invalid });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a comment when type is comment', async () => {
|
||||
@@ -226,12 +211,7 @@ describe('/activity', () => {
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: uuidDto.notFound, type: 'comment', comment: null });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'comment must be a string',
|
||||
'comment should not be empty',
|
||||
])
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['comment must be a string', 'comment should not be empty']));
|
||||
});
|
||||
|
||||
it('should add a comment to an album', async () => {
|
||||
@@ -271,9 +251,7 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should return a 200 for a duplicate like on the album', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
]);
|
||||
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
@@ -356,9 +334,7 @@ describe('/activity', () => {
|
||||
|
||||
describe('DELETE /activity/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(
|
||||
`/activity/${uuidDto.notFound}`
|
||||
);
|
||||
const { status, body } = await request(app).delete(`/activity/${uuidDto.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -420,9 +396,7 @@ describe('/activity', () => {
|
||||
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest('Not found or no activity.delete access')
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access'));
|
||||
});
|
||||
|
||||
it('should let a non-owner remove their own comment', async () => {
|
||||
@@ -432,7 +406,7 @@ describe('/activity', () => {
|
||||
type: ReactionType.Comment,
|
||||
comment: 'This is a test comment',
|
||||
},
|
||||
nonOwner.accessToken
|
||||
nonOwner.accessToken,
|
||||
);
|
||||
|
||||
const { status } = await request(app)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkType,
|
||||
deleteUser,
|
||||
@@ -21,8 +21,8 @@ const user2NotShared = 'user2NotShared';
|
||||
describe('/album', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user1Asset1: AssetResponseDto;
|
||||
let user1Asset2: AssetResponseDto;
|
||||
let user1Asset1: AssetFileUploadResponseDto;
|
||||
let user1Asset2: AssetFileUploadResponseDto;
|
||||
let user1Albums: AlbumResponseDto[];
|
||||
let user2: LoginResponseDto;
|
||||
let user2Albums: AlbumResponseDto[];
|
||||
@@ -41,7 +41,7 @@ describe('/album', () => {
|
||||
]);
|
||||
|
||||
[user1Asset1, user1Asset2] = await Promise.all([
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken, { isFavorite: true }),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
@@ -93,10 +93,7 @@ describe('/album', () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await deleteUser(
|
||||
{ id: user3.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
await deleteUser({ id: user3.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
});
|
||||
|
||||
describe('GET /album', () => {
|
||||
@@ -111,9 +108,7 @@ describe('/album', () => {
|
||||
.get('/album?shared=invalid')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['shared must be a boolean value'])
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['shared must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId param', async () => {
|
||||
@@ -124,6 +119,17 @@ describe('/album', () => {
|
||||
expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
|
||||
});
|
||||
|
||||
it("should not show other users' favorites", async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining({ isFavorite: false })],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return shared albums with a deleted owner', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/album?shared=true')
|
||||
@@ -148,14 +154,12 @@ describe('/album', () => {
|
||||
albumName: user2SharedUser,
|
||||
shared: true,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -175,7 +179,7 @@ describe('/album', () => {
|
||||
albumName: user1NotShared,
|
||||
shared: false,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -202,7 +206,7 @@ describe('/album', () => {
|
||||
albumName: user2SharedUser,
|
||||
shared: true,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -219,7 +223,7 @@ describe('/album', () => {
|
||||
albumName: user1NotShared,
|
||||
shared: false,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -250,9 +254,7 @@ describe('/album', () => {
|
||||
|
||||
describe('GET /album/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
`/album/${user1Albums[0].id}`
|
||||
);
|
||||
const { status, body } = await request(app).get(`/album/${user1Albums[0].id}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -265,7 +267,7 @@ describe('/album', () => {
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining(user1Albums[0].assets[0])],
|
||||
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,7 +279,7 @@ describe('/album', () => {
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user2Albums[0],
|
||||
assets: [expect.objectContaining(user2Albums[0].assets[0])],
|
||||
assets: [expect.objectContaining({ id: user2Albums[0].assets[0].id })],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -289,7 +291,7 @@ describe('/album', () => {
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining(user1Albums[0].assets[0])],
|
||||
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,9 +328,7 @@ describe('/album', () => {
|
||||
|
||||
describe('POST /album', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/album')
|
||||
.send({ albumName: 'New album' });
|
||||
const { status, body } = await request(app).post('/album').send({ albumName: 'New album' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -360,9 +360,7 @@ describe('/album', () => {
|
||||
|
||||
describe('PUT /album/:id/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(
|
||||
`/album/${user1Albums[0].id}/assets`
|
||||
);
|
||||
const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/assets`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -375,9 +373,7 @@ describe('/album', () => {
|
||||
.send({ ids: [asset.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: asset.id, success: true }),
|
||||
]);
|
||||
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
|
||||
});
|
||||
|
||||
it('should be able to add own asset to shared album', async () => {
|
||||
@@ -388,9 +384,7 @@ describe('/album', () => {
|
||||
.send({ ids: [asset.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: asset.id, success: true }),
|
||||
]);
|
||||
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,9 +467,7 @@ describe('/album', () => {
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: user1Asset1.id, success: true }),
|
||||
]);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from shared album', async () => {
|
||||
@@ -485,9 +477,7 @@ describe('/album', () => {
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: user1Asset1.id, success: true }),
|
||||
]);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -501,9 +491,7 @@ describe('/album', () => {
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/album/${user1Albums[0].id}/users`)
|
||||
.send({ sharedUserIds: [] });
|
||||
const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/users`).send({ sharedUserIds: [] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -519,7 +507,7 @@ describe('/album', () => {
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
sharedUsers: [expect.objectContaining({ id: user2.userId })],
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
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 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);
|
||||
};
|
||||
|
||||
const today = DateTime.fromObject({
|
||||
year: 2023,
|
||||
month: 11,
|
||||
day: 3,
|
||||
}) as DateTime<true>;
|
||||
const yesterday = today.minus({ days: 1 });
|
||||
|
||||
describe('/asset', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
let userStats: LoginResponseDto;
|
||||
let user1Assets: AssetFileUploadResponseDto[];
|
||||
let user2Assets: AssetFileUploadResponseDto[];
|
||||
let assetLocation: 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),
|
||||
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([
|
||||
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),
|
||||
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' },
|
||||
}),
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
describe('GET /asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/asset/${uuidDto.notFound}`);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user2Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
it('should get the asset info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id });
|
||||
});
|
||||
|
||||
it('should work with a shared link', async () => {
|
||||
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [user1Assets[0].id],
|
||||
});
|
||||
|
||||
const { status, body } = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id });
|
||||
});
|
||||
|
||||
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}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
isFavorite: false,
|
||||
people: [
|
||||
{
|
||||
birthDate: null,
|
||||
id: expect.any(String),
|
||||
isHidden: false,
|
||||
name: 'Test Person',
|
||||
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [user1Assets[0].id],
|
||||
});
|
||||
|
||||
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
|
||||
expect(data.status).toBe(200);
|
||||
expect(data.body).toMatchObject({ people: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/asset/statistics');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return stats of all assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`);
|
||||
|
||||
expect(body).toEqual({ images: 3, videos: 1, total: 4 });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return stats of all favored assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isFavorite: true });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 1, videos: 1, total: 2 });
|
||||
});
|
||||
|
||||
it('should return stats of all archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isArchived: true });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 1, videos: 1, total: 2 });
|
||||
});
|
||||
|
||||
it('should return stats of all favored and archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isFavorite: true, isArchived: true });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 0, videos: 1, total: 1 });
|
||||
});
|
||||
|
||||
it('should return stats of all assets neither favored nor archived', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isFavorite: false, isArchived: false });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 1, videos: 0, total: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/random', () => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all([
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/asset/random');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it.each(TEN_TIMES)('should return 1 random assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const assets: AssetResponseDto[] = body;
|
||||
expect(assets.length).toBe(1);
|
||||
expect(assets[0].ownerId).toBe(user1.userId);
|
||||
});
|
||||
|
||||
it.each(TEN_TIMES)('should return 2 random assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random?count=2')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const assets: AssetResponseDto[] = body;
|
||||
expect(assets.length).toBe(2);
|
||||
|
||||
for (const asset of assets) {
|
||||
expect(asset.ownerId).toBe(user1.userId);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(TEN_TIMES)(
|
||||
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
|
||||
async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random')
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
|
||||
},
|
||||
);
|
||||
|
||||
it('should return error', async () => {
|
||||
const { status } = await request(app)
|
||||
.get('/asset/random?count=ABC')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/asset/:${uuidDto.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user2Assets[0].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);
|
||||
expect(before.isFavorite).toBe(false);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should archive an asset', async () => {
|
||||
const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
|
||||
expect(before.isArchived).toBe(false);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isArchived: true });
|
||||
expect(body).toMatchObject({ id: user1Assets[0].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}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
dateTimeOriginal: '2023-11-20T01:11:00.000Z',
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should reject invalid gps coordinates', async () => {
|
||||
for (const test of [
|
||||
{ latitude: 12 },
|
||||
{ longitude: 12 },
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
{ latitude: null, longitude: 12 },
|
||||
{ latitude: 12, longitude: null },
|
||||
{ latitude: 91, longitude: 12 },
|
||||
{ latitude: -91, longitude: 12 },
|
||||
{ latitude: 12, longitude: -181 },
|
||||
{ latitude: 12, longitude: 181 },
|
||||
]) {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.send(test)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
it('should update gps data', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ latitude: 12, longitude: 12 });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should set the description', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ description: 'Test asset description' });
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
description: 'Test asset description',
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should return tagged people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
isFavorite: true,
|
||||
people: [
|
||||
{
|
||||
birthDate: null,
|
||||
id: expect.any(String),
|
||||
isHidden: false,
|
||||
name: 'Test Person',
|
||||
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /asset', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.send({ ids: [uuidDto.notFound] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.send({ ids: [uuidDto.invalid] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.send({ ids: [uuidDto.notFound] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no asset.delete access'));
|
||||
});
|
||||
|
||||
it('should move an asset to the trash', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
|
||||
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(before.isTrashed).toBe(false);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete('/asset')
|
||||
.send({ ids: [assetId] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk';
|
||||
import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/audit', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
await fileUtils.reset();
|
||||
|
||||
admin = await apiUtils.adminSetup();
|
||||
});
|
||||
|
||||
describe('GET :/file-report', () => {
|
||||
it('excludes assets without issues from report', async () => {
|
||||
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) }),
|
||||
updateAsset(
|
||||
{
|
||||
id: archivedAsset.id,
|
||||
updateAssetDto: { isArchived: true },
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
),
|
||||
]);
|
||||
|
||||
const body = await getAuditFiles({
|
||||
headers: asBearerAuth(admin.accessToken),
|
||||
});
|
||||
|
||||
expect(body.orphans).toHaveLength(0);
|
||||
expect(body.extras).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,6 @@
|
||||
import {
|
||||
LoginResponseDto,
|
||||
getAuthDevices,
|
||||
login,
|
||||
signUpAdmin,
|
||||
} from '@immich/sdk';
|
||||
import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk';
|
||||
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
|
||||
import {
|
||||
deviceDto,
|
||||
errorDto,
|
||||
loginResponseDto,
|
||||
signupResponseDto,
|
||||
} from 'src/responses';
|
||||
import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -48,18 +38,14 @@ describe(`/auth/admin-sign-up`, () => {
|
||||
|
||||
for (const { should, data } of invalid) {
|
||||
it(`should ${should}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send(data);
|
||||
const { status, body } = await request(app).post('/auth/admin-sign-up').send(data);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it(`should sign up the admin`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send(signupDto.admin);
|
||||
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(signupResponseDto.admin);
|
||||
});
|
||||
@@ -86,9 +72,7 @@ describe(`/auth/admin-sign-up`, () => {
|
||||
it('should not allow a second admin to sign up', async () => {
|
||||
await signUpAdmin({ signUpDto: signupDto.admin });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send(signupDto.admin);
|
||||
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.alreadyHasAdmin);
|
||||
@@ -107,9 +91,7 @@ describe('/auth/*', () => {
|
||||
|
||||
describe(`POST /auth/login`, () => {
|
||||
it('should reject an incorrect password', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({ email, password: 'incorrect' });
|
||||
const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.incorrectLogin);
|
||||
});
|
||||
@@ -125,9 +107,7 @@ describe('/auth/*', () => {
|
||||
}
|
||||
|
||||
it('should accept a correct password', async () => {
|
||||
const { status, body, headers } = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({ email, password });
|
||||
const { status, body, headers } = await request(app).post('/auth/login').send({ email, password });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(loginResponseDto.admin);
|
||||
|
||||
@@ -136,15 +116,9 @@ describe('/auth/*', () => {
|
||||
|
||||
const cookies = headers['set-cookie'];
|
||||
expect(cookies).toHaveLength(3);
|
||||
expect(cookies[0]).toEqual(
|
||||
`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`
|
||||
);
|
||||
expect(cookies[1]).toEqual(
|
||||
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'
|
||||
);
|
||||
expect(cookies[2]).toEqual(
|
||||
'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'
|
||||
);
|
||||
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
|
||||
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
|
||||
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,18 +150,12 @@ describe('/auth/*', () => {
|
||||
await login({ loginCredentialDto: loginDto.admin });
|
||||
}
|
||||
|
||||
await expect(
|
||||
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
|
||||
).resolves.toHaveLength(6);
|
||||
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/auth/devices`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
const { status } = await request(app).delete(`/auth/devices`).set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
await expect(
|
||||
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
|
||||
).resolves.toHaveLength(1);
|
||||
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw an error for a non-existent device id', async () => {
|
||||
@@ -195,9 +163,7 @@ describe('/auth/*', () => {
|
||||
.delete(`/auth/devices/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest('Not found or no authDevice.delete access')
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
|
||||
});
|
||||
|
||||
it('should logout a device', async () => {
|
||||
@@ -219,9 +185,7 @@ describe('/auth/*', () => {
|
||||
|
||||
describe('POST /auth/validateToken', () => {
|
||||
it('should reject an invalid token', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/auth/validateToken`)
|
||||
.set('Authorization', 'Bearer 123');
|
||||
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.invalidToken);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
@@ -6,7 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/download', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetResponseDto;
|
||||
let asset1: AssetFileUploadResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
@@ -35,16 +35,14 @@ describe('/download', () => {
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
archives: [expect.objectContaining({ assetIds: [asset1.id] })],
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(
|
||||
`/download/asset/${asset1.id}`
|
||||
);
|
||||
const { status, body } = await request(app).post(`/download/asset/${asset1.id}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -56,7 +54,7 @@ describe('/download', () => {
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toEqual('image/jpeg');
|
||||
expect(response.headers['content-type']).toEqual('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk';
|
||||
import { userDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils, testAssetDirInternal } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/library', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user: LoginResponseDto;
|
||||
let library: LibraryResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup();
|
||||
user = await apiUtils.userSetup(admin.accessToken, userDto.user1);
|
||||
library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
|
||||
});
|
||||
|
||||
describe('GET /library', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/library');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should start with a default upload library', async () => {
|
||||
const { status, body } = await request(app).get('/library').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ownerId: admin.userId,
|
||||
type: LibraryType.Upload,
|
||||
name: 'Default Library',
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [],
|
||||
exclusionPatterns: [],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /library', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/library').send({});
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require admin authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.send({ type: LibraryType.External });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
it('should create an external library with defaults', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: LibraryType.External });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerId: admin.userId,
|
||||
type: LibraryType.External,
|
||||
name: 'New External Library',
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [],
|
||||
exclusionPatterns: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an external library with options', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
type: LibraryType.External,
|
||||
name: 'My Awesome Library',
|
||||
importPaths: ['/path/to/import'],
|
||||
exclusionPatterns: ['**/Raw/**'],
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'My Awesome Library',
|
||||
importPaths: ['/path/to/import'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create an external library with duplicate import paths', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
type: LibraryType.External,
|
||||
name: 'My Awesome Library',
|
||||
importPaths: ['/path', '/path'],
|
||||
exclusionPatterns: ['**/Raw/**'],
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
|
||||
});
|
||||
|
||||
it('should not create an external library with duplicate exclusion patterns', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
type: LibraryType.External,
|
||||
name: 'My Awesome Library',
|
||||
importPaths: ['/path/to/import'],
|
||||
exclusionPatterns: ['**/Raw/**', '**/Raw/**'],
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
|
||||
});
|
||||
|
||||
it('should create an upload library with defaults', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: LibraryType.Upload });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerId: admin.userId,
|
||||
type: LibraryType.Upload,
|
||||
name: 'New Upload Library',
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [],
|
||||
exclusionPatterns: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an upload library with options', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: LibraryType.Upload, name: 'My Awesome Library' });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'My Awesome Library',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow upload libraries to have import paths', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
|
||||
});
|
||||
|
||||
it('should not allow upload libraries to have exclusion patterns', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/library')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /library/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/library/${uuidDto.notFound}`).send({});
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should change the library name', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ name: 'New Library Name' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'New Library Name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set an empty name', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ name: '' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
|
||||
});
|
||||
|
||||
it('should change the import paths', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ importPaths: [testAssetDirInternal] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
importPaths: [testAssetDirInternal],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an empty import path', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ importPaths: [''] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
|
||||
});
|
||||
|
||||
it('should reject duplicate import paths', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ importPaths: ['/path', '/path'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
|
||||
});
|
||||
|
||||
it('should change the exclusion pattern', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ exclusionPatterns: ['**/Raw/**'] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
exclusionPatterns: ['**/Raw/**'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject duplicate exclusion patterns', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
|
||||
});
|
||||
|
||||
it('should reject an empty exclusion pattern', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ exclusionPatterns: [''] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /library/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require admin access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/library/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${user.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
it('should get library by id', async () => {
|
||||
const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerId: admin.userId,
|
||||
type: LibraryType.External,
|
||||
name: 'New External Library',
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [],
|
||||
exclusionPatterns: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /library/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(`/library/${uuidDto.notFound}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should not delete the last upload library', async () => {
|
||||
const libraries = await getAllLibraries(
|
||||
{ $type: LibraryType.Upload },
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
|
||||
const adminLibraries = libraries.filter((library) => library.ownerId === admin.userId);
|
||||
expect(adminLibraries.length).toBeGreaterThanOrEqual(1);
|
||||
const lastLibrary = adminLibraries.pop() as LibraryResponseDto;
|
||||
|
||||
// delete all but the last upload library
|
||||
for (const library of adminLibraries) {
|
||||
const { status } = await request(app)
|
||||
.delete(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
}
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/library/${lastLibrary.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(body).toEqual(errorDto.noDeleteUploadLibrary);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('should delete an external library', async () => {
|
||||
const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(204);
|
||||
expect(body).toEqual({});
|
||||
|
||||
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(libraries).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: library.id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /library/:id/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}/statistics`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /library/:id/scan', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/scan`).send({});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /library/:id/removeOffline', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/removeOffline`).send({});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /library/:id/validate', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/validate`).send({});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should pass with no import paths', async () => {
|
||||
const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { importPaths: [] });
|
||||
expect(response.importPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fail if path does not exist', async () => {
|
||||
const pathToTest = `${testAssetDirInternal}/does/not/exist`;
|
||||
|
||||
const response = await apiUtils.validateLibrary(admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Path does not exist (ENOENT)`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if path is a file', async () => {
|
||||
const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`;
|
||||
|
||||
const response = await apiUtils.validateLibrary(admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Not a directory`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,9 @@ describe(`/oauth`, () => {
|
||||
|
||||
describe('POST /oauth/authorize', () => {
|
||||
it(`should throw an error if a redirect uri is not provided`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/oauth/authorize')
|
||||
.send({});
|
||||
const { status, body } = await request(app).post('/oauth/authorize').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'redirectUri must be a string',
|
||||
'redirectUri should not be empty',
|
||||
])
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,14 +24,8 @@ describe('/partner', () => {
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
createPartner(
|
||||
{ id: user2.userId },
|
||||
{ headers: asBearerAuth(user1.accessToken) }
|
||||
),
|
||||
createPartner(
|
||||
{ id: user1.userId },
|
||||
{ headers: asBearerAuth(user2.accessToken) }
|
||||
),
|
||||
createPartner({ id: user2.userId }, { headers: asBearerAuth(user1.accessToken) }),
|
||||
createPartner({ id: user1.userId }, { headers: asBearerAuth(user2.accessToken) }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -66,9 +60,7 @@ describe('/partner', () => {
|
||||
|
||||
describe('POST /partner/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(
|
||||
`/partner/${user3.userId}`
|
||||
);
|
||||
const { status, body } = await request(app).post(`/partner/${user3.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -89,17 +81,13 @@ describe('/partner', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Partner already exists' })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /partner/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(
|
||||
`/partner/${user2.userId}`
|
||||
);
|
||||
const { status, body } = await request(app).put(`/partner/${user2.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -112,17 +100,13 @@ describe('/partner', () => {
|
||||
.send({ inTimeline: false });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ id: user2.userId, inTimeline: false })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /partner/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(
|
||||
`/partner/${user3.userId}`
|
||||
);
|
||||
const { status, body } = await request(app).delete(`/partner/${user3.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -142,9 +126,7 @@ describe('/partner', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Partner not found' })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,9 +65,7 @@ describe('/activity', () => {
|
||||
});
|
||||
|
||||
it('should return only visible people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/person')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
const { status, body } = await request(app).get('/person').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
@@ -80,9 +78,7 @@ describe('/activity', () => {
|
||||
|
||||
describe('GET /person/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
`/person/${uuidDto.notFound}`
|
||||
);
|
||||
const { status, body } = await request(app).get(`/person/${uuidDto.notFound}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -109,9 +105,7 @@ describe('/activity', () => {
|
||||
|
||||
describe('PUT /person/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(
|
||||
`/person/${uuidDto.notFound}`
|
||||
);
|
||||
const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -139,7 +133,7 @@ describe('/activity', () => {
|
||||
birthDate: '123567',
|
||||
response: 'Not found or no person.write access',
|
||||
},
|
||||
{ birthDate: 123567, response: 'Not found or no person.write access' },
|
||||
{ birthDate: 123_567, response: 'Not found or no person.write access' },
|
||||
]) {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/person/${uuidDto.notFound}`)
|
||||
|
||||
@@ -97,9 +97,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
'/server-info/statistics'
|
||||
);
|
||||
const { status, body } = await request(app).get('/server-info/statistics');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -145,9 +143,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/media-types', () => {
|
||||
it('should return accepted media types', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
'/server-info/media-types'
|
||||
);
|
||||
const { status, body } = await request(app).get('/server-info/media-types');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
sidecar: ['.xmp'],
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkType,
|
||||
createSharedLink as create,
|
||||
createAlbum,
|
||||
deleteUser,
|
||||
} from '@immich/sdk';
|
||||
@@ -17,8 +15,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/shared-link', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetResponseDto;
|
||||
let asset2: AssetResponseDto;
|
||||
let asset1: AssetFileUploadResponseDto;
|
||||
let asset2: AssetFileUploadResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
@@ -48,14 +46,8 @@ describe('/shared-link', () => {
|
||||
]);
|
||||
|
||||
[album, deletedAlbum, metadataAlbum] = await Promise.all([
|
||||
createAlbum(
|
||||
{ createAlbumDto: { albumName: 'album' } },
|
||||
{ headers: asBearerAuth(user1.accessToken) }
|
||||
),
|
||||
createAlbum(
|
||||
{ createAlbumDto: { albumName: 'deleted album' } },
|
||||
{ headers: asBearerAuth(user2.accessToken) }
|
||||
),
|
||||
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
||||
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
||||
createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
@@ -63,51 +55,42 @@ describe('/shared-link', () => {
|
||||
assetIds: [asset1.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(user1.accessToken) }
|
||||
{ headers: asBearerAuth(user1.accessToken) },
|
||||
),
|
||||
]);
|
||||
|
||||
[
|
||||
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', () => {
|
||||
@@ -132,7 +115,7 @@ describe('/shared-link', () => {
|
||||
expect.objectContaining({ id: linkWithPassword.id }),
|
||||
expect.objectContaining({ id: linkWithMetadata.id }),
|
||||
expect.objectContaining({ id: linkWithoutMetadata.id }),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,17 +131,13 @@ describe('/shared-link', () => {
|
||||
|
||||
describe('GET /shared-link/me', () => {
|
||||
it('should not require admin authentication', async () => {
|
||||
const { status } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
const { status } = await request(app).get('/shared-link/me').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('should get data for correct shared link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithAlbum.key });
|
||||
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithAlbum.key });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
@@ -166,7 +145,7 @@ describe('/shared-link', () => {
|
||||
album,
|
||||
userId: user1.userId,
|
||||
type: SharedLinkType.Album,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -180,18 +159,14 @@ describe('/shared-link', () => {
|
||||
});
|
||||
|
||||
it('should return unauthorized if target has been soft deleted', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithDeletedAlbum.key });
|
||||
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.invalidShareKey);
|
||||
});
|
||||
|
||||
it('should return unauthorized for password protected link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithPassword.key });
|
||||
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithPassword.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.invalidSharePassword);
|
||||
@@ -208,14 +183,12 @@ describe('/shared-link', () => {
|
||||
album,
|
||||
userId: user1.userId,
|
||||
type: SharedLinkType.Album,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -225,15 +198,13 @@ describe('/shared-link', () => {
|
||||
localDateTime: expect.any(String),
|
||||
fileCreatedAt: expect.any(String),
|
||||
exifInfo: expect.any(Object),
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(body.album).toBeDefined();
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -249,9 +220,7 @@ describe('/shared-link', () => {
|
||||
|
||||
describe('GET /shared-link/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
`/shared-link/${linkWithAlbum.id}`
|
||||
);
|
||||
const { status, body } = await request(app).get(`/shared-link/${linkWithAlbum.id}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -268,7 +237,7 @@ describe('/shared-link', () => {
|
||||
album,
|
||||
userId: user1.userId,
|
||||
type: SharedLinkType.Album,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -278,9 +247,7 @@ describe('/shared-link', () => {
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Shared link not found' })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,9 +277,7 @@ describe('/shared-link', () => {
|
||||
.send({ type: SharedLinkType.Album });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Invalid albumId' })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
@@ -322,9 +287,7 @@ describe('/shared-link', () => {
|
||||
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Invalid assetIds' })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
|
||||
});
|
||||
|
||||
it('should create a shared link', async () => {
|
||||
@@ -338,7 +301,7 @@ describe('/shared-link', () => {
|
||||
expect.objectContaining({
|
||||
type: SharedLinkType.Album,
|
||||
userId: user1.userId,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -375,7 +338,7 @@ describe('/shared-link', () => {
|
||||
type: SharedLinkType.Album,
|
||||
userId: user1.userId,
|
||||
description: 'foo',
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -426,9 +389,7 @@ describe('/shared-link', () => {
|
||||
|
||||
describe('DELETE /shared-link/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(
|
||||
`/shared-link/${linkWithAlbum.id}`
|
||||
);
|
||||
const { status, body } = await request(app).delete(`/shared-link/${linkWithAlbum.id}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
|
||||
@@ -18,9 +18,7 @@ describe('/system-config', () => {
|
||||
|
||||
describe('GET /system-config/map/style.json', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
'/system-config/map/style.json'
|
||||
);
|
||||
const { status, body } = await request(app).get('/system-config/map/style.json');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -32,11 +30,7 @@ describe('/system-config', () => {
|
||||
.query({ theme })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'theme must be one of the following values: light, dark',
|
||||
])
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark']));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { LoginResponseDto, getAllAssets } from '@immich/sdk';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils, wsUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/trash', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let ws: Socket;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup({ onboarding: false });
|
||||
ws = await wsUtils.connect(admin.accessToken);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wsUtils.disconnect(ws);
|
||||
});
|
||||
|
||||
describe('POST /trash/empty', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/trash/empty');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should empty the trash', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
|
||||
|
||||
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}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
await wsUtils.waitForEvent({ event: 'delete', assetId });
|
||||
|
||||
const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trash/restore', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/trash/restore');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should restore all trashed assets', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
|
||||
|
||||
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}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(after.isTrashed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trash/restore/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/trash/restore/assets');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should restore a trashed asset by id', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
|
||||
|
||||
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(before.isTrashed).toBe(true);
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/trash/restore/assets')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ids: [assetId] });
|
||||
expect(status).toBe(204);
|
||||
|
||||
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(after.isTrashed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,7 @@ describe('/server-info', () => {
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
|
||||
]);
|
||||
|
||||
await deleteUser(
|
||||
{ id: deletedUser.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
});
|
||||
|
||||
describe('GET /user', () => {
|
||||
@@ -36,9 +33,7 @@ describe('/server-info', () => {
|
||||
});
|
||||
|
||||
it('should get users', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/user')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
const { status, body } = await request(app).get('/user').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(4);
|
||||
expect(body).toEqual(
|
||||
@@ -47,7 +42,7 @@ describe('/server-info', () => {
|
||||
expect.objectContaining({ email: 'user1@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user2@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user3@immich.cloud' }),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,7 +58,7 @@ describe('/server-info', () => {
|
||||
expect.objectContaining({ email: 'admin@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user2@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user3@immich.cloud' }),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -81,7 +76,7 @@ describe('/server-info', () => {
|
||||
expect.objectContaining({ email: 'user1@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user2@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user3@immich.cloud' }),
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -112,9 +107,7 @@ describe('/server-info', () => {
|
||||
});
|
||||
|
||||
it('should get my info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/user/me`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
const { status, body } = await request(app).get(`/user/me`).set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
id: admin.userId,
|
||||
@@ -125,9 +118,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('POST /user', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/user`)
|
||||
.send(createUserDto.user1);
|
||||
const { status, body } = await request(app).post(`/user`).send(createUserDto.user1);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -181,9 +172,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('DELETE /user/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(
|
||||
`/user/${userToDelete.userId}`
|
||||
);
|
||||
const { status, body } = await request(app).delete(`/user/${userToDelete.userId}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
@@ -241,10 +230,7 @@ describe('/server-info', () => {
|
||||
});
|
||||
|
||||
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
|
||||
const before = await getUserById(
|
||||
{ id: admin.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
@@ -261,10 +247,7 @@ describe('/server-info', () => {
|
||||
});
|
||||
|
||||
it('should update first and last name', async () => {
|
||||
const before = await getUserById(
|
||||
{ id: admin.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
@@ -284,10 +267,7 @@ describe('/server-info', () => {
|
||||
});
|
||||
|
||||
it('should update memories enabled', async () => {
|
||||
const before = await getUserById(
|
||||
{ id: admin.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.send({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
|
||||
import { beforeEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich login-key`, () => {
|
||||
beforeAll(() => {
|
||||
@@ -24,25 +24,15 @@ describe(`immich login-key`, () => {
|
||||
});
|
||||
|
||||
it('should require a valid key', async () => {
|
||||
const { stderr, exitCode } = await immichCli([
|
||||
'login-key',
|
||||
app,
|
||||
'immich-is-so-cool',
|
||||
]);
|
||||
expect(stderr).toContain(
|
||||
'Failed to connect to server http://127.0.0.1:2283/api: Error: 401'
|
||||
);
|
||||
const { stderr, exitCode } = await immichCli(['login-key', app, 'immich-is-so-cool']);
|
||||
expect(stderr).toContain('Failed to connect to server http://127.0.0.1:2283/api: Error: 401');
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should login', async () => {
|
||||
const admin = await apiUtils.adminSetup();
|
||||
const key = await apiUtils.createApiKey(admin.accessToken);
|
||||
const { stdout, stderr, exitCode } = await immichCli([
|
||||
'login-key',
|
||||
app,
|
||||
`${key.secret}`,
|
||||
]);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
'Logging in...',
|
||||
'Logged in as admin@immich.cloud',
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich server-info`, () => {
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
await cliUtils.login();
|
||||
});
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
import { getAllAlbums, getAllAssets } from '@immich/sdk';
|
||||
import {
|
||||
apiUtils,
|
||||
asKeyAuth,
|
||||
cliUtils,
|
||||
dbUtils,
|
||||
immichCli,
|
||||
testAssetDir,
|
||||
} from 'src/utils';
|
||||
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
|
||||
import { apiUtils, asKeyAuth, cliUtils, dbUtils, immichCli, testAssetDir } from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdir, readdir, rm, symlink } from 'fs/promises';
|
||||
|
||||
describe(`immich upload`, () => {
|
||||
let key: string;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
key = await cliUtils.login();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset(['assets', 'albums']);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -55,7 +43,7 @@ describe(`immich upload`, () => {
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -69,15 +57,9 @@ describe(`immich upload`, () => {
|
||||
});
|
||||
|
||||
it('should add existing assets to albums', async () => {
|
||||
const response1 = await immichCli([
|
||||
'upload',
|
||||
`${testAssetDir}/albums/nature/`,
|
||||
'--recursive',
|
||||
]);
|
||||
const response1 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
||||
expect(response1.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
])
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
|
||||
);
|
||||
expect(response1.stderr).toBe('');
|
||||
expect(response1.exitCode).toBe(0);
|
||||
@@ -88,19 +70,12 @@ 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'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(response2.stderr).toBe('');
|
||||
expect(response2.exitCode).toBe(0);
|
||||
@@ -127,7 +102,7 @@ describe(`immich upload`, () => {
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -146,17 +121,10 @@ describe(`immich upload`, () => {
|
||||
await mkdir(`/tmp/albums/nature`, { recursive: true });
|
||||
const filesToLink = await readdir(`${testAssetDir}/albums/nature`);
|
||||
for (const file of filesToLink) {
|
||||
await symlink(
|
||||
`${testAssetDir}/albums/nature/${file}`,
|
||||
`/tmp/albums/nature/${file}`
|
||||
);
|
||||
await symlink(`${testAssetDir}/albums/nature/${file}`, `/tmp/albums/nature/${file}`);
|
||||
}
|
||||
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`/tmp/albums/nature`,
|
||||
'--delete',
|
||||
]);
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `/tmp/albums/nature`, '--delete']);
|
||||
|
||||
const files = await readdir(`/tmp/albums/nature`);
|
||||
await rm(`/tmp/albums/nature`, { recursive: true });
|
||||
@@ -166,7 +134,7 @@ describe(`immich upload`, () => {
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Deleting assets that have been uploaded'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -44,7 +44,6 @@ export const userDto = {
|
||||
email: signupDto.admin.email,
|
||||
password: signupDto.admin.password,
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
@@ -63,7 +62,6 @@ export const userDto = {
|
||||
email: createUserDto.user1.email,
|
||||
password: createUserDto.user1.password,
|
||||
storageLabel: null,
|
||||
externalPath: null,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
const createPNG = (r: number, g: number, b: number) => {
|
||||
const image = new PNG({ width: 1, height: 1 });
|
||||
image.data[0] = r;
|
||||
image.data[1] = g;
|
||||
image.data[2] = b;
|
||||
image.data[3] = 255;
|
||||
return PNG.sync.write(image);
|
||||
};
|
||||
|
||||
function* newPngFactory() {
|
||||
for (let r = 0; r < 255; r++) {
|
||||
for (let g = 0; g < 255; g++) {
|
||||
for (let b = 0; b < 255; b++) {
|
||||
yield createPNG(r, g, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pngFactory = newPngFactory();
|
||||
|
||||
export const makeRandomImage = () => {
|
||||
const { value } = pngFactory.next();
|
||||
if (!value) {
|
||||
throw new Error('Ran out of random asset data');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -65,7 +65,6 @@ export const signupResponseDto = {
|
||||
name: 'Immich Admin',
|
||||
email: 'admin@immich.cloud',
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
profileImagePath: '',
|
||||
// why? lol
|
||||
shouldChangePassword: true,
|
||||
|
||||
+5
-7
@@ -1,26 +1,24 @@
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
|
||||
export default async () => {
|
||||
let _resolve: () => unknown;
|
||||
const promise = new Promise<void>((resolve) => (_resolve = resolve));
|
||||
const ready = 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 Server is listening')) {
|
||||
if (input.includes('Immich Microservices is listening')) {
|
||||
_resolve();
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => console.log(data.toString()));
|
||||
|
||||
await promise;
|
||||
await ready;
|
||||
|
||||
return async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
exec('docker compose down', () => resolve())
|
||||
);
|
||||
await new Promise<void>((resolve) => exec('docker compose down', () => resolve()));
|
||||
};
|
||||
};
|
||||
|
||||
+156
-58
@@ -1,30 +1,42 @@
|
||||
import {
|
||||
AssetFileUploadResponseDto,
|
||||
AssetResponseDto,
|
||||
CreateAlbumDto,
|
||||
CreateAssetDto,
|
||||
CreateLibraryDto,
|
||||
CreateUserDto,
|
||||
PersonUpdateDto,
|
||||
SharedLinkCreateDto,
|
||||
ValidateLibraryDto,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createLibrary,
|
||||
createPerson,
|
||||
createSharedLink,
|
||||
createUser,
|
||||
defaults,
|
||||
deleteAssets,
|
||||
getAssetInfo,
|
||||
login,
|
||||
setAdminOnboarding,
|
||||
signUpAdmin,
|
||||
updatePerson,
|
||||
validate,
|
||||
} from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
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);
|
||||
|
||||
export const app = 'http://127.0.0.1:2283/api';
|
||||
|
||||
const directoryExists = (directory: string) =>
|
||||
@@ -34,14 +46,24 @@ 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';
|
||||
const dirs = [
|
||||
`"${mediaDir}/thumbs"`,
|
||||
`"${mediaDir}/upload"`,
|
||||
`"${mediaDir}/library"`,
|
||||
`"${mediaDir}/encoded-video"`,
|
||||
].join(' ');
|
||||
|
||||
if (!(await directoryExists(`${testAssetDir}/albums`))) {
|
||||
throw new Error(
|
||||
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
|
||||
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`,
|
||||
);
|
||||
}
|
||||
|
||||
const setBaseUrl = () => (defaults.baseUrl = app);
|
||||
export const asBearerAuth = (accessToken: string) => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
@@ -50,14 +72,14 @@ export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
|
||||
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}"`);
|
||||
},
|
||||
};
|
||||
|
||||
export const dbUtils = {
|
||||
createFace: async ({
|
||||
assetId,
|
||||
personId,
|
||||
}: {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}) => {
|
||||
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
@@ -65,31 +87,28 @@ export const dbUtils = {
|
||||
const vector = Array.from({ length: 512 }, Math.random);
|
||||
const embedding = `[${vector.join(',')}]`;
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)',
|
||||
[assetId, personId, embedding]
|
||||
);
|
||||
await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [
|
||||
assetId,
|
||||
personId,
|
||||
embedding,
|
||||
]);
|
||||
},
|
||||
setPersonThumbnail: async (personId: string) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`,
|
||||
[personId]
|
||||
);
|
||||
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
|
||||
},
|
||||
reset: async (tables?: string[]) => {
|
||||
try {
|
||||
if (!client) {
|
||||
client = new pg.Client(
|
||||
'postgres://postgres:postgres@127.0.0.1:5433/immich'
|
||||
);
|
||||
client = new pg.Client('postgres://postgres:postgres@127.0.0.1:5433/immich');
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
tables = tables || [
|
||||
'libraries',
|
||||
'shared_links',
|
||||
'person',
|
||||
'albums',
|
||||
@@ -156,10 +175,85 @@ 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,
|
||||
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();
|
||||
});
|
||||
},
|
||||
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] = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
type AssetData = { bytes?: Buffer; filename: string };
|
||||
|
||||
export const apiUtils = {
|
||||
setup: () => {
|
||||
setBaseUrl();
|
||||
defaults.baseUrl = app;
|
||||
},
|
||||
|
||||
adminSetup: async (options?: AdminSetupOptions) => {
|
||||
options = options || { onboarding: true };
|
||||
|
||||
@@ -171,60 +265,64 @@ export const apiUtils = {
|
||||
return response;
|
||||
},
|
||||
userSetup: async (accessToken: string, dto: CreateUserDto) => {
|
||||
await createUser(
|
||||
{ createUserDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
);
|
||||
await createUser({ createUserDto: dto }, { headers: asBearerAuth(accessToken) });
|
||||
return login({
|
||||
loginCredentialDto: { email: dto.email, password: dto.password },
|
||||
});
|
||||
},
|
||||
createApiKey: (accessToken: string) => {
|
||||
return createApiKey(
|
||||
{ apiKeyCreateDto: { name: 'e2e' } },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
);
|
||||
return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
|
||||
createAlbum(
|
||||
{ createAlbumDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
),
|
||||
createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }),
|
||||
createAsset: async (
|
||||
accessToken: string,
|
||||
dto?: Omit<CreateAssetDto, 'assetData'>
|
||||
dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData },
|
||||
) => {
|
||||
dto = dto || {
|
||||
const _dto = {
|
||||
deviceAssetId: 'test-1',
|
||||
deviceId: 'test',
|
||||
fileCreatedAt: new Date().toISOString(),
|
||||
fileModifiedAt: new Date().toISOString(),
|
||||
...dto,
|
||||
};
|
||||
const { body } = await request(app)
|
||||
|
||||
const assetData = dto?.assetData?.bytes || makeRandomImage();
|
||||
const filename = dto?.assetData?.filename || 'example.png';
|
||||
|
||||
const builder = request(app)
|
||||
.post(`/asset/upload`)
|
||||
.field('deviceAssetId', dto.deviceAssetId)
|
||||
.field('deviceId', dto.deviceId)
|
||||
.field('fileCreatedAt', dto.fileCreatedAt)
|
||||
.field('fileModifiedAt', dto.fileModifiedAt)
|
||||
.attach('assetData', randomBytes(32), 'example.jpg')
|
||||
.attach('assetData', assetData, filename)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
return body as AssetResponseDto;
|
||||
for (const [key, value] of Object.entries(_dto)) {
|
||||
void builder.field(key, String(value));
|
||||
}
|
||||
|
||||
const { body } = await builder;
|
||||
|
||||
return body as AssetFileUploadResponseDto;
|
||||
},
|
||||
createPerson: async (accessToken: string, dto: PersonUpdateDto) => {
|
||||
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
||||
deleteAssets: (accessToken: string, ids: string[]) =>
|
||||
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
|
||||
createPerson: async (accessToken: string, dto?: PersonUpdateDto) => {
|
||||
// TODO fix createPerson to accept a body
|
||||
const { id } = await createPerson({ headers: asBearerAuth(accessToken) });
|
||||
await dbUtils.setPersonThumbnail(id);
|
||||
return updatePerson(
|
||||
{ id, personUpdateDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
);
|
||||
const person = await createPerson({ headers: asBearerAuth(accessToken) });
|
||||
await dbUtils.setPersonThumbnail(person.id);
|
||||
|
||||
if (!dto) {
|
||||
return person;
|
||||
}
|
||||
|
||||
return updatePerson({ id: person.id, personUpdateDto: dto }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
|
||||
createSharedLink(
|
||||
{ sharedLinkCreateDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
),
|
||||
createSharedLink({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }),
|
||||
createLibrary: (accessToken: string, dto: CreateLibraryDto) =>
|
||||
createLibrary({ createLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
|
||||
validateLibrary: (accessToken: string, id: string, dto: ValidateLibraryDto) =>
|
||||
validate({ id, validateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
|
||||
};
|
||||
|
||||
export const cliUtils = {
|
||||
@@ -244,7 +342,7 @@ export const webUtils = {
|
||||
value: accessToken,
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
expires: 1_742_402_728,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
@@ -254,7 +352,7 @@ export const webUtils = {
|
||||
value: 'password',
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
expires: 1_742_402_728,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
@@ -264,7 +362,7 @@ export const webUtils = {
|
||||
value: 'true',
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
expires: 1_742_402_728,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { apiUtils, dbUtils, webUtils } from 'src/utils';
|
||||
|
||||
test.describe('Registration', () => {
|
||||
@@ -68,7 +68,7 @@ test.describe('Registration', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
// change password
|
||||
expect(page.getByRole('heading')).toHaveText('Change Password');
|
||||
await expect(page.getByRole('heading')).toHaveText('Change Password');
|
||||
await expect(page).toHaveURL('/auth/change-password');
|
||||
await page.getByLabel('New Password').fill('new-password');
|
||||
await page.getByLabel('Confirm Password').fill('new-password');
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkType,
|
||||
createAlbum,
|
||||
createSharedLink,
|
||||
} from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { apiUtils, asBearerAuth, dbUtils } from 'src/utils';
|
||||
|
||||
test.describe('Shared Links', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let sharedLink: SharedLinkResponseDto;
|
||||
let sharedLinkPassword: SharedLinkResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
@@ -28,18 +28,17 @@ test.describe('Shared Links', () => {
|
||||
assetIds: [asset.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
// { headers: asBearerAuth(admin.accessToken)},
|
||||
);
|
||||
sharedLink = await createSharedLink(
|
||||
{
|
||||
sharedLinkCreateDto: {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
sharedLink = await apiUtils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
});
|
||||
sharedLinkPassword = await apiUtils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
password: 'test-password',
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
@@ -53,6 +52,18 @@ test.describe('Shared Links', () => {
|
||||
await page.waitForSelector('#asset-group-by-date svg');
|
||||
await page.getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
await page.getByText('DOWNLOADING').waitFor();
|
||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
||||
});
|
||||
|
||||
test('enter password for a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLinkPassword.key}`);
|
||||
await page.getByPlaceholder('Password').fill('test-password');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
});
|
||||
|
||||
test('show error for invalid shared link', async ({ page }) => {
|
||||
await page.goto('/share/invalid');
|
||||
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
"rootDirs": ["src"],
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// skip `docker compose up` if `make e2e` was already run
|
||||
const globalSetup: string[] = [];
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2283/api/server-info/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/setup.ts');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/{api,cli}/specs/*.e2e-spec.ts'],
|
||||
globalSetup: ['src/setup.ts'],
|
||||
globalSetup,
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .ann import Ann, is_available
|
||||
|
||||
@@ -32,8 +32,7 @@ T = TypeVar("T", covariant=True)
|
||||
|
||||
|
||||
class Newable(Protocol[T]):
|
||||
def new(self) -> None:
|
||||
...
|
||||
def new(self) -> None: ...
|
||||
|
||||
|
||||
class _Singleton(type, Newable[T]):
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from typing import Any
|
||||
|
||||
import onnx
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import snapshot_download
|
||||
from onnx.shape_inference import infer_shapes
|
||||
from onnx.tools.update_model_dims import update_inputs_outputs_dims
|
||||
|
||||
import ann.ann
|
||||
from app.models.constants import STATIC_INPUT_PROVIDERS, SUPPORTED_PROVIDERS
|
||||
from app.models.constants import SUPPORTED_PROVIDERS
|
||||
|
||||
from ..config import get_cache_dir, get_hf_model_name, log, settings
|
||||
from ..schemas import ModelRuntime, ModelType
|
||||
@@ -113,63 +111,25 @@ class InferenceModel(ABC):
|
||||
)
|
||||
model_path = onnx_path
|
||||
|
||||
if any(provider in STATIC_INPUT_PROVIDERS for provider in self.providers):
|
||||
static_path = model_path.parent / "static_1" / "model.onnx"
|
||||
static_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not static_path.is_file():
|
||||
self._convert_to_static(model_path, static_path)
|
||||
model_path = static_path
|
||||
|
||||
match model_path.suffix:
|
||||
case ".armnn":
|
||||
session = AnnSession(model_path)
|
||||
case ".onnx":
|
||||
session = ort.InferenceSession(
|
||||
model_path.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(model_path.parent)
|
||||
session = ort.InferenceSession(
|
||||
model_path.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported model file type: {model_path.suffix}")
|
||||
return session
|
||||
|
||||
def _convert_to_static(self, source_path: Path, target_path: Path) -> None:
|
||||
inferred = infer_shapes(onnx.load(source_path))
|
||||
inputs = self._get_static_dims(inferred.graph.input)
|
||||
outputs = self._get_static_dims(inferred.graph.output)
|
||||
|
||||
# check_model gets called in update_inputs_outputs_dims and doesn't work for large models
|
||||
check_model = onnx.checker.check_model
|
||||
try:
|
||||
|
||||
def check_model_stub(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
onnx.checker.check_model = check_model_stub
|
||||
updated_model = update_inputs_outputs_dims(inferred, inputs, outputs)
|
||||
finally:
|
||||
onnx.checker.check_model = check_model
|
||||
|
||||
onnx.save(
|
||||
updated_model,
|
||||
target_path,
|
||||
save_as_external_data=True,
|
||||
all_tensors_to_one_file=False,
|
||||
size_threshold=1048576,
|
||||
)
|
||||
|
||||
def _get_static_dims(self, graph_io: Any, dim_size: int = 1) -> dict[str, list[int]]:
|
||||
return {
|
||||
field.name: [
|
||||
d.dim_value if d.HasField("dim_value") else dim_size
|
||||
for shape in field.type.ListFields()
|
||||
if (dim := shape[1].shape.dim)
|
||||
for d in dim
|
||||
]
|
||||
for field in graph_io
|
||||
}
|
||||
|
||||
@property
|
||||
def model_type(self) -> ModelType:
|
||||
return self._model_type
|
||||
@@ -205,6 +165,14 @@ class InferenceModel(ABC):
|
||||
def providers_default(self) -> list[str]:
|
||||
available_providers = set(ort.get_available_providers())
|
||||
log.debug(f"Available ORT providers: {available_providers}")
|
||||
if (openvino := "OpenVINOExecutionProvider") in available_providers:
|
||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||
|
||||
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||
if not gpu_devices:
|
||||
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
|
||||
available_providers.remove(openvino)
|
||||
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
||||
|
||||
@property
|
||||
@@ -224,15 +192,7 @@ class InferenceModel(ABC):
|
||||
case "CPUExecutionProvider" | "CUDAExecutionProvider":
|
||||
option = {"arena_extend_strategy": "kSameAsRequested"}
|
||||
case "OpenVINOExecutionProvider":
|
||||
try:
|
||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||
option = {"device_id": gpu_devices[0]} if gpu_devices else {}
|
||||
except AttributeError as e:
|
||||
log.warning("Failed to get OpenVINO device IDs. Using default options.")
|
||||
log.error(e)
|
||||
option = {}
|
||||
option = {"device_type": "GPU_FP32"}
|
||||
case _:
|
||||
option = {}
|
||||
options.append(option)
|
||||
|
||||
@@ -54,9 +54,6 @@ _INSIGHTFACE_MODELS = {
|
||||
SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"]
|
||||
|
||||
|
||||
STATIC_INPUT_PROVIDERS = ["OpenVINOExecutionProvider"]
|
||||
|
||||
|
||||
def is_openclip(model_name: str) -> bool:
|
||||
return clean_name(model_name) in _OPENCLIP_MODELS
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from random import randint
|
||||
@@ -44,11 +45,23 @@ class TestBase:
|
||||
assert encoder.providers == self.CUDA_EP
|
||||
|
||||
@pytest.mark.providers(OV_EP)
|
||||
def test_sets_openvino_provider_if_available(self, providers: list[str]) -> None:
|
||||
def test_sets_openvino_provider_if_available(self, providers: list[str], mocker: MockerFixture) -> None:
|
||||
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
|
||||
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
|
||||
assert encoder.providers == self.OV_EP
|
||||
|
||||
@pytest.mark.providers(OV_EP)
|
||||
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], mocker: MockerFixture) -> None:
|
||||
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
|
||||
mocked.get_available_openvino_device_ids.return_value = ["CPU"]
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
|
||||
assert encoder.providers == self.CPU_EP
|
||||
|
||||
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
||||
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
@@ -67,22 +80,14 @@ class TestBase:
|
||||
|
||||
assert encoder.providers == providers
|
||||
|
||||
def test_sets_default_provider_options(self) -> None:
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
|
||||
|
||||
assert encoder.provider_options == [
|
||||
{},
|
||||
{"arena_extend_strategy": "kSameAsRequested"},
|
||||
]
|
||||
|
||||
def test_sets_openvino_device_id_if_possible(self, mocker: MockerFixture) -> None:
|
||||
def test_sets_default_provider_options(self, mocker: MockerFixture) -> None:
|
||||
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
|
||||
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
|
||||
|
||||
assert encoder.provider_options == [
|
||||
{"device_id": "GPU.0"},
|
||||
{"device_type": "GPU_FP32"},
|
||||
{"arena_extend_strategy": "kSameAsRequested"},
|
||||
]
|
||||
|
||||
@@ -237,12 +242,12 @@ class TestBase:
|
||||
mock_model_path.is_file.return_value = True
|
||||
mock_model_path.suffix = ".armnn"
|
||||
mock_model_path.with_suffix.return_value = mock_model_path
|
||||
mock_session = mocker.patch("app.models.base.AnnSession")
|
||||
mock_ann = mocker.patch("app.models.base.AnnSession")
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
encoder._make_session(mock_model_path)
|
||||
|
||||
mock_session.assert_called_once()
|
||||
mock_ann.assert_called_once()
|
||||
|
||||
def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None:
|
||||
mock_armnn_path = mocker.Mock()
|
||||
@@ -256,6 +261,7 @@ class TestBase:
|
||||
|
||||
mock_ann = mocker.patch("app.models.base.AnnSession")
|
||||
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
|
||||
mocker.patch("app.models.base.os.chdir")
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
encoder._make_session(mock_armnn_path)
|
||||
@@ -278,6 +284,26 @@ class TestBase:
|
||||
mock_ann.assert_not_called()
|
||||
mock_ort.assert_not_called()
|
||||
|
||||
def test_make_session_changes_cwd(self, mocker: MockerFixture) -> None:
|
||||
mock_model_path = mocker.Mock()
|
||||
mock_model_path.is_file.return_value = True
|
||||
mock_model_path.suffix = ".onnx"
|
||||
mock_model_path.parent = "model_parent"
|
||||
mock_model_path.with_suffix.return_value = mock_model_path
|
||||
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
|
||||
mock_chdir = mocker.patch("app.models.base.os.chdir")
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
encoder._make_session(mock_model_path)
|
||||
|
||||
mock_chdir.assert_has_calls(
|
||||
[
|
||||
mock.call(mock_model_path.parent),
|
||||
mock.call(os.getcwd()),
|
||||
]
|
||||
)
|
||||
mock_ort.assert_called_once()
|
||||
|
||||
def test_download(self, mocker: MockerFixture) -> None:
|
||||
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:926cac38640709f90f3fef2a3f730733b5c350be612f0d14706be8833b79ad8c as builder
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:6038b89363c9181215f3d9e8ce2720c880e224537f4028a854482e43a9b4998a as builder
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
TRANSFORMERS_CACHE=/cache \
|
||||
|
||||
Generated
+75
-75
@@ -1250,13 +1250,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
description = "The next generation HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
|
||||
{file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
|
||||
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
|
||||
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2101,61 +2101,61 @@ numpy = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.9.14"
|
||||
version = "3.9.15"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "orjson-3.9.14-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:793f6c9448ab6eb7d4974b4dde3f230345c08ca6c7995330fbceeb43a5c8aa5e"},
|
||||
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bc7928d161840096adc956703494b5c0193ede887346f028216cac0af87500"},
|
||||
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58b36f54da759602d8e2f7dad958752d453dfe2c7122767bc7f765e17dc59959"},
|
||||
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:abcda41ecdc950399c05eff761c3de91485d9a70d8227cb599ad3a66afe93bcc"},
|
||||
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df76ecd17b1b3627bddfd689faaf206380a1a38cc9f6c4075bd884eaedcf46c2"},
|
||||
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d450a8e0656efb5d0fcb062157b918ab02dcca73278975b4ee9ea49e2fcf5bd5"},
|
||||
{file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:95c03137b0cf66517c8baa65770507a756d3a89489d8ecf864ea92348e1beabe"},
|
||||
{file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20837e10835c98973673406d6798e10f821e7744520633811a5a3d809762d8cc"},
|
||||
{file = "orjson-3.9.14-cp310-none-win32.whl", hash = "sha256:1f7b6f3ef10ae8e3558abb729873d033dbb5843507c66b1c0767e32502ba96bb"},
|
||||
{file = "orjson-3.9.14-cp310-none-win_amd64.whl", hash = "sha256:ea890e6dc1711aeec0a33b8520e395c2f3d59ead5b4351a788e06bf95fc7ba81"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c19009ff37f033c70acd04b636380379499dac2cba27ae7dfc24f304deabbc81"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19cdea0664aec0b7f385be84986d4defd3334e9c3c799407686ee1c26f7b8251"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:135d518f73787ce323b1a5e21fb854fe22258d7a8ae562b81a49d6c7f826f2a3"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2cf1d0557c61c75e18cf7d69fb689b77896e95553e212c0cc64cf2087944b84"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7c11667421df2d8b18b021223505dcc3ee51be518d54e4dc49161ac88ac2b87"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eefc41ba42e75ed88bc396d8fe997beb20477f3e7efa000cd7a47eda452fbb2"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:917311d6a64d1c327c0dfda1e41f3966a7fb72b11ca7aa2e7a68fcccc7db35d9"},
|
||||
{file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4dc1c132259b38d12c6587d190cd09cd76e3b5273ce71fe1372437b4cbc65f6f"},
|
||||
{file = "orjson-3.9.14-cp311-none-win32.whl", hash = "sha256:6f39a10408478f4c05736a74da63727a1ae0e83e3533d07b19443400fe8591ca"},
|
||||
{file = "orjson-3.9.14-cp311-none-win_amd64.whl", hash = "sha256:26280a7fcb62d8257f634c16acebc3bec626454f9ab13558bbf7883b9140760e"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:08e722a8d06b13b67a51f247a24938d1a94b4b3862e40e0eef3b2e98c99cd04c"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2591faa0c031cf3f57e5bce1461cfbd6160f3f66b5a72609a130924917cb07d"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2450d87dd7b4f277f4c5598faa8b49a0c197b91186c47a2c0b88e15531e4e3e"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90903d2908158a2c9077a06f11e27545de610af690fb178fd3ba6b32492d4d1c"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce6f095eef0026eae76fc212f20f786011ecf482fc7df2f4c272a8ae6dd7b1ef"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:751250a31fef2bac05a2da2449aae7142075ea26139271f169af60456d8ad27a"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a1af21160a38ee8be3f4fcf24ee4b99e6184cadc7f915d599f073f478a94d2c"},
|
||||
{file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:449bf090b2aa4e019371d7511a6ea8a5a248139205c27d1834bb4b1e3c44d936"},
|
||||
{file = "orjson-3.9.14-cp312-none-win_amd64.whl", hash = "sha256:a603161318ff699784943e71f53899983b7dee571b4dd07c336437c9c5a272b0"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:814f288c011efdf8f115c5ebcc1ab94b11da64b207722917e0ceb42f52ef30a3"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88cafb100af68af3b9b29b5ccd09fdf7a48c63327916c8c923a94c336d38dd3"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba3518b999f88882ade6686f1b71e207b52e23546e180499be5bbb63a2f9c6e6"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978f416bbff9da8d2091e3cf011c92da68b13f2c453dcc2e8109099b2a19d234"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75fc593cf836f631153d0e21beaeb8d26e144445c73645889335c2247fcd71a0"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d1528db3c7554f9d6eeb09df23cb80dd5177ec56eeb55cc5318826928de506"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7183cc68ee2113b19b0b8714221e5e3b07b3ba10ca2bb108d78fd49cefaae101"},
|
||||
{file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df3266d54246cb56b8bb17fa908660d2a0f2e3f63fbc32451ffc1b1505051d07"},
|
||||
{file = "orjson-3.9.14-cp38-none-win32.whl", hash = "sha256:7913079b029e1b3501854c9a78ad938ed40d61fe09bebab3c93e60ff1301b189"},
|
||||
{file = "orjson-3.9.14-cp38-none-win_amd64.whl", hash = "sha256:29512eb925b620e5da2fd7585814485c67cc6ba4fe739a0a700c50467a8a8065"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bf597530544db27a8d76aced49cfc817ee9503e0a4ebf0109cd70331e7bbe0c"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac650d49366fa41fe702e054cb560171a8634e2865537e91f09a8d05ea5b1d37"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:236230433a9a4968ab895140514c308fdf9f607cb8bee178a04372b771123860"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3014ccbda9be0b1b5f8ea895121df7e6524496b3908f4397ff02e923bcd8f6dd"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac0c7eae7ad3a223bde690565442f8a3d620056bd01196f191af8be58a5248e1"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca33fdd0b38839b01912c57546d4f412ba7bfa0faf9bf7453432219aec2df07"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f75823cc1674a840a151e999a7dfa0d86c911150dd6f951d0736ee9d383bf415"},
|
||||
{file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f52ac2eb49e99e7373f62e2a68428c6946cda52ce89aa8fe9f890c7278e2d3a"},
|
||||
{file = "orjson-3.9.14-cp39-none-win32.whl", hash = "sha256:0572f174f50b673b7df78680fb52cd0087a8585a6d06d295a5f790568e1064c6"},
|
||||
{file = "orjson-3.9.14-cp39-none-win_amd64.whl", hash = "sha256:ab90c02cb264250b8a58cedcc72ed78a4a257d956c8d3c8bebe9751b818dfad8"},
|
||||
{file = "orjson-3.9.14.tar.gz", hash = "sha256:06fb40f8e49088ecaa02f1162581d39e2cf3fd9dbbfe411eb2284147c99bad79"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d61f7ce4727a9fa7680cd6f3986b0e2c732639f46a5e0156e550e35258aa313a"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4feeb41882e8aa17634b589533baafdceb387e01e117b1ec65534ec724023d04"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fbbeb3c9b2edb5fd044b2a070f127a0ac456ffd079cb82746fc84af01ef021a4"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b66bcc5670e8a6b78f0313bcb74774c8291f6f8aeef10fe70e910b8040f3ab75"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2973474811db7b35c30248d1129c64fd2bdf40d57d84beed2a9a379a6f57d0ab"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fe41b6f72f52d3da4db524c8653e46243c8c92df826ab5ffaece2dba9cccd58"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4228aace81781cc9d05a3ec3a6d2673a1ad0d8725b4e915f1089803e9efd2b99"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f7b65bfaf69493c73423ce9db66cfe9138b2f9ef62897486417a8fcb0a92bfe"},
|
||||
{file = "orjson-3.9.15-cp310-none-win32.whl", hash = "sha256:2d99e3c4c13a7b0fb3792cc04c2829c9db07838fb6973e578b85c1745e7d0ce7"},
|
||||
{file = "orjson-3.9.15-cp310-none-win_amd64.whl", hash = "sha256:b725da33e6e58e4a5d27958568484aa766e825e93aa20c26c91168be58e08cbb"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c8e8fe01e435005d4421f183038fc70ca85d2c1e490f51fb972db92af6e047c2"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87f1097acb569dde17f246faa268759a71a2cb8c96dd392cd25c668b104cad2f"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff0f9913d82e1d1fadbd976424c316fbc4d9c525c81d047bbdd16bd27dd98cfc"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8055ec598605b0077e29652ccfe9372247474375e0e3f5775c91d9434e12d6b1"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6768a327ea1ba44c9114dba5fdda4a214bdb70129065cd0807eb5f010bfcbb5"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12365576039b1a5a47df01aadb353b68223da413e2e7f98c02403061aad34bde"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71c6b009d431b3839d7c14c3af86788b3cfac41e969e3e1c22f8a6ea13139404"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e18668f1bd39e69b7fed19fa7cd1cd110a121ec25439328b5c89934e6d30d357"},
|
||||
{file = "orjson-3.9.15-cp311-none-win32.whl", hash = "sha256:62482873e0289cf7313461009bf62ac8b2e54bc6f00c6fabcde785709231a5d7"},
|
||||
{file = "orjson-3.9.15-cp311-none-win_amd64.whl", hash = "sha256:b3d336ed75d17c7b1af233a6561cf421dee41d9204aa3cfcc6c9c65cd5bb69a8"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:82425dd5c7bd3adfe4e94c78e27e2fa02971750c2b7ffba648b0f5d5cc016a73"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c51378d4a8255b2e7c1e5cc430644f0939539deddfa77f6fac7b56a9784160a"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae4e06be04dc00618247c4ae3f7c3e561d5bc19ab6941427f6d3722a0875ef7"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcef128f970bb63ecf9a65f7beafd9b55e3aaf0efc271a4154050fc15cdb386e"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b72758f3ffc36ca566ba98a8e7f4f373b6c17c646ff8ad9b21ad10c29186f00d"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c57bc7b946cf2efa67ac55766e41764b66d40cbd9489041e637c1304400494"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:946c3a1ef25338e78107fba746f299f926db408d34553b4754e90a7de1d44068"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2f256d03957075fcb5923410058982aea85455d035607486ccb847f095442bda"},
|
||||
{file = "orjson-3.9.15-cp312-none-win_amd64.whl", hash = "sha256:5bb399e1b49db120653a31463b4a7b27cf2fbfe60469546baf681d1b39f4edf2"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b17f0f14a9c0ba55ff6279a922d1932e24b13fc218a3e968ecdbf791b3682b25"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f6cbd8e6e446fb7e4ed5bac4661a29e43f38aeecbf60c4b900b825a353276a1"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76bc6356d07c1d9f4b782813094d0caf1703b729d876ab6a676f3aaa9a47e37c"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdfa97090e2d6f73dced247a2f2d8004ac6449df6568f30e7fa1a045767c69a6"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7413070a3e927e4207d00bd65f42d1b780fb0d32d7b1d951f6dc6ade318e1b5a"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cf1596680ac1f01839dba32d496136bdd5d8ffb858c280fa82bbfeb173bdd40"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:809d653c155e2cc4fd39ad69c08fdff7f4016c355ae4b88905219d3579e31eb7"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:920fa5a0c5175ab14b9c78f6f820b75804fb4984423ee4c4f1e6d748f8b22bc1"},
|
||||
{file = "orjson-3.9.15-cp38-none-win32.whl", hash = "sha256:2b5c0f532905e60cf22a511120e3719b85d9c25d0e1c2a8abb20c4dede3b05a5"},
|
||||
{file = "orjson-3.9.15-cp38-none-win_amd64.whl", hash = "sha256:67384f588f7f8daf040114337d34a5188346e3fae6c38b6a19a2fe8c663a2f9b"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6fc2fe4647927070df3d93f561d7e588a38865ea0040027662e3e541d592811e"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34cbcd216e7af5270f2ffa63a963346845eb71e174ea530867b7443892d77180"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f541587f5c558abd93cb0de491ce99a9ef8d1ae29dd6ab4dbb5a13281ae04cbd"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92255879280ef9c3c0bcb327c5a1b8ed694c290d61a6a532458264f887f052cb"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:05a1f57fb601c426635fcae9ddbe90dfc1ed42245eb4c75e4960440cac667262"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ede0bde16cc6e9b96633df1631fbcd66491d1063667f260a4f2386a098393790"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e88b97ef13910e5f87bcbc4dd7979a7de9ba8702b54d3204ac587e83639c0c2b"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57d5d8cf9c27f7ef6bc56a5925c7fbc76b61288ab674eb352c26ac780caa5b10"},
|
||||
{file = "orjson-3.9.15-cp39-none-win32.whl", hash = "sha256:001f4eb0ecd8e9ebd295722d0cbedf0748680fb9998d3993abaed2f40587257a"},
|
||||
{file = "orjson-3.9.15-cp39-none-win_amd64.whl", hash = "sha256:ea0b183a5fe6b2b45f3b854b0d19c4e932d6f5934ae1f723b07cf9560edd4ec7"},
|
||||
{file = "orjson-3.9.15.tar.gz", hash = "sha256:95cae920959d772f30ab36d3b25f83bb0f3be671e986c72ce22f8fa700dae061"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2465,13 +2465,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.0.0"
|
||||
version = "8.0.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"},
|
||||
{file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"},
|
||||
{file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"},
|
||||
{file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2836,28 +2836,28 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"},
|
||||
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"},
|
||||
{file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"},
|
||||
{file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"},
|
||||
{file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"},
|
||||
{file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"},
|
||||
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"},
|
||||
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"},
|
||||
{file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"},
|
||||
{file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"},
|
||||
{file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"},
|
||||
{file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.95.1"
|
||||
version = "1.97.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
@@ -82,10 +82,10 @@ warn_untyped_fields = true
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"test_main.py" = ["F403"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
per-file-ignores = { "test_main.py" = ["F403"] }
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
@@ -63,6 +63,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
npm --prefix server version "$SERVER_PUMP"
|
||||
npm --prefix web version "$SERVER_PUMP"
|
||||
npm --prefix open-api/typescript-sdk version "$SERVER_PUMP"
|
||||
make open-api
|
||||
poetry --directory machine-learning version "$SERVER_PUMP"
|
||||
fi
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.13.6
|
||||
@@ -1 +0,0 @@
|
||||
3.13.6
|
||||
@@ -1 +0,0 @@
|
||||
C:/Users/alext/fvm/versions/3.13.6
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 123,
|
||||
"android.injected.version.name" => "1.95.1",
|
||||
"android.injected.version.code" => 125,
|
||||
"android.injected.version.name" => "1.97.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')
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000232">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000266">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="78.881681">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.342186">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="32.080999">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="48.746195">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 139;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
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 = 139;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
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 = 139;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -55,11 +55,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.95.0</string>
|
||||
<string>1.97.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>139</string>
|
||||
<string>141</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.95.1"
|
||||
version_number: "1.97.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000255">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000304">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.157832">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.272646">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.825919">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.560896">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.18815">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.235745">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="110.912709">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="114.820395">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="78.396901">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.950812">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
extension LocalAlbumIsarHelper on IsarCollection<LocalAlbum> {
|
||||
Future<void> store(LocalAlbum a) async {
|
||||
await put(a);
|
||||
await a.thumb.save();
|
||||
await a.assets.save();
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteAlbumIsarHelper on IsarCollection<RemoteAlbum> {
|
||||
Future<void> store(RemoteAlbum a) async {
|
||||
await put(a);
|
||||
await a.owner.save();
|
||||
await a.thumb.save();
|
||||
await a.sharedUsers.save();
|
||||
await a.assets.save();
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumIsarHelper on IsarCollection<BackupAlbum> {
|
||||
Future<void> store(BackupAlbum a) async {
|
||||
await put(a);
|
||||
await a.album.save();
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumResponseDtoHelper on AlbumResponseDto {
|
||||
List<Asset> getAssets() => assets.map(Asset.remote).toList();
|
||||
}
|
||||
|
||||
extension AssetPathEntityHelper on AssetPathEntity {
|
||||
String get eTagKeyAssetCount => "device-album-$id-asset-count";
|
||||
}
|
||||
@@ -30,7 +30,7 @@ extension LogOnError<T> on AsyncValue<T> {
|
||||
}
|
||||
|
||||
if (hasError && !hasValue) {
|
||||
_asyncErrorLogger.severe("$error", error, stackTrace);
|
||||
_asyncErrorLogger.severe('Could not load value', error, stackTrace);
|
||||
return onError?.call(error, stackTrace) ??
|
||||
ScaffoldErrorBody(errorMsg: error?.toString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
extension OptionalCast on Object {
|
||||
T? tryCast<T>() => this is T ? this as T : null;
|
||||
}
|
||||
|
||||
extension NullUtilities on Object? {
|
||||
/// Returns true if object is null or is empty
|
||||
bool get isNullOrEmpty => (this == null || _isIterableAndEmpty);
|
||||
|
||||
bool get _isIterableAndEmpty =>
|
||||
this is Iterable ? (this as Iterable).isEmpty : false;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'package:http/http.dart';
|
||||
|
||||
extension LoggerExtension on Response {
|
||||
String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body";
|
||||
}
|
||||
+7
-10
@@ -9,20 +9,18 @@ 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';
|
||||
@@ -95,14 +93,13 @@ Future<Isar> loadDb() async {
|
||||
StoreValueSchema,
|
||||
ExifInfoSchema,
|
||||
AssetSchema,
|
||||
AlbumSchema,
|
||||
UserSchema,
|
||||
BackupAlbumSchema,
|
||||
DuplicatedAssetSchema,
|
||||
LocalAlbumSchema,
|
||||
RemoteAlbumSchema,
|
||||
UserSchema,
|
||||
LoggerMessageSchema,
|
||||
ETagSchema,
|
||||
if (Platform.isAndroid) AndroidDeviceAssetSchema,
|
||||
if (Platform.isIOS) IOSDeviceAssetSchema,
|
||||
DeviceAssetSchema,
|
||||
],
|
||||
directory: dir.path,
|
||||
maxSizeMiB: 256,
|
||||
|
||||
@@ -10,13 +10,14 @@ mixin ErrorLoggerMixin {
|
||||
/// Else, logs the error to the overrided logger and returns an AsyncError<>
|
||||
AsyncFuture<T> guardError<T>(
|
||||
Future<T> Function() fn, {
|
||||
required String errorMessage,
|
||||
Level logLevel = Level.SEVERE,
|
||||
}) async {
|
||||
try {
|
||||
final result = await fn();
|
||||
return AsyncData(result);
|
||||
} catch (error, stackTrace) {
|
||||
logger.log(logLevel, "$error", error, stackTrace);
|
||||
logger.log(logLevel, errorMessage, error, stackTrace);
|
||||
return AsyncError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
@@ -26,12 +27,13 @@ mixin ErrorLoggerMixin {
|
||||
Future<T> logError<T>(
|
||||
Future<T> Function() fn, {
|
||||
required T defaultValue,
|
||||
required String errorMessage,
|
||||
Level logLevel = Level.SEVERE,
|
||||
}) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error, stackTrace) {
|
||||
logger.log(logLevel, "$error", error, stackTrace);
|
||||
logger.log(logLevel, errorMessage, error, stackTrace);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class ActivityService with ErrorLoggerMixin {
|
||||
return list != null ? list.map(Activity.fromDto).toList() : [];
|
||||
},
|
||||
defaultValue: [],
|
||||
errorMessage: "Failed to get all activities for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ class ActivityService with ErrorLoggerMixin {
|
||||
return dto?.comments ?? 0;
|
||||
},
|
||||
defaultValue: 0,
|
||||
errorMessage: "Failed to statistics for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ class ActivityService with ErrorLoggerMixin {
|
||||
return true;
|
||||
},
|
||||
defaultValue: false,
|
||||
errorMessage: "Failed to delete activity",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,21 +57,24 @@ class ActivityService with ErrorLoggerMixin {
|
||||
String? assetId,
|
||||
String? comment,
|
||||
}) async {
|
||||
return guardError(() async {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
throw NoResponseDtoError();
|
||||
});
|
||||
return guardError(
|
||||
() async {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
throw NoResponseDtoError();
|
||||
},
|
||||
errorMessage: "Failed to create $type for album $albumId",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/activities/providers/activity.provider.dar
|
||||
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
|
||||
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
|
||||
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
@@ -22,15 +23,15 @@ class ActivitiesPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Album has to be set in the provider before reaching this page
|
||||
final album = ref.watch(currentAlbumProvider)!;
|
||||
// Album has to be set in the provider before reaching this page and has to be a RemoteAlbum
|
||||
final album = ref.watch(currentAlbumProvider)! as RemoteAlbum;
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activityNotifier = ref
|
||||
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
|
||||
final activityNotifier =
|
||||
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
|
||||
final activities =
|
||||
ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
|
||||
ref.watch(albumActivityProvider(album.id, asset?.remoteId));
|
||||
|
||||
final listViewScrollController = useScrollController();
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ class ActivityTextField extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentAlbumProvider)!;
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
final activityNotifier = ref
|
||||
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
|
||||
final activityNotifier =
|
||||
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final inputController = useTextEditingController();
|
||||
final inputFocusNode = useFocusNode();
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// ignore_for_file: add-copy-with
|
||||
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
part 'album.model.g.dart';
|
||||
|
||||
/// Acts as a common class for RemoteAlbums and LocalAlbums to perform generic album handling irrespective of
|
||||
/// where the album is from
|
||||
sealed class Album {
|
||||
Id get isarId => fastHash(id);
|
||||
@Index(unique: true, replace: true, type: IndexType.hash)
|
||||
final String id;
|
||||
String name;
|
||||
DateTime modifiedAt;
|
||||
final IsarLink<Asset> thumb = IsarLink<Asset>();
|
||||
|
||||
static const assetsLinkId = 'assets';
|
||||
final IsarLinks<Asset> assets = IsarLinks<Asset>();
|
||||
|
||||
@ignore
|
||||
int get assetCount => assets.length;
|
||||
|
||||
@ignore
|
||||
Asset? get thumbnail => thumb.value;
|
||||
|
||||
Album({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.modifiedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Album(id: $id, name: $name, assetCount: $assetCount)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Album other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.name == name &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.thumb == thumb &&
|
||||
other.assetCount == assetCount;
|
||||
}
|
||||
|
||||
@override
|
||||
@ignore
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
name.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
thumb.hashCode ^
|
||||
assetCount.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@Collection()
|
||||
class LocalAlbum extends Album {
|
||||
static const isAllId = 'isAll';
|
||||
|
||||
@Backlink(to: BackupAlbum.albumLinkId)
|
||||
final IsarLink<BackupAlbum> backup = IsarLink<BackupAlbum>();
|
||||
|
||||
LocalAlbum({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.modifiedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LocalAlbum(id: $id, name: $name, assetCount: $assetCount)';
|
||||
}
|
||||
|
||||
static LocalAlbum fromAssetPathEntity(
|
||||
AssetPathEntity ape, {
|
||||
Asset? thumbnail,
|
||||
Iterable<Asset>? assets,
|
||||
}) {
|
||||
final album = LocalAlbum(
|
||||
id: ape.id,
|
||||
name: ape.name,
|
||||
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||
);
|
||||
|
||||
if (assets != null) {
|
||||
album.assets.addAll(assets);
|
||||
}
|
||||
|
||||
album.thumb.value = thumbnail;
|
||||
return album;
|
||||
}
|
||||
}
|
||||
|
||||
@Collection()
|
||||
class RemoteAlbum extends Album {
|
||||
DateTime createdAt;
|
||||
DateTime? startDate;
|
||||
DateTime? endDate;
|
||||
DateTime? lastModifiedAssetTimestamp;
|
||||
bool shared;
|
||||
bool activityEnabled;
|
||||
final IsarLink<User> owner = IsarLink<User>();
|
||||
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
||||
|
||||
@ignore
|
||||
String? get ownerId => owner.value?.id;
|
||||
|
||||
@ignore
|
||||
String? get ownerName {
|
||||
// Guard null owner
|
||||
if (owner.value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final name = <String>[];
|
||||
if (owner.value?.name != null) {
|
||||
name.add(owner.value!.name);
|
||||
}
|
||||
|
||||
return name.join(' ');
|
||||
}
|
||||
|
||||
RemoteAlbum({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.modifiedAt,
|
||||
required this.createdAt,
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
this.shared = false,
|
||||
this.activityEnabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RemoteAlbum(id: $id, name: $name, assetCount: $assetCount, createdAt: $createdAt, startDate: $startDate, endDate: $endDate, lastModifiedAssetTimestamp: $lastModifiedAssetTimestamp, shared: $shared, activityEnabled: $activityEnabled)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant RemoteAlbum other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
final lastModifiedAssetTimestampIsSetAndEqual =
|
||||
lastModifiedAssetTimestamp != null &&
|
||||
other.lastModifiedAssetTimestamp != null
|
||||
? lastModifiedAssetTimestamp!
|
||||
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
|
||||
: true;
|
||||
|
||||
return super == other &&
|
||||
other.createdAt == createdAt &&
|
||||
other.startDate == startDate &&
|
||||
other.endDate == endDate &&
|
||||
lastModifiedAssetTimestampIsSetAndEqual &&
|
||||
other.shared == shared &&
|
||||
other.activityEnabled == activityEnabled;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return super.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
startDate.hashCode ^
|
||||
endDate.hashCode ^
|
||||
lastModifiedAssetTimestamp.hashCode ^
|
||||
shared.hashCode ^
|
||||
activityEnabled.hashCode;
|
||||
}
|
||||
|
||||
static Future<RemoteAlbum> fromDto(AlbumResponseDto dto, Isar db) async {
|
||||
final album = RemoteAlbum(
|
||||
id: dto.id,
|
||||
name: dto.albumName,
|
||||
createdAt: dto.createdAt,
|
||||
modifiedAt: dto.updatedAt,
|
||||
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
|
||||
shared: dto.shared,
|
||||
startDate: dto.startDate,
|
||||
endDate: dto.endDate,
|
||||
activityEnabled: dto.isActivityEnabled,
|
||||
);
|
||||
|
||||
album.owner.value = await db.users.getById(dto.ownerId);
|
||||
|
||||
if (dto.albumThumbnailAssetId != null) {
|
||||
album.thumb.value = await db.assets
|
||||
.where()
|
||||
.remoteIdEqualTo(dto.albumThumbnailAssetId)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
if (dto.sharedUsers.isNotEmpty) {
|
||||
final users = await db.users
|
||||
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
|
||||
album.sharedUsers.addAll(users.cast());
|
||||
}
|
||||
|
||||
if (dto.assets.isNotEmpty) {
|
||||
final assets =
|
||||
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
|
||||
album.assets.addAll(assets);
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
}
|
||||
+2605
File diff suppressed because it is too large
Load Diff
@@ -1,74 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||
AlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
|
||||
query.findAll().then((value) {
|
||||
if (mounted) {
|
||||
state = value;
|
||||
}
|
||||
});
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
}
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<void> getAllAlbums() => Future.wait([
|
||||
_albumService.refreshDeviceAlbums(),
|
||||
_albumService.refreshRemoteAlbums(isShared: false),
|
||||
]);
|
||||
|
||||
Future<void> getDeviceAlbums() => _albumService.refreshDeviceAlbums();
|
||||
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumTitle,
|
||||
Set<Asset> assets,
|
||||
) =>
|
||||
_albumService.createAlbum(albumTitle, assets, []);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final albumProvider =
|
||||
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
|
||||
return AlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final albumWatcher =
|
||||
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
|
||||
final remoteAlbumWatcher =
|
||||
StreamProvider.autoDispose.family<RemoteAlbum, int>((ref, albumId) async* {
|
||||
final db = ref.watch(dbProvider);
|
||||
final a = await db.albums.get(albumId);
|
||||
final a = await db.remoteAlbums.get(albumId);
|
||||
if (a != null) yield a;
|
||||
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
|
||||
await for (final a
|
||||
in db.remoteAlbums.watchObject(albumId, fireImmediately: true)) {
|
||||
if (a != null) yield a;
|
||||
}
|
||||
});
|
||||
|
||||
final albumRenderlistProvider =
|
||||
final remoteAlbumRenderlistProvider =
|
||||
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
|
||||
final album = ref.watch(albumWatcher(albumId)).value;
|
||||
final album = ref.watch(remoteAlbumWatcher(albumId)).value;
|
||||
if (album != null) {
|
||||
final query =
|
||||
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
|
||||
@@ -76,3 +28,25 @@ final albumRenderlistProvider =
|
||||
}
|
||||
return const Stream.empty();
|
||||
});
|
||||
|
||||
final localAlbumWatcher =
|
||||
StreamProvider.autoDispose.family<LocalAlbum, int>((ref, albumId) async* {
|
||||
final db = ref.watch(dbProvider);
|
||||
final a = await db.localAlbums.get(albumId);
|
||||
if (a != null) yield a;
|
||||
await for (final a
|
||||
in db.localAlbums.watchObject(albumId, fireImmediately: true)) {
|
||||
if (a != null) yield a;
|
||||
}
|
||||
});
|
||||
|
||||
final localAlbumRenderlistProvider =
|
||||
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
|
||||
final album = ref.watch(localAlbumWatcher(albumId)).value;
|
||||
if (album != null) {
|
||||
final query =
|
||||
album.assets.filter().localIdIsNotNull().sortByFileCreatedAtDesc();
|
||||
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
|
||||
}
|
||||
return const Stream.empty();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'album_sort_by_options.provider.g.dart';
|
||||
@@ -11,9 +11,13 @@ typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
|
||||
class _AlbumSortHandlers {
|
||||
const _AlbumSortHandlers._();
|
||||
|
||||
/// Sorts a List<Album> based on their created date.
|
||||
///
|
||||
/// ! This is not support for LocalAlbums and they are filtered out from the result
|
||||
static const AlbumSortFn created = _sortByCreated;
|
||||
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sortedBy((album) => album.createdAt);
|
||||
final sorted =
|
||||
albums.whereType<RemoteAlbum>().sortedBy((album) => album.createdAt);
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
@@ -36,9 +40,12 @@ class _AlbumSortHandlers {
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
/// Sorts a List<Album> based on the most recent assets.
|
||||
///
|
||||
/// ! This is not support for LocalAlbums and they are filtered out from the result
|
||||
static const AlbumSortFn mostRecent = _sortByMostRecent;
|
||||
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
|
||||
if (a.endDate != null && b.endDate != null) {
|
||||
return a.endDate!.compareTo(b.endDate!);
|
||||
}
|
||||
@@ -49,9 +56,12 @@ class _AlbumSortHandlers {
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
/// Sorts a List<Album> based on the most oldest assets.
|
||||
///
|
||||
/// ! This is not support for LocalAlbums and they are filtered out from the result
|
||||
static const AlbumSortFn mostOldest = _sortByMostOldest;
|
||||
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
|
||||
if (a.startDate != null && b.startDate != null) {
|
||||
return a.startDate!.compareTo(b.startDate!);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
|
||||
AlbumViewerNotifier(this.ref)
|
||||
@@ -31,7 +31,7 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
|
||||
}
|
||||
|
||||
Future<bool> changeAlbumTitle(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
String newAlbumTitle,
|
||||
) async {
|
||||
AlbumService service = ref.watch(albumServiceProvider);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'current_album.provider.g.dart';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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
|
||||
@@ -0,0 +1,17 @@
|
||||
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),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'remote_album.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class RemoteAlbums extends _$RemoteAlbums {
|
||||
@override
|
||||
Stream<List<RemoteAlbum>> build() async* {
|
||||
final db = ref.read(dbProvider);
|
||||
final stream =
|
||||
db.remoteAlbums.where().watch().listen((v) => state = AsyncData(v));
|
||||
ref.onDispose(() => stream.cancel());
|
||||
yield await db.remoteAlbums.where().findAll();
|
||||
}
|
||||
|
||||
Future<void> getRemoteAlbums([bool isShared = false]) =>
|
||||
ref.read(albumServiceProvider).refreshRemoteAlbums(isShared: isShared);
|
||||
|
||||
Future<bool> deleteAlbum(RemoteAlbum album) =>
|
||||
ref.read(albumServiceProvider).deleteAlbum(album);
|
||||
|
||||
Future<RemoteAlbum?> createAlbum(
|
||||
String albumTitle,
|
||||
Set<Asset> assets,
|
||||
) =>
|
||||
ref.read(albumServiceProvider).createAlbum(albumTitle, assets, []);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'remote_album.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$remoteAlbumsHash() => r'bae71f612bbfcd3e2f193dd63eef53ea1d822af2';
|
||||
|
||||
/// See also [RemoteAlbums].
|
||||
@ProviderFor(RemoteAlbums)
|
||||
final remoteAlbumsProvider =
|
||||
AutoDisposeStreamNotifierProvider<RemoteAlbums, List<RemoteAlbum>>.internal(
|
||||
RemoteAlbums.new,
|
||||
name: r'remoteAlbumsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$remoteAlbumsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$RemoteAlbums = AutoDisposeStreamNotifier<List<RemoteAlbum>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -2,16 +2,17 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
|
||||
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
final query =
|
||||
db.remoteAlbums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
query.findAll().then((value) {
|
||||
if (mounted) {
|
||||
state = value;
|
||||
@@ -21,9 +22,9 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
}
|
||||
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
late final StreamSubscription<List<RemoteAlbum>> _streamSub;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
Future<RemoteAlbum?> createSharedAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets,
|
||||
Iterable<User> sharedUsers,
|
||||
@@ -43,9 +44,10 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Future<void> getAllSharedAlbums() =>
|
||||
_albumService.refreshRemoteAlbums(isShared: true);
|
||||
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
Future<bool> deleteAlbum(RemoteAlbum album) =>
|
||||
_albumService.deleteAlbum(album);
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
Future<bool> leaveAlbum(RemoteAlbum album) async {
|
||||
var res = await _albumService.leaveAlbum(album);
|
||||
|
||||
if (res) {
|
||||
@@ -56,11 +58,11 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
|
||||
Future<bool> removeAssetFromAlbum(RemoteAlbum album, Iterable<Asset> assets) {
|
||||
return _albumService.removeAssetFromAlbum(album, assets);
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(Album album, User user) async {
|
||||
Future<bool> removeUserFromAlbum(RemoteAlbum album, User user) async {
|
||||
final result = await _albumService.removeUserFromAlbum(album, user);
|
||||
|
||||
if (result && album.sharedUsers.isEmpty) {
|
||||
@@ -70,7 +72,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
|
||||
Future<bool> setActivityEnabled(RemoteAlbum album, bool activityEnabled) {
|
||||
return _albumService.setActivityEnabled(album, activityEnabled);
|
||||
}
|
||||
|
||||
@@ -82,7 +84,8 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
}
|
||||
|
||||
final sharedAlbumProvider =
|
||||
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<RemoteAlbum>>(
|
||||
(ref) {
|
||||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'sorted_album.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
List<Album> sortedAlbum(SortedAlbumRef ref, List<Album> albums) {
|
||||
final albumSortOption = ref.watch(albumSortByOptionsProvider);
|
||||
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
|
||||
return albumSortOption.sortFn(albums, albumSortIsReverse);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sorted_album.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sortedAlbumHash() => r'350f537324a42ba9e01e50ca3722878ec3a8330c';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
@ProviderFor(sortedAlbum)
|
||||
const sortedAlbumProvider = SortedAlbumFamily();
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
class SortedAlbumFamily extends Family<List<Album>> {
|
||||
/// See also [sortedAlbum].
|
||||
const SortedAlbumFamily();
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
SortedAlbumProvider call(
|
||||
List<Album> albums,
|
||||
) {
|
||||
return SortedAlbumProvider(
|
||||
albums,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
SortedAlbumProvider getProviderOverride(
|
||||
covariant SortedAlbumProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.albums,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'sortedAlbumProvider';
|
||||
}
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
class SortedAlbumProvider extends AutoDisposeProvider<List<Album>> {
|
||||
/// See also [sortedAlbum].
|
||||
SortedAlbumProvider(
|
||||
List<Album> albums,
|
||||
) : this._internal(
|
||||
(ref) => sortedAlbum(
|
||||
ref as SortedAlbumRef,
|
||||
albums,
|
||||
),
|
||||
from: sortedAlbumProvider,
|
||||
name: r'sortedAlbumProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$sortedAlbumHash,
|
||||
dependencies: SortedAlbumFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
SortedAlbumFamily._allTransitiveDependencies,
|
||||
albums: albums,
|
||||
);
|
||||
|
||||
SortedAlbumProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.albums,
|
||||
}) : super.internal();
|
||||
|
||||
final List<Album> albums;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
List<Album> Function(SortedAlbumRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: SortedAlbumProvider._internal(
|
||||
(ref) => create(ref as SortedAlbumRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
albums: albums,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeProviderElement<List<Album>> createElement() {
|
||||
return _SortedAlbumProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SortedAlbumProvider && other.albums == albums;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, albums.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin SortedAlbumRef on AutoDisposeProviderRef<List<Album>> {
|
||||
/// The parameter `albums` of this provider.
|
||||
List<Album> get albums;
|
||||
}
|
||||
|
||||
class _SortedAlbumProviderElement
|
||||
extends AutoDisposeProviderElement<List<Album>> with SortedAlbumRef {
|
||||
_SortedAlbumProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
List<Album> get albums => (origin as SortedAlbumProvider).albums;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -1,14 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/album_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
@@ -18,9 +14,7 @@ import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final albumServiceProvider = Provider(
|
||||
(ref) => AlbumService(
|
||||
@@ -28,7 +22,6 @@ final albumServiceProvider = Provider(
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -37,9 +30,6 @@ class AlbumService {
|
||||
final UserService _userService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final BackupService _backupService;
|
||||
final Logger _log = Logger('AlbumService');
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
|
||||
AlbumService(
|
||||
@@ -47,98 +37,8 @@ class AlbumService {
|
||||
this._userService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
this._backupService,
|
||||
);
|
||||
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
_log.info("refreshDeviceAlbums is already in progress");
|
||||
return _localCompleter.future;
|
||||
}
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<String> excludedIds =
|
||||
await _backupService.excludedAlbumsQuery().idProperty().findAll();
|
||||
final List<String> selectedIds =
|
||||
await _backupService.selectedAlbumsQuery().idProperty().findAll();
|
||||
if (selectedIds.isEmpty) {
|
||||
final numLocal = await _db.albums.where().localIdIsNotNull().count();
|
||||
if (numLocal > 0) {
|
||||
_syncService.removeAllLocalAlbumsAndAssets();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final List<AssetPathEntity> onDevice =
|
||||
await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||
);
|
||||
_log.info("Found ${onDevice.length} device albums");
|
||||
Set<String>? excludedAssets;
|
||||
if (excludedIds.isNotEmpty) {
|
||||
if (Platform.isIOS) {
|
||||
// iOS and Android device album working principle differ significantly
|
||||
// on iOS, an asset can be in multiple albums
|
||||
// on Android, an asset can only be in exactly one album (folder!) at the same time
|
||||
// thus, on Android, excluding an album can be done by ignoring that album
|
||||
// however, on iOS, it it necessary to load the assets from all excluded
|
||||
// albums and check every asset from any selected album against the set
|
||||
// of excluded assets
|
||||
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
|
||||
_log.info("Found ${excludedAssets.length} assets to exclude");
|
||||
}
|
||||
// remove all excluded albums
|
||||
onDevice.removeWhere((e) => excludedIds.contains(e.id));
|
||||
_log.info(
|
||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||
);
|
||||
}
|
||||
final hasAll = selectedIds
|
||||
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
||||
.whereNotNull()
|
||||
.any((a) => a.isAll);
|
||||
if (hasAll) {
|
||||
if (Platform.isAndroid) {
|
||||
// remove the virtual "Recent" album and keep and individual albums
|
||||
// on Android, the virtual "Recent" `lastModified` value is always null
|
||||
onDevice.removeWhere((e) => e.isAll);
|
||||
_log.info("'Recents' is selected, keeping all individual albums");
|
||||
}
|
||||
} else {
|
||||
// keep only the explicitly selected albums
|
||||
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
|
||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||
}
|
||||
changes =
|
||||
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
|
||||
_log.info("Syncing completed. Changes: $changes");
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadExcludedAssetIds(
|
||||
List<AssetPathEntity> albums,
|
||||
List<String> excludedAlbumIds,
|
||||
) async {
|
||||
final Set<String> result = HashSet<String>();
|
||||
for (AssetPathEntity a in albums) {
|
||||
if (excludedAlbumIds.contains(a.id)) {
|
||||
final List<AssetEntity> assets =
|
||||
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
|
||||
result.addAll(assets.map((e) => e.id));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||
/// updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
|
||||
@@ -170,7 +70,7 @@ class AlbumService {
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
Future<RemoteAlbum?> createAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets, [
|
||||
Iterable<User> sharedUsers = const [],
|
||||
@@ -184,8 +84,8 @@ class AlbumService {
|
||||
),
|
||||
);
|
||||
if (remote != null) {
|
||||
Album album = await Album.remote(remote);
|
||||
await _db.writeTxn(() => _db.albums.store(album));
|
||||
RemoteAlbum album = await RemoteAlbum.fromDto(remote, _db);
|
||||
await _db.writeTxn(() => _db.remoteAlbums.store(album));
|
||||
return album;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -203,13 +103,16 @@ class AlbumService {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (null ==
|
||||
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
|
||||
await _db.remoteAlbums
|
||||
.filter()
|
||||
.nameEqualTo(proposedName)
|
||||
.findFirst()) {
|
||||
return proposedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> createAlbumWithGeneratedName(
|
||||
Future<RemoteAlbum?> createAlbumWithGeneratedName(
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
return createAlbum(
|
||||
@@ -221,11 +124,17 @@ class AlbumService {
|
||||
|
||||
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
|
||||
Iterable<Asset> assets,
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
) async {
|
||||
try {
|
||||
final remoteAlbum =
|
||||
await _db.remoteAlbums.where().idEqualTo(album.id).findFirst();
|
||||
if (remoteAlbum == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var response = await _apiService.albumApi.addAssetsToAlbum(
|
||||
album.remoteId!,
|
||||
remoteAlbum.id,
|
||||
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
|
||||
);
|
||||
|
||||
@@ -244,10 +153,10 @@ class AlbumService {
|
||||
}
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await album.assets.update(link: successAssets);
|
||||
final a = await _db.albums.get(album.id);
|
||||
await remoteAlbum.assets.update(link: successAssets);
|
||||
final a = await _db.remoteAlbums.get(remoteAlbum.isarId);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
await _db.remoteAlbums.put(a!);
|
||||
});
|
||||
|
||||
return AddAssetsResponse(
|
||||
@@ -264,11 +173,11 @@ class AlbumService {
|
||||
|
||||
Future<bool> addAdditionalUserToAlbum(
|
||||
List<String> sharedUserIds,
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.addUsersToAlbum(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
AddUsersDto(sharedUserIds: sharedUserIds),
|
||||
);
|
||||
if (result != null) {
|
||||
@@ -276,7 +185,7 @@ class AlbumService {
|
||||
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
|
||||
album.shared = result.shared;
|
||||
await _db.writeTxn(() async {
|
||||
await _db.albums.put(album);
|
||||
await _db.remoteAlbums.put(album);
|
||||
await album.sharedUsers.save();
|
||||
});
|
||||
return true;
|
||||
@@ -287,15 +196,15 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool enabled) async {
|
||||
Future<bool> setActivityEnabled(RemoteAlbum album, bool enabled) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
UpdateAlbumDto(isActivityEnabled: enabled),
|
||||
);
|
||||
if (result != null) {
|
||||
album.activityEnabled = enabled;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
await _db.writeTxn(() => _db.remoteAlbums.put(album));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -304,20 +213,20 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
Future<bool> deleteAlbum(RemoteAlbum album) async {
|
||||
try {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
if (album.owner.value?.isarId == userId) {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
await _apiService.albumApi.deleteAlbum(album.id);
|
||||
}
|
||||
if (album.shared) {
|
||||
final foreignAssets =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
final List<Album> albums =
|
||||
await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
|
||||
final List<RemoteAlbum> albums =
|
||||
await _db.remoteAlbums.filter().sharedEqualTo(true).findAll();
|
||||
final List<Asset> existing = [];
|
||||
for (Album a in albums) {
|
||||
for (RemoteAlbum a in albums) {
|
||||
existing.addAll(
|
||||
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
|
||||
);
|
||||
@@ -328,7 +237,7 @@ class AlbumService {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||
}
|
||||
} else {
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -337,9 +246,9 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
Future<bool> leaveAlbum(RemoteAlbum album) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
|
||||
await _apiService.albumApi.removeUserFromAlbum(album.id, "me");
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||
@@ -348,21 +257,21 @@ class AlbumService {
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeAssetFromAlbum(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
BulkIdsDto(
|
||||
ids: assets.map((asset) => asset.remoteId!).toList(),
|
||||
),
|
||||
);
|
||||
await _db.writeTxn(() async {
|
||||
await album.assets.update(unlink: assets);
|
||||
final a = await _db.albums.get(album.id);
|
||||
final a = await _db.remoteAlbums.get(album.isarId);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
await _db.remoteAlbums.put(a!);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -373,21 +282,21 @@ class AlbumService {
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
User user,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
user.id,
|
||||
);
|
||||
|
||||
album.sharedUsers.remove(user);
|
||||
await _db.writeTxn(() async {
|
||||
await album.sharedUsers.update(unlink: [user]);
|
||||
final a = await _db.albums.get(album.id);
|
||||
final a = await _db.remoteAlbums.get(album.isarId);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
await _db.remoteAlbums.put(a!);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -398,18 +307,18 @@ class AlbumService {
|
||||
}
|
||||
|
||||
Future<bool> changeTitleAlbum(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
String newAlbumTitle,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
UpdateAlbumDto(
|
||||
albumName: newAlbumTitle,
|
||||
),
|
||||
);
|
||||
album.name = newAlbumTitle;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
await _db.writeTxn(() => _db.remoteAlbums.put(album));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
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/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
@@ -25,14 +25,14 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||
final albums = ref.watch(remoteAlbumsProvider).valueOrNull ?? [];
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// Fetch album updates, e.g., cover image
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return null;
|
||||
@@ -40,7 +40,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
void addToAlbum(Album album) async {
|
||||
void addToAlbum(RemoteAlbum album) async {
|
||||
final result = await albumService.addAdditionalAssetToAlbum(
|
||||
assets,
|
||||
album,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
/// The asset to add to an album
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
final void Function(Album) onAddToAlbum;
|
||||
final List<RemoteAlbum> albums;
|
||||
final List<RemoteAlbum> sharedAlbums;
|
||||
final void Function(RemoteAlbum) onAddToAlbum;
|
||||
final bool enabled;
|
||||
|
||||
const AddToAlbumSliverList({
|
||||
@@ -46,9 +46,11 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: sortedSharedAlbums.length,
|
||||
itemBuilder: (context, index) => AlbumThumbnailListTile(
|
||||
album: sortedSharedAlbums[index],
|
||||
album: sortedSharedAlbums[index] as RemoteAlbum,
|
||||
onTap: enabled
|
||||
? () => onAddToAlbum(sortedSharedAlbums[index])
|
||||
? () => onAddToAlbum(
|
||||
sortedSharedAlbums[index] as RemoteAlbum,
|
||||
)
|
||||
: () {},
|
||||
),
|
||||
),
|
||||
@@ -59,7 +61,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
|
||||
// Build albums list
|
||||
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
|
||||
final album = sortedAlbums[offset];
|
||||
final album = sortedAlbums[offset] as RemoteAlbum;
|
||||
return AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: enabled ? () => onAddToAlbum(album) : () {},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
|
||||
class AlbumSortSelector extends ConsumerWidget {
|
||||
final List<AlbumSortMode> sortModes;
|
||||
|
||||
const AlbumSortSelector({required this.sortModes, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albumSortMode = ref.watch(albumSortByOptionsProvider);
|
||||
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
|
||||
|
||||
List<PopupMenuEntry<AlbumSortMode>> buildOptions(BuildContext ctx) {
|
||||
{
|
||||
return sortModes.map((option) {
|
||||
final selected = albumSortMode == option;
|
||||
return PopupMenuItem(
|
||||
value: option,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: selected ? context.primaryColor : Colors.transparent,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
option.label.tr(),
|
||||
style: TextStyle(
|
||||
color: selected ? context.primaryColor : null,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
void onSelected(AlbumSortMode mode) {
|
||||
final isAlreadySelected = albumSortMode == mode;
|
||||
// Switch direction
|
||||
if (isAlreadySelected) {
|
||||
return ref
|
||||
.read(albumSortOrderProvider.notifier)
|
||||
.changeSortDirection(!albumSortIsReverse);
|
||||
}
|
||||
return ref.read(albumSortByOptionsProvider.notifier).changeSortMode(mode);
|
||||
}
|
||||
|
||||
return PopupMenuButton(
|
||||
position: PopupMenuPosition.over,
|
||||
itemBuilder: buildOptions,
|
||||
onSelected: onSelected,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: Icon(
|
||||
albumSortIsReverse
|
||||
? Icons.arrow_downward_rounded
|
||||
: Icons.arrow_upward_rounded,
|
||||
size: 14,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
albumSortMode.label.tr(),
|
||||
style: context.textTheme.labelLarge
|
||||
?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/extensions/object_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
|
||||
class AlbumThumbnailCard extends StatelessWidget {
|
||||
final Album album;
|
||||
final Function()? onTap;
|
||||
final bool showAssetCount;
|
||||
final Icon? emptyThumbnailPlaceholder;
|
||||
|
||||
/// Whether or not to show the owner of the album (or "Owned")
|
||||
/// in the subtitle of the album
|
||||
@@ -16,11 +20,11 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
super.key,
|
||||
required this.album,
|
||||
this.onTap,
|
||||
this.showAssetCount = true,
|
||||
this.showOwner = false,
|
||||
this.emptyThumbnailPlaceholder,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isDarkTheme = context.isDarkTheme;
|
||||
@@ -29,62 +33,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
builder: (context, constraints) {
|
||||
var cardSize = constraints.maxWidth;
|
||||
|
||||
buildEmptyThumbnail() {
|
||||
return Container(
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkTheme ? Colors.grey[800] : Colors.grey[200],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.no_photography,
|
||||
size: cardSize * .15,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() => ImmichImage.thumbnail(
|
||||
album.thumbnail.value,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
);
|
||||
|
||||
buildAlbumTextRow() {
|
||||
// Add the owner name to the subtitle
|
||||
String? owner;
|
||||
if (showOwner) {
|
||||
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
|
||||
owner = 'album_thumbnail_owned'.tr();
|
||||
} else if (album.ownerName != null) {
|
||||
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
|
||||
}
|
||||
}
|
||||
|
||||
return RichText(
|
||||
overflow: TextOverflow.fade,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
.tr(args: ['${album.assetCount}'])
|
||||
: 'album_thumbnail_card_items'
|
||||
.tr(args: ['${album.assetCount}']),
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
if (owner != null) const TextSpan(text: ' · '),
|
||||
if (owner != null)
|
||||
TextSpan(
|
||||
text: owner,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Flex(
|
||||
@@ -98,14 +46,46 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: album.thumbnail.value == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(20)),
|
||||
child: album.thumbnail == null
|
||||
// Empty placeholder
|
||||
? Container(
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 53, 53, 53)
|
||||
: const Color.fromARGB(
|
||||
255,
|
||||
203,
|
||||
203,
|
||||
203,
|
||||
),
|
||||
),
|
||||
color: isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[50],
|
||||
),
|
||||
child: Center(
|
||||
child: emptyThumbnailPlaceholder ??
|
||||
Icon(
|
||||
Icons.no_photography,
|
||||
size: cardSize * .15,
|
||||
),
|
||||
),
|
||||
)
|
||||
// Thumbnail image
|
||||
: ImmichThumbnail(
|
||||
asset: album.thumbnail,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
padding: const EdgeInsets.only(top: 8.0, left: 8.0),
|
||||
child: SizedBox(
|
||||
width: cardSize,
|
||||
child: Text(
|
||||
@@ -118,7 +98,14 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
buildAlbumTextRow(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: _AlbumTextRow(
|
||||
album: album,
|
||||
showAssetCount: showAssetCount,
|
||||
showOwner: showOwner,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -129,3 +116,55 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumTextRow extends StatelessWidget {
|
||||
final Album album;
|
||||
final bool showAssetCount;
|
||||
|
||||
/// Whether or not to show the owner of the album (or "Owned")
|
||||
/// in the subtitle of the album
|
||||
final bool showOwner;
|
||||
|
||||
const _AlbumTextRow({
|
||||
required this.album,
|
||||
required this.showAssetCount,
|
||||
required this.showOwner,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String? owner;
|
||||
|
||||
if (showOwner) {
|
||||
if (album.tryCast<RemoteAlbum>()?.ownerId ==
|
||||
Store.get(StoreKey.currentUser).id) {
|
||||
owner = 'album_thumbnail_owned'.tr();
|
||||
} else if (album.tryCast<RemoteAlbum>()?.ownerName != null) {
|
||||
owner = 'album_thumbnail_shared_by'
|
||||
.tr(args: [(album as RemoteAlbum).ownerName!]);
|
||||
}
|
||||
}
|
||||
|
||||
return Text.rich(
|
||||
overflow: TextOverflow.fade,
|
||||
TextSpan(
|
||||
children: [
|
||||
if (showAssetCount)
|
||||
TextSpan(
|
||||
text: album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
.tr(args: ['${album.assetCount}'])
|
||||
: 'album_thumbnail_card_items'
|
||||
.tr(args: ['${album.assetCount}']),
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
if (owner != null) const TextSpan(text: ' · '),
|
||||
TextSpan(
|
||||
text: owner,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -16,7 +16,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
final void Function()? onTap;
|
||||
|
||||
@override
|
||||
@@ -61,7 +61,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap ??
|
||||
() {
|
||||
context.pushRoute(AlbumViewerRoute(albumId: album.id));
|
||||
context.pushRoute(RemoteAlbumViewerRoute(albumId: album.isarId));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
@@ -70,7 +70,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: album.thumbnail.value == null
|
||||
child: album.thumbnail == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
|
||||
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
final FocusNode titleFocusNode;
|
||||
const AlbumViewerEditableTitle({
|
||||
super.key,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user