Compare commits

..

13 Commits

Author SHA1 Message Date
mertalev
ea33cd9515 remove version flag 2024-05-14 20:40:36 -04:00
mertalev
e9ceb8b017 fix mutating config 2024-05-14 20:40:36 -04:00
mertalev
94030d809f add hw decode toggle 2024-05-14 20:40:36 -04:00
mertalev
4db0717a08 update api 2024-05-14 20:40:36 -04:00
mertalev
b6808c1675 separate configs for hw/sw 2024-05-14 20:40:36 -04:00
mertalev
a2b7403978 fix software tone-mapping not being applied 2024-05-14 20:40:36 -04:00
mertalev
1556d978ed toggle for hardware decoding, software / hardware decoding for nvenc and rkmpp 2024-05-14 20:40:36 -04:00
mertalev
48a71ac4d9 refactor 2024-05-14 20:38:08 -04:00
mertalev
adf620331c tweak settings 2024-05-14 20:38:08 -04:00
mertalev
c92637df80 update nvenc options 2024-05-14 20:38:08 -04:00
mertalev
3356b023c5 tweaks 2024-05-14 20:38:08 -04:00
mertalev
a2e8b657e6 libplacebo for nvenc
update dockerfile
2024-05-14 20:38:08 -04:00
mertalev
f420befc15 use arrayContaining 2024-05-14 20:37:56 -04:00
1322 changed files with 36349 additions and 22282 deletions

2
.gitattributes vendored
View File

@@ -2,6 +2,8 @@ mobile/openapi/**/*.md -diff -merge
mobile/openapi/**/*.md linguist-generated=true
mobile/openapi/**/*.dart -diff -merge
mobile/openapi/**/*.dart linguist-generated=true
mobile/openapi/.openapi-generator/FILES -diff -merge
mobile/openapi/.openapi-generator/FILES linguist-generated=true
mobile/lib/**/*.g.dart -diff -merge
mobile/lib/**/*.g.dart linguist-generated=true

View File

@@ -45,7 +45,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
flutter-version: '3.19.3'
cache: true
- name: Create the Keystore

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.7.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.7.0
uses: stumpylog/image-cleaner-action/untagged@v0.6.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"

View File

@@ -1,43 +0,0 @@
name: Docs build
on:
push:
branches: [main]
paths:
- "docs/**"
pull_request:
branches: [main]
paths:
- "docs/**"
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
- name: Check formatting
run: npm run format
- name: Run build
run: npm run build
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: docs-build-output
path: docs/build/
retention-days: 1

View File

@@ -1,189 +0,0 @@
name: Docs deploy
on:
workflow_run:
workflows: ["Docs build"]
types:
- completed
jobs:
checks:
runs-on: ubuntu-latest
outputs:
parameters: ${{ steps.parameters.outputs.result }}
steps:
- if: ${{ github.event.workflow_run.conclusion == 'failure' }}
run: echo 'The triggering workflow failed' && exit 1
- name: Determine deploy parameters
id: parameters
uses: actions/github-script@v7
with:
script: |
const eventType = context.payload.workflow_run.event;
const isFork = context.payload.workflow_run.repository.fork;
let parameters;
console.log({eventType, isFork});
if (eventType == "push") {
const branch = context.payload.workflow_run.head_branch;
console.log({branch});
const shouldDeploy = !isFork && branch == "main";
parameters = {
event: "branch",
name: "main",
shouldDeploy
};
} else if (eventType == "pull_request") {
let pull_number = context.payload.workflow_run.pull_requests[0]?.number;
if(!pull_number) {
const response = await github.rest.search.issuesAndPullRequests({q: 'repo:${{ github.repository }} is:pr sha:${{ github.event.workflow_run.head_sha }}',per_page: 1,})
const items = response.data.items
if (items.length < 1) {
throw new Error("No pull request found for the commit")
}
const pullRequestNumber = items[0].number
console.info("Pull request number is", pullRequestNumber)
pull_number = pullRequestNumber
}
const {data: pr} = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number
});
console.log({pull_number});
parameters = {
event: "pr",
name: `pr-${pull_number}`,
pr_number: pull_number,
shouldDeploy: true
};
} else if (eventType == "release") {
parameters = {
event: "release",
name: context.payload.workflow_run.head_branch,
shouldDeploy: !isFork
};
}
console.log(parameters);
return parameters;
deploy:
runs-on: ubuntu-latest
needs: checks
if: ${{ fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Load parameters
id: parameters
uses: actions/github-script@v7
with:
script: |
const json = `${{ needs.checks.outputs.parameters }}`;
const parameters = JSON.parse(json);
core.setOutput("event", parameters.event);
core.setOutput("name", parameters.name);
core.setOutput("shouldDeploy", parameters.shouldDeploy);
- run: |
echo "Starting docs deployment for ${{ steps.parameters.outputs.event }} ${{ steps.parameters.outputs.name }}"
- name: Download artifact
uses: actions/github-script@v7
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "docs-build-output"
})[0];
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/docs-build-output.zip`, Buffer.from(download.data));
- name: Unzip artifact
run: unzip "${{ github.workspace }}/docs-build-output.zip" -d "${{ github.workspace }}/docs/build"
- name: Deploy Docs Subdomain
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
TF_VAR_prefix_event_type: ${{ steps.parameters.outputs.event }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@v2
with:
tg_version: "0.58.12"
tofu_version: "1.7.1"
tg_dir: "deployment/modules/cloudflare/docs"
tg_command: "apply"
- name: Deploy Docs Subdomain Output
id: docs-output
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
TF_VAR_prefix_event_type: ${{ steps.parameters.outputs.event }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@v2
with:
tg_version: "0.58.12"
tofu_version: "1.7.1"
tg_dir: "deployment/modules/cloudflare/docs"
tg_command: "output -json"
- name: Output Cleaning
id: clean
run: |
TG_OUT=$(echo '${{ steps.docs-output.outputs.tg_action_output }}' | sed 's|%0A|\n|g ; s|%3C|<|g' | jq -c .)
echo "output=$TG_OUT" >> $GITHUB_OUTPUT
- name: Publish to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ fromJson(steps.clean.outputs.output).pages_project_name.value }}
workingDirectory: "docs"
directory: "build"
branch: ${{ steps.parameters.outputs.name }}
wranglerVersion: '3'
- name: Deploy Docs Release Domain
if: ${{ steps.parameters.outputs.event == 'release' }}
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@v2
with:
tg_version: '0.58.12'
tofu_version: '1.7.1'
tg_dir: 'deployment/modules/cloudflare/docs-release'
tg_command: 'apply'
- name: Comment
uses: actions-cool/maintain-one-comment@v3
if: ${{ steps.parameters.outputs.event == 'pr' }}
with:
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
body: |
📖 Documentation deployed to [${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }}](https://${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }})
emojis: 'rocket'
body-include: '<!-- Docs PR URL -->'

View File

@@ -1,32 +0,0 @@
name: Docs destroy
on:
pull_request_target:
types: [closed]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Destroy Docs Subdomain
env:
TF_VAR_prefix_name: "pr-${{ github.event.number }}"
TF_VAR_prefix_event_type: "pr"
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
uses: gruntwork-io/terragrunt-action@v2
with:
tg_version: "0.58.12"
tofu_version: "1.7.1"
tg_dir: "deployment/modules/cloudflare/docs"
tg_command: "destroy"
- name: Comment
uses: actions-cool/maintain-one-comment@v3
with:
number: ${{ github.event.number }}
delete: true
body-include: '<!-- Docs PR URL -->'

View File

@@ -1,15 +0,0 @@
name: PR Conventional Commit Validation
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
validate-pr-title:
runs-on: ubuntu-latest
steps:
- name: PR Conventional Commit Validation
uses: ytanikin/PRConventionalCommits@1.2.0
with:
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
add_label: 'false'

View File

@@ -22,8 +22,8 @@ jobs:
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
channel: "stable"
flutter-version: "3.19.3"
- name: Install dependencies
run: dart pub get

View File

@@ -10,6 +10,28 @@ concurrency:
cancel-in-progress: true
jobs:
doc-tests:
name: Docs
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
- name: Run formatter
run: npm run format
if: ${{ !cancelled() }}
- name: Run build
run: npm run build
if: ${{ !cancelled() }}
server-unit-tests:
name: Server
runs-on: ubuntu-latest
@@ -186,7 +208,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
flutter-version: '3.19.3'
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1
@@ -238,18 +260,9 @@ jobs:
name: OpenAPI Clients
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install server dependencies
run: npm --prefix=server ci
- name: Build the app
run: npm --prefix=server run build
- uses: actions/checkout@v4
- name: Run API generation
run: make open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@v20
id: verify-changed-files
@@ -257,8 +270,6 @@ jobs:
files: |
mobile/openapi
open-api/typescript-sdk
open-api/immich-openapi-specs.json
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |
@@ -321,7 +332,7 @@ jobs:
exit 1
- name: Run SQL generation
run: npm run sync:sql
run: npm run sql:generate
env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich

5
.gitignore vendored
View File

@@ -14,10 +14,7 @@ mobile/gradle.properties
mobile/openapi/pubspec.lock
mobile/*.jks
mobile/libisar.dylib
mobile/openapi/test
mobile/openapi/doc
mobile/openapi/.openapi-generator/FILES
open-api/typescript-sdk/build
mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
mobile/ios/fastlane/report.xml

14
.vscode/settings.json vendored
View File

@@ -1,16 +1,6 @@
{
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[css]": {
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
@@ -41,4 +31,4 @@
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
}
}
}

View File

@@ -37,7 +37,7 @@ open-api-typescript:
cd ./open-api && bash ./bin/generate-open-api.sh typescript
sql:
npm --prefix server run sync:sql
npm --prefix server run sql:generate
attach-server:
docker exec -it docker_immich-server_1 sh

1
base-images Submodule

Submodule base-images added at d0d3ab018c

View File

@@ -1 +1 @@
20.14
20.13

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine3.19@sha256:696ae41fb5880949a15ade7879a2deae93b3f0723f757bdb5b8a9e4a744ce27f as core
FROM node:20-alpine3.19@sha256:291e84d956f1aff38454bbd3da38941461ad569a185c20aa289f71f37ea08e23 as core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

284
cli/package-lock.json generated
View File

@@ -31,7 +31,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^53.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
@@ -54,7 +54,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.12.13",
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
},
@@ -174,9 +174,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz",
"integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==",
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@@ -1113,6 +1113,12 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
@@ -1138,11 +1144,10 @@
}
},
"node_modules/@types/node": {
"version": "20.12.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.13.tgz",
"integrity": "sha512-gBGeanV41c1L171rR7wjbMiEpEI/l5XFQdLLfhr/REwpgDy/4U8y89+i8kRiLzDyZdOkXh+cRaTetUnCYutoXA==",
"version": "20.12.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz",
"integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -1153,21 +1158,28 @@
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz",
"integrity": "sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz",
"integrity": "sha512-gFTT+ezJmkwutUPmB0skOj3GZJtlEGnlssems4AjkVweUPGj7jRwwqg0Hhg7++kPGJqKtTYx+R05Ftww372aIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/type-utils": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/type-utils": "7.8.0",
"@typescript-eslint/utils": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
"semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
@@ -1188,16 +1200,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.11.0.tgz",
"integrity": "sha512-yimw99teuaXVWsBcPO1Ais02kwJ1jmNA1KxE7ng0aT7ndr1pT1wqj0OJnsYVGKKlc4QJai86l/025L6z8CljOg==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.8.0.tgz",
"integrity": "sha512-KgKQly1pv0l4ltcftP59uQZCi4HUYswCLbTqVZEJu7uLX8CTLyswqMLqLN+2QFz4jCptqWVV4SB7vdxcH2+0kQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1217,14 +1228,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz",
"integrity": "sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.8.0.tgz",
"integrity": "sha512-viEmZ1LmwsGcnr85gIq+FCYI7nO90DVbE37/ll51hjv9aG+YZMb4WDE2fyWpUR4O/UrhGRpYXK/XajcGTk2B8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0"
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1235,14 +1245,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz",
"integrity": "sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.8.0.tgz",
"integrity": "sha512-H70R3AefQDQpz9mGv13Uhi121FNMh+WEaRqcXTX09YEDky21km4dV1ZXJIp8QjXc4ZaVkXVdohvWDzbnbHDS+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"@typescript-eslint/utils": "7.8.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -1263,11 +1272,10 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz",
"integrity": "sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz",
"integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
@@ -1277,14 +1285,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz",
"integrity": "sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz",
"integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1306,16 +1313,18 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz",
"integrity": "sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.8.0.tgz",
"integrity": "sha512-L0yFqOCflVqXxiZyXrDr80lnahQfSOfc9ELAAZ75sqicqp2i36kEZZGuUymHNFoYOqxRT05up760b4iGsl02nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0"
"@types/json-schema": "^7.0.15",
"@types/semver": "^7.5.8",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"semver": "^7.6.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1329,13 +1338,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz",
"integrity": "sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.8.0.tgz",
"integrity": "sha512-q4/gibTNBQNA0lGyYQCmWRS5D15n8rXh4QjK3KV+MBPlTYHpfBUT3D3PaPR/HeNiI9W6R7FvlkcGhNyAoP+caA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/types": "7.8.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -1556,7 +1564,6 @@
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -1581,7 +1588,6 @@
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -1801,11 +1807,10 @@
"dev": true
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz",
"integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
@@ -1817,12 +1822,12 @@
"dev": true
},
"node_modules/core-js-compat": {
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",
"integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==",
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz",
"integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==",
"dev": true,
"dependencies": {
"browserslist": "^4.23.0"
"browserslist": "^4.22.3"
},
"funding": {
"type": "opencollective",
@@ -1892,7 +1897,6 @@
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
@@ -2090,17 +2094,17 @@
}
},
"node_modules/eslint-plugin-unicorn": {
"version": "53.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz",
"integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==",
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.24.5",
"@babel/helper-validator-identifier": "^7.22.20",
"@eslint-community/eslint-utils": "^4.4.0",
"@eslint/eslintrc": "^3.0.2",
"@eslint/eslintrc": "^2.1.4",
"ci-info": "^4.0.0",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.37.0",
"core-js-compat": "^3.34.0",
"esquery": "^1.5.0",
"indent-string": "^4.0.0",
"is-builtin-module": "^3.2.1",
@@ -2109,11 +2113,11 @@
"read-pkg-up": "^7.0.1",
"regexp-tree": "^0.1.27",
"regjsparser": "^0.10.0",
"semver": "^7.6.1",
"semver": "^7.5.4",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=18.18"
"node": ">=16"
},
"funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
@@ -2122,92 +2126,6 @@
"eslint": ">=8.56.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/@eslint/eslintrc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz",
"integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==",
"dev": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/eslint-visitor-keys": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
"integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/espree": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz",
"integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==",
"dev": true,
"dependencies": {
"acorn": "^8.11.3",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@@ -2548,7 +2466,6 @@
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
@@ -3052,7 +2969,6 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -3316,7 +3232,6 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -3796,10 +3711,13 @@
}
},
"node_modules/semver": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
"integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -3807,6 +3725,18 @@
"node": ">=10"
}
},
"node_modules/semver/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3851,7 +3781,6 @@
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -4281,11 +4210,10 @@
}
},
"node_modules/vite": {
"version": "5.2.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.12.tgz",
"integrity": "sha512-/gC8GxzxMK5ntBwb48pR32GGhENnjtY30G4A0jemunsBkiEZFw60s8InGpN8gkhHEkjnRK1aSAxeQgwvFhUHAA==",
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz",
"integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.20.1",
"postcss": "^8.4.38",
@@ -4479,6 +4407,12 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/yaml": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",

View File

@@ -28,7 +28,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^53.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
@@ -62,6 +62,6 @@
"lodash-es": "^4.17.21"
},
"volta": {
"node": "20.14.0"
"node": "20.13.1"
}
}

View File

@@ -1,8 +1,7 @@
import {
Action,
AssetBulkUploadCheckResult,
AssetMediaResponseDto,
AssetMediaStatus,
AssetFileUploadResponseDto,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
@@ -168,7 +167,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
newAssets.push({ id: response.id, filepath });
if (response.status === AssetMediaStatus.Duplicate) {
if (response.duplicate) {
duplicateCount++;
duplicateSize += stats.size ?? 0;
} else {
@@ -193,7 +192,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
return newAssets;
};
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadResponseDto> => {
const { baseUrl, headers } = defaults;
const assetPath = path.parse(input);
@@ -226,7 +225,7 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
formData.append('sidecarData', sidecarData);
}
const response = await fetch(`${baseUrl}/assets`, {
const response = await fetch(`${baseUrl}/asset/upload`, {
method: 'post',
redirect: 'error',
headers: headers as Record<string, string>,

View File

@@ -1,4 +1,4 @@
import { getMyUser } from '@immich/sdk';
import { getMyUserInfo } from '@immich/sdk';
import { existsSync } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils';
@@ -10,13 +10,13 @@ export const login = async (url: string, key: string, options: BaseOptions) => {
await connect(url, key);
const [error, user] = await withError(getMyUser());
const [error, userInfo] = await withError(getMyUserInfo());
if (error) {
logError(error, 'Failed to load user info');
process.exit(1);
}
console.log(`Logged in as ${user.email}`);
console.log(`Logged in as ${userInfo.email}`);
if (!existsSync(configDir)) {
// Create config folder if it doesn't exist

View File

@@ -1,4 +1,4 @@
import { getAssetStatistics, getMyUser, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { BaseOptions, authenticate } from 'src/utils';
export const serverInfo = async (options: BaseOptions) => {
@@ -8,7 +8,7 @@ export const serverInfo = async (options: BaseOptions) => {
getServerVersion(),
getSupportedMediaTypes(),
getAssetStatistics({}),
getMyUser(),
getMyUserInfo(),
]);
console.log(`Server Info (via ${userInfo.email})`);

View File

@@ -1,4 +1,4 @@
import { getMyUser, init, isHttpError } from '@immich/sdk';
import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk';
import { glob } from 'fast-glob';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
@@ -46,9 +46,10 @@ export const connect = async (url: string, key: string) => {
// noop
}
init({ baseUrl: url, apiKey: key });
defaults.baseUrl = url;
defaults.headers = { 'x-api-key': key };
const [error] = await withError(getMyUser());
const [error] = await withError(getMyUserInfo());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
process.exit(1);

37
cli/src/version.ts Normal file
View File

@@ -0,0 +1,37 @@
import { version } from '../package.json';
export interface ICliVersion {
major: number;
minor: number;
patch: number;
}
export class CliVersion implements ICliVersion {
constructor(
public readonly major: number,
public readonly minor: number,
public readonly patch: number,
) {}
toString() {
return `${this.major}.${this.minor}.${this.patch}`;
}
toJSON() {
const { major, minor, patch } = this;
return { major, minor, patch };
}
static fromString(version: string): CliVersion {
const regex = /v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
const matchResult = version.match(regex);
if (matchResult) {
const [, major, minor, patch] = matchResult.map(Number);
return new CliVersion(major, minor, patch);
} else {
throw new Error(`Invalid version format: ${version}`);
}
}
}
export const cliVersion = CliVersion.fromString(version);

38
deployment/.gitignore vendored
View File

@@ -1,38 +0,0 @@
# OpenTofu
# Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
# Ignore CLI configuration files
.terraformrc
terraform.rc
# Terragrunt
# terragrunt cache directories
**/.terragrunt-cache/*
# Terragrunt debug output file (when using `--terragrunt-debug` option)
# See: https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-debug
terragrunt-debug.tfvars.json

View File

@@ -1,38 +0,0 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.34.0"
constraints = "4.34.0"
hashes = [
"h1:+W0+Xe1AUh7yvHjDbgR9T7CY1UbBC3Y6U7Eo+ucLnJM=",
"h1:2+1lKObDDdFZRluvROF3RKtXD66CFT3PfnHOvR6CmfA=",
"h1:7vluN2wmw8D9nI11YwTgoGv3hGDXlkt8xqQ4L/JABeQ=",
"h1:B0Urm8ZKTJ8cXzSCtEpJ+o+LsD8MXaD6LU59qVbh50Q=",
"h1:FpGLCm5oF12FaRti3E4iQJlkVbdCC7toyGVuH8og7KY=",
"h1:FunTmrCMDy+rom7YskY0WiL5/Y164zFrrD9xnBxU5NY=",
"h1:GrxZhEb+5HzmHF/BvZBdGKBJy6Wyjme0+ABVDz/63to=",
"h1:J36dda2K42/oTfHuZ4jKkW5+nI6BTWFRUvo60P17NJg=",
"h1:Kq0Wyn+j6zoQeghMYixbnfnyP9ZSIEJbOCzMbaCiAQQ=",
"h1:TKxunXCiS/z105sN/kBNFwU6tIKD67JKJ3ZKjwzoCuI=",
"h1:TR0URKFQxsRO5/v7bKm5hkD/CTTjsG7aVGllL/Mf25c=",
"h1:V+3Qs0Reb6r+8p4XjE5ZFDWYrOIN0x5SwORz4wvHOJ4=",
"h1:mZB3Ui7V/lPQMQK53eBOjIHcrul74252dT06Kgn3J+s=",
"h1:wJwZrIXxoki8omXLJ7XA7B1KaSrtcLMJp090fRtFRAc=",
"zh:02aa46743c1585ada8faa7db23af68ea614053a506f88f05d1090ff5e0e68076",
"zh:1e1a545e83e6457a0e15357b23139bc288fb4fbd5e9a5ddfedc95a6a0216b08c",
"zh:29eef2621e0b1501f620e615bf73b1b90d5417d745e38af63634bc03250faf87",
"zh:3c20989d7e1e141882e6091384bf85fdc83f70f3d29e3e047c493a07de992095",
"zh:3d39619379ba29c7ffb15196f0ea72a04c84cfcdf4b39ac42ac4cf4c19f3eae2",
"zh:805f4a2774e9279c590b8214aabe6df9dcc22bb995df2530513f2f78c647ce75",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8af716f8655a57aa986861a8a7fa1d724594a284bd77c870eaea4db5f8b9732d",
"zh:a3d13c93b4e6ee6004782debaa9a17f990f2fe8ec8ba545c232818bb6064aba9",
"zh:bfa136acf82d3719473c0064446cc16d1b0303d98b06f55f503b7abeebceadb1",
"zh:ca6cf9254ae5436f2efbc01a0e3f7e4aa3c08b45182037b3eb3eb9539b2f7aec",
"zh:cba32d5de02674004e0a5955bd5222016d9991ca0553d4bd3bea517cd9def6ab",
"zh:d22c8cd527c6d0e84567f57be5911792e2fcd5969e3bba3747489f18bb16705b",
"zh:e4eeede9b3e72cdadd6cc252d4cbcf41baee6ecfd12bacd927e2dcbe733ab210",
"zh:facdaa787a69f86203cd3cc6922baea0b4a18bd9c36b0a8162e2e88ef6c90655",
]
}

View File

@@ -1,11 +0,0 @@
terraform {
backend "pg" {}
required_version = "~> 1.7"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.34.0"
}
}
}

View File

@@ -1,14 +0,0 @@
resource "cloudflare_pages_domain" "immich_app_release_domain" {
account_id = var.cloudflare_account_id
project_name = data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_name
domain = "immich.app"
}
resource "cloudflare_record" "immich_app_release_domain" {
name = "immich.app"
proxied = true
ttl = 1
type = "CNAME"
value = data.terraform_remote_state.cloudflare_immich_app_docs.outputs.immich_app_branch_pages_hostname
zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
}

View File

@@ -1,3 +0,0 @@
provider "cloudflare" {
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_docs
}

View File

@@ -1,27 +0,0 @@
data "terraform_remote_state" "api_keys_state" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_api_keys"
}
}
data "terraform_remote_state" "cloudflare_account" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_account"
}
}
data "terraform_remote_state" "cloudflare_immich_app_docs" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_immich_app_docs_${var.prefix_name}"
}
}

View File

@@ -1,20 +0,0 @@
terraform {
source = "."
extra_arguments custom_vars {
commands = get_terraform_commands_that_need_vars()
}
}
include {
path = find_in_parent_folders("state.hcl")
}
remote_state {
backend = "pg"
config = {
conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
schema_name = "prod_cloudflare_immich_app_docs_release"
}
}

View File

@@ -1,4 +0,0 @@
variable "cloudflare_account_id" {}
variable "tf_state_postgres_conn_str" {}
variable "prefix_name" {}

View File

@@ -1,38 +0,0 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.34.0"
constraints = "4.34.0"
hashes = [
"h1:+W0+Xe1AUh7yvHjDbgR9T7CY1UbBC3Y6U7Eo+ucLnJM=",
"h1:2+1lKObDDdFZRluvROF3RKtXD66CFT3PfnHOvR6CmfA=",
"h1:7vluN2wmw8D9nI11YwTgoGv3hGDXlkt8xqQ4L/JABeQ=",
"h1:B0Urm8ZKTJ8cXzSCtEpJ+o+LsD8MXaD6LU59qVbh50Q=",
"h1:FpGLCm5oF12FaRti3E4iQJlkVbdCC7toyGVuH8og7KY=",
"h1:FunTmrCMDy+rom7YskY0WiL5/Y164zFrrD9xnBxU5NY=",
"h1:GrxZhEb+5HzmHF/BvZBdGKBJy6Wyjme0+ABVDz/63to=",
"h1:J36dda2K42/oTfHuZ4jKkW5+nI6BTWFRUvo60P17NJg=",
"h1:Kq0Wyn+j6zoQeghMYixbnfnyP9ZSIEJbOCzMbaCiAQQ=",
"h1:TKxunXCiS/z105sN/kBNFwU6tIKD67JKJ3ZKjwzoCuI=",
"h1:TR0URKFQxsRO5/v7bKm5hkD/CTTjsG7aVGllL/Mf25c=",
"h1:V+3Qs0Reb6r+8p4XjE5ZFDWYrOIN0x5SwORz4wvHOJ4=",
"h1:mZB3Ui7V/lPQMQK53eBOjIHcrul74252dT06Kgn3J+s=",
"h1:wJwZrIXxoki8omXLJ7XA7B1KaSrtcLMJp090fRtFRAc=",
"zh:02aa46743c1585ada8faa7db23af68ea614053a506f88f05d1090ff5e0e68076",
"zh:1e1a545e83e6457a0e15357b23139bc288fb4fbd5e9a5ddfedc95a6a0216b08c",
"zh:29eef2621e0b1501f620e615bf73b1b90d5417d745e38af63634bc03250faf87",
"zh:3c20989d7e1e141882e6091384bf85fdc83f70f3d29e3e047c493a07de992095",
"zh:3d39619379ba29c7ffb15196f0ea72a04c84cfcdf4b39ac42ac4cf4c19f3eae2",
"zh:805f4a2774e9279c590b8214aabe6df9dcc22bb995df2530513f2f78c647ce75",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8af716f8655a57aa986861a8a7fa1d724594a284bd77c870eaea4db5f8b9732d",
"zh:a3d13c93b4e6ee6004782debaa9a17f990f2fe8ec8ba545c232818bb6064aba9",
"zh:bfa136acf82d3719473c0064446cc16d1b0303d98b06f55f503b7abeebceadb1",
"zh:ca6cf9254ae5436f2efbc01a0e3f7e4aa3c08b45182037b3eb3eb9539b2f7aec",
"zh:cba32d5de02674004e0a5955bd5222016d9991ca0553d4bd3bea517cd9def6ab",
"zh:d22c8cd527c6d0e84567f57be5911792e2fcd5969e3bba3747489f18bb16705b",
"zh:e4eeede9b3e72cdadd6cc252d4cbcf41baee6ecfd12bacd927e2dcbe733ab210",
"zh:facdaa787a69f86203cd3cc6922baea0b4a18bd9c36b0a8162e2e88ef6c90655",
]
}

View File

@@ -1,11 +0,0 @@
terraform {
backend "pg" {}
required_version = "~> 1.7"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.34.0"
}
}
}

View File

@@ -1,26 +0,0 @@
resource "cloudflare_pages_domain" "immich_app_branch_domain" {
account_id = var.cloudflare_account_id
project_name = local.is_release ? data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_name : data.terraform_remote_state.cloudflare_account.outputs.immich_app_preview_pages_project_name
domain = "${var.prefix_name}.${local.deploy_domain_prefix}.immich.app"
}
resource "cloudflare_record" "immich_app_branch_subdomain" {
name = "${var.prefix_name}.${local.deploy_domain_prefix}.immich.app"
proxied = true
ttl = 1
type = "CNAME"
value = "${replace(var.prefix_name, "/\\/|\\./", "-")}.${local.is_release ? data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_subdomain : data.terraform_remote_state.cloudflare_account.outputs.immich_app_preview_pages_project_subdomain}"
zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
}
output "immich_app_branch_subdomain" {
value = cloudflare_record.immich_app_branch_subdomain.hostname
}
output "immich_app_branch_pages_hostname" {
value = cloudflare_record.immich_app_branch_subdomain.value
}
output "pages_project_name" {
value = cloudflare_pages_domain.immich_app_branch_domain.project_name
}

View File

@@ -1,7 +0,0 @@
locals {
domain_name = "immich.app"
preview_prefix = contains(["branch", "pr"], var.prefix_event_type) ? "preview" : ""
archive_prefix = contains(["release"], var.prefix_event_type) ? "archive" : ""
deploy_domain_prefix = coalesce(local.preview_prefix, local.archive_prefix)
is_release = contains(["release"], var.prefix_event_type)
}

View File

@@ -1,3 +0,0 @@
provider "cloudflare" {
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_docs
}

View File

@@ -1,17 +0,0 @@
data "terraform_remote_state" "api_keys_state" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_api_keys"
}
}
data "terraform_remote_state" "cloudflare_account" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_account"
}
}

View File

@@ -1,24 +0,0 @@
terraform {
source = "."
extra_arguments custom_vars {
commands = get_terraform_commands_that_need_vars()
}
}
include {
path = find_in_parent_folders("state.hcl")
}
locals {
prefix_name = get_env("TF_VAR_prefix_name")
}
remote_state {
backend = "pg"
config = {
conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
schema_name = "prod_cloudflare_immich_app_docs_${local.prefix_name}"
}
}

View File

@@ -1,5 +0,0 @@
variable "cloudflare_account_id" {}
variable "tf_state_postgres_conn_str" {}
variable "prefix_name" {}
variable "prefix_event_type" {}

View File

@@ -1,20 +0,0 @@
locals {
cloudflare_account_id = get_env("CLOUDFLARE_ACCOUNT_ID")
cloudflare_api_token = get_env("CLOUDFLARE_API_TOKEN")
tf_state_postgres_conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
}
remote_state {
backend = "pg"
config = {
conn_str = local.tf_state_postgres_conn_str
}
}
inputs = {
cloudflare_account_id = local.cloudflare_account_id
cloudflare_api_token = local.cloudflare_api_token
tf_state_postgres_conn_str = local.tf_state_postgres_conn_str
}

View File

@@ -4,32 +4,32 @@
name: immich-dev
x-server-build: &server-common
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile
target: dev
restart: always
volumes:
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ulimits:
nofile:
soft: 1048576
hard: 1048576
services:
immich-server:
container_name: immich_server
command: ['/usr/src/app/bin/immich-dev']
image: immich-server-dev:latest
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
build:
context: ../
dockerfile: server/Dockerfile
target: dev
restart: always
volumes:
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ulimits:
nofile:
soft: 1048576
hard: 1048576
command: ['/usr/src/app/bin/immich-dev', 'immich']
<<: *server-common
ports:
- 3001:3001
- 9230:9230
@@ -37,6 +37,19 @@ services:
- redis
- database
immich-microservices:
container_name: immich_microservices
command: ['/usr/src/app/bin/immich-dev', 'microservices']
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
ports:
- 9231:9230
depends_on:
- database
- immich-server
immich-web:
container_name: immich_web
image: immich-web-dev:latest
@@ -84,9 +97,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93
healthcheck:
test: redis-cli ping || exit 1
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
database:
container_name: immich_postgres
@@ -102,11 +113,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
interval: 5m
start_interval: 30s
start_period: 5m
command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"]
# set IMMICH_METRICS=true in .env to enable metrics

View File

@@ -1,26 +1,39 @@
name: immich-prod
x-server-build: &server-common
image: immich-server:latest
build:
context: ../
dockerfile: server/Dockerfile
volumes:
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
restart: always
services:
immich-server:
container_name: immich_server
image: immich-server:latest
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
build:
context: ../
dockerfile: server/Dockerfile
volumes:
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
command: ['start.sh', 'immich']
<<: *server-common
ports:
- 2283:3001
depends_on:
- redis
- database
restart: always
immich-microservices:
container_name: immich_microservices
command: ['start.sh', 'microservices']
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
depends_on:
- redis
- database
- immich-server
immich-machine-learning:
container_name: immich_machine_learning
@@ -41,9 +54,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93
healthcheck:
test: redis-cli ping || exit 1
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
restart: always
database:
@@ -60,13 +71,7 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
interval: 5m
start_interval: 30s
start_period: 5m
command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"]
restart: always
# set IMMICH_METRICS=true in .env to enable metrics
immich-prometheus:
@@ -85,7 +90,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:11.0.0-ubuntu@sha256:02e99d1ee0b52dc9d3000c7b5314e7a07e0dfd69cc49bb3f8ce323491ed3406b
image: grafana/grafana:10.4.2-ubuntu@sha256:4f55071b556fb03f12b41423c98a185ed6695ed9ff2558e35805f0dd765fd958
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -12,9 +12,7 @@ services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
command: ['start.sh', 'immich']
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
@@ -27,6 +25,23 @@ services:
- database
restart: always
immich-microservices:
container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
# 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']
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:
- redis
- database
restart: always
immich-machine-learning:
container_name: immich_machine_learning
# For hardware acceleration, add one of -[armnn, cuda, openvino] to the image tag.
@@ -43,14 +58,12 @@ services:
redis:
container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93
healthcheck:
test: redis-cli ping || exit 1
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
restart: always
database:
container_name: immich_postgres
image: docker.io/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}
@@ -58,13 +71,8 @@ services:
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
healthcheck:
test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
interval: 5m
start_interval: 30s
start_period: 5m
command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"]
restart: always
command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"]
volumes:
model-cache:

View File

@@ -5,9 +5,6 @@ UPLOAD_LOCATION=./library
# The location where your database files are stored
DB_DATA_LOCATION=./postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
IMMICH_VERSION=release

View File

@@ -10,6 +10,9 @@ services:
cpu: {}
nvenc:
runtime: nvidia
environment:
- DISPLAY:$DISPLAY
deploy:
resources:
reservations:
@@ -20,6 +23,8 @@ services:
- gpu
- compute
- video
- display
- graphics
quicksync:
devices:

View File

@@ -1 +1 @@
20.14
20.13

View File

@@ -5,13 +5,13 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
### Installation
```
$ npm install
$ yarn
```
### Local Development
```
$ npm run start
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
@@ -19,7 +19,7 @@ This command starts a local development server and opens up a browser window. Mo
### Build
```
$ npm run build
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
@@ -29,13 +29,13 @@ This command generates static content into the `build` directory and can be serv
Using SSH:
```
$ USE_SSH=true npm run deploy
$ USE_SSH=true yarn deploy
```
Not using SSH:
```
$ GIT_USER=<Your GitHub username> npm run deploy
$ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

View File

@@ -67,7 +67,7 @@ Yes, with an [External Library](/docs/features/libraries.md).
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
Template changes will only apply to _new_ assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs-workers/#jobs) page.
Template changes will only apply to _new_ assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
### Why are only photos and not videos being uploaded to Immich?

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -1,55 +0,0 @@
# Jobs and Workers
## Workers
### Architecture
The `immich-server` container contains multiple workers:
- `api`: responds to API requests for data and files for the web and mobile app.
- `microservices`: handles most other work, such as thumbnail generation and video encoding, in the form of _jobs_. Simply put, a job is a request to process data in the background.
## Split workers
If you prefer to throttle or distribute the workers, you can do this using the [environment variables](/docs/install/environment-variables) to specify which container should pick up which tasks.
For example, for a simple setup with one container for the Web/API and one for all other microservices, you can do the following:
Copy the entire `immich-server` block as a new service and make the following changes to the **copy**:
```diff
- immich-server:
- container_name: immich_server
...
- ports:
- - 2283:3001
+ immich-microservices:
+ container_name: immich_microservices
```
Once you have two copies of the immich-server service, make the following chnages to each one. This will allow one container to only serve the web UI and API, and the other one to handle all other tasks.
```diff
services:
immich-server:
...
+ environment:
+ IMMICH_WORKERS_INCLUDE: 'api'
immich-microservices:
...
+ environment:
+ IMMICH_WORKERS_EXCLUDE: 'api'
```
## Jobs
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
:::info
Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library.
:::
<img src={require('./img/admin-jobs.png').default} width="80%" title="Admin jobs" />

View File

@@ -0,0 +1,13 @@
# Jobs
The `immich-server` responds to API requests for data and files for the web and mobile app. To do this quickly and reliably, it offloads most other work to `immich-microservices` in the form of _jobs_. Simply put, a job is a request to process data in the background. Jobs are picked up automatically by microservices containers.
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
:::info
Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library.
:::
<img src={require('./img/admin-jobs.png').default} width="80%" title="Admin jobs" />

View File

@@ -110,66 +110,8 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to
## Example Configuration
<details>
<summary>Authentik Example</summary>
### Authentik Example
Here's an example of OAuth configured for Authentik:
Configuration of Authorised redirect URIs (Authentik OAuth2/OpenID Provider)
<img src={require('./img/authentik-redirect-uris-example.webp').default} width='70%' title="Authentik authorised redirect URIs" />
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| Issuer URL | `https://example.immich.app/application/o/immich/.well-known/openid-configuration` |
| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... |
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Button Text | Sign in with Authentik (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disable |
| Mobile Redirect URI | |
</details>
<details>
<summary>Google Example</summary>
### Google Example
Here's an example of OAuth configured for Google:
Configuration of Authorised redirect URIs (Google Console)
<img src={require('./img/google-redirect-uris-example.webp').default} width='50%' title="Google authorised redirect URIs" />
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
| Issuer URL | [https://accounts.google.com](https://accounts.google.com) |
| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com |
| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Button Text | Sign in with Google (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled |
| Mobile Redirect URI Override | Enabled (required) |
| Mobile Redirect URI | [https://demo.immich.app/api/oauth/mobile-redirect](https://demo.immich.app/api/oauth/mobile-redirect) |
</details>
![OAuth Settings](./img/oauth-settings.png)
[oidc]: https://openid.net/connect/

View File

@@ -18,7 +18,7 @@ In any other situation, there are 3 different options that can appear:
- MATCHES - These files are matched by their checksums.
- OFFLINE PATHS - These files are the result of manually deleting files from immich or a failed file move in the past (losing track of a file).
- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file).
- UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug.

View File

@@ -77,6 +77,7 @@ immich-admin list-users
deletedAt: null,
updatedAt: 2023-09-21T15:42:28.129Z,
oauthId: '',
memoriesEnabled: true
}
]
```

View File

@@ -80,7 +80,7 @@ The Immich Microservices image uses the same `Dockerfile` as the Immich Server,
- Background jobs (file deletion, user deletion)
:::info
This list closely matches what is available on the [Administration > Jobs](/docs/administration/jobs-workers/#jobs) page, which provides some remote queue management capabilities.
This list closely matches what is available on the [Administration > Jobs](/docs/administration/jobs.md) page, which provides some remote queue management capabilities.
:::
### Machine Learning

View File

@@ -22,8 +22,7 @@ You do not need to redo any transcoding jobs after enabling hardware acceleratio
- WSL2 does not support Quick Sync.
- Raspberry Pi is currently not supported.
- Two-pass mode is only supported for NVENC. Other APIs will ignore this setting.
- By default, only encoding is currently hardware accelerated. This means the CPU is still used for software decoding and tone-mapping.
- NVENC and RKMPP can be fully accelerated by enabling hardware decoding in the video transcoding settings.
- Only encoding is currently hardware accelerated, so the CPU is still used for software decoding and tone-mapping.
- Hardware dependent
- Codec support varies, but H.264 and HEVC are usually supported.
- Notably, NVIDIA and AMD GPUs do not support VP9 encoding.
@@ -66,7 +65,6 @@ For RKMPP to work:
3. Redeploy the `immich-microservices` container with these updated settings.
4. In the Admin page under `Video transcoding settings`, change the hardware acceleration setting to the appropriate option and save.
5. (Optional) If using a compatible backend, you may enable hardware decoding for optimal performance.
#### Single Compose File

View File

@@ -4,6 +4,10 @@
Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries.
## The Upload Library
Immich comes preconfigured with an upload library for each user. All assets uploaded to Immich are added to this library. This library can be renamed, but not deleted. The upload library is the only library that can be synced with a mobile device. No items in an upload library is allowed to have the same sha1 hash as another item in the same library in order to prevent duplicates.
## External Libraries
External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc.
@@ -44,16 +48,16 @@ If the import paths are edited in a way that an external file is no longer in an
### Troubleshooting
Sometimes, an external library will not scan correctly. This can happen if Immich can't access the files. Here are some things to check:
Sometimes, an external library will not scan correctly. This can happen if immich_server or immich_microservices can't access the files. Here are some things to check:
- In the docker-compose file, are the volumes mounted correctly?
- Are the volumes also mounted to any worker containers?
- Are the volumes identical between the `server` and `microservices` container?
- Are the import paths set correctly, and do they match the path set in docker-compose file?
- Make sure you don't use symlinks in your import libraries, and that you aren't linking across docker mounts.
- Are the permissions set correctly?
- Make sure you are using forward slashes (`/`) and not backward slashes.
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the same in any microservices containers.
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_microservices /bin/bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the `immich_server` container. If you cannot access this directory in both the `microservices` and `server` containers, Immich won't be able to import files.
### Exclusion Patterns
@@ -98,7 +102,7 @@ First, we need to plan how we want to organize the libraries. The christmas trip
### Mount Docker Volumes
The `immich-server` container will need access to the gallery. Modify your docker compose file as follows
`immich-server` and `immich-microservices` containers will need access to the gallery. Modify your docker compose file as follows
```diff title="docker-compose.yml"
immich-server:
@@ -107,6 +111,15 @@ The `immich-server` container will need access to the gallery. Modify your docke
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos:ro
+ - "C:/Users/user_name/Desktop/my media:/mnt/media/my-media:ro" # import path in Windows system.
immich-microservices:
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos:ro
+ - "C:/Users/user_name/Desktop/my media:/mnt/media/my-media:ro" # import path in Windows system.
```

View File

@@ -95,7 +95,7 @@ immich-machine-learning:
Once this is done, you can redeploy the `immich-machine-learning` container.
:::info
You can confirm the device is being recognized and used by checking its utilization (via `nvtop` for CUDA, `intel_gpu_top` for OpenVINO, etc.). You can also enable debug logging by setting `IMMICH_LOG_LEVEL=debug` in the `.env` file and restarting the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, you should see a log for `Available ORT providers` containing the relevant provider. In the case of ARM NN, the absence of a `Could not load ANN shared libraries` log entry means it loaded successfully.
You can confirm the device is being recognized and used by checking its utilization (via `nvtop` for CUDA, `intel_gpu_top` for OpenVINO, etc.). You can also enable debug logging by setting `LOG_LEVEL=debug` in the `.env` file and restarting the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, you should see a log for `Available ORT providers` containing the relevant provider. In the case of ARM NN, the absence of a `Could not load ANN shared libraries` log entry means it loaded successfully.
:::
[hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml

View File

@@ -9,7 +9,7 @@ It is important to remember to update the backup settings after following the gu
In our `.env` file, we will define variables that will help us in the future when we want to move to a more advanced server in the future
```diff title=".env"
# You can find documentation for all the supported env variables [here](/docs/install/environment-variables)
# You can find documentation for all the supported env variables at https://immich.app/docs/install/environment-variables
# Custom location where your uploaded, thumbnails, and transcoded video files are stored
- UPLOAD_LOCATION=./library

View File

@@ -2,52 +2,48 @@
A short guide on connecting [pgAdmin](https://www.pgadmin.org/) to Immich.
:::note
In order to connect to the database the immich_postgres container **must be running**.
The passwords and usernames used below match the ones specified in the example `.env` file. If changed, please use actual values instead.
**Optional:** To connect to the database **outside** of your Docker's network:
- Expose port 5432 in your `docker-compose.yml` file.
- Edit the PostgreSQL [`pg_hba.conf`](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html) file.
- Make sure your firewall does not block access to port 5432.
Note that exposing the database port increases the risk of getting attacked by hackers.
Make sure to remove the binding port after finishing the database's tasks.
:::
## 1. Install pgAdmin
Add a file `docker-compose-pgadmin.yml` next to your `docker-compose.yml` with the following content:
```
name: immich
services:
pgadmin:
image: dpage/pgadmin4
container_name: pgadmin4_container
restart: always
ports:
- "8888:80"
environment:
PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com
PGADMIN_DEFAULT_PASSWORD: strong-password
volumes:
- pgadmin-data:/var/lib/pgadmin
volumes:
pgadmin-data:
```
Change the values of `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD` in this file.
Run `docker compose -f docker-compose.yml -f docker-compose-pgadmin.yml up` to start immich along with `pgAdmin`.
Download and install [pgAdmin](https://www.pgadmin.org/download/) following the official documentation.
## 2. Add a Server
Open [localhost:8888](http://localhost:8888) and login with the default credentials from above.
Open pgAdmin and click "Add New Server".
Right click on `Servers` and click on `Register >> Server..` then enter the values below in the `Connection` tab.
<img src={require('./img/add-new-server-option.png').default} width="50%" title="new server option" />
<img src={require('./img/pgadmin-add-new-server.png').default} width="50%" title="new server option" />
## 3. Enter Connection Details
:::note
The parameters used here match those specified in the example `.env` file. If you have changed your `.env` file, you'll need to adjust accordingly.
:::
| Name | Value |
| -------------------- | ----------- |
| Host name/address | `localhost` |
| Port | `5432` |
| Maintenance database | `immich` |
| Username | `postgres` |
| Password | `postgres` |
| Name | Value |
| -------------------- | ----------------- |
| Host name/address | `immich_postgres` |
| Port | `5432` |
| Maintenance database | `immich` |
| Username | `postgres` |
| Password | `postgres` |
<img src={require('./img/Connection-Pgadmin.png').default} width="75%" title="Connection" />
## 4. Save Connection
Click on "Save" to connect to the Immich database.
:::tip
View [Database Queries](/docs/guides/database-queries/) for common database queries.
:::

View File

@@ -96,7 +96,7 @@ SELECT * FROM "users";
## System Config
```sql title="Custom settings"
SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config';
SELECT "key", "value" FROM "system_config";
```
(Only used when not using the [config file](/docs/install/config-file))

View File

@@ -11,8 +11,9 @@ docker ps -a # see a list of running and stopped containers
```bash
docker exec -it <id or name> <command> # attach to a container with a command
docker exec -it immich_server bash
docker exec -it immich_machine_learning bash
docker exec -it immich_server sh
docker exec -it immich_microservices sh
docker exec -it immich_machine_learning sh
```
## Logs
@@ -21,6 +22,7 @@ docker exec -it immich_machine_learning bash
docker logs <id or name> # see the logs for a specific container (by id or name)
docker logs immich_server
docker logs immich_microservices
docker logs immich_machine_learning
```

View File

@@ -6,14 +6,20 @@ in a directory on the same machine.
# Mount the directory into the containers.
Edit `docker-compose.yml` to add two new mount points in the section `immich-server:` under `volumes:`
Edit `docker-compose.yml` to add two new mount points in the sections `immich-server:` and `immich-microservices:` under `volumes:`
```diff
immich-server:
volumes:
+ - ${EXTERNAL_PATH}:/usr/src/app/external
immich-microservices:
volumes:
+ - ${EXTERNAL_PATH}:/usr/src/app/external
```
Be sure to add exactly the same path to both services.
Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer:
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -32,7 +32,7 @@ def upload(file):
}
response = requests.post(
f'{BASE_URL}/assets', headers=headers, data=data, files=files)
f'{BASE_URL}/asset/upload', headers=headers, data=data, files=files)
print(response.json())
# {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False}

View File

@@ -7,7 +7,7 @@ To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-imm
- Start the container by running `docker compose up -d`.
:::info
Starting with version v1.93.0 face detection work and face recognize were split. From now on face detection is done in the immich_machine_learning container, but facial recognition is done in the `microservices` worker.
Starting with version v1.93.0 face detection work and face recognize were split. From now on face detection is done in the immich_machine_learning service, but facial recognition is done in the immich_microservices service.
:::
:::note
@@ -15,7 +15,7 @@ The [hwaccel.ml.yml](https://github.com/immich-app/immich/releases/latest/downlo
:::
```yaml
name: immich_remote_ml
version: '3.8'
services:
immich-machine-learning:

View File

@@ -77,10 +77,6 @@ The default configuration looks like this:
"enabled": true,
"modelName": "ViT-B-32__openai"
},
"duplicateDetection": {
"enabled": false,
"maxDistance": 0.03
},
"facialRecognition": {
"enabled": true,
"modelName": "buffalo_l",
@@ -157,6 +153,9 @@ The default configuration looks like this:
"server": {
"externalDomain": "",
"loginPageMessage": ""
},
"user": {
"deleteDelay": 7
}
}
```

View File

@@ -17,11 +17,11 @@ If this should not work, try running `docker compose up -d --force-recreate`.
## Docker Compose
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-------: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `release` | server, machine learning |
| `UPLOAD_LOCATION` | Host Path for uploads | | server |
| `DB_DATA_LOCATION` | Host Path for Postgres database | | database |
| Variable | Description | Default | Services |
| :----------------- | :------------------------------ | :-------: | :-------------------------------------- |
| `IMMICH_VERSION` | Image tags | `release` | server, microservices, machine learning |
| `UPLOAD_LOCATION` | Host Path for uploads | | server, microservices |
| `DB_DATA_LOCATION` | Host Path for Postgres database | | database |
:::tip
These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly.
@@ -38,16 +38,15 @@ Regardless of filesystem, it is not recommended to use a network share for your
## General
| Variable | Description | Default | Containers | Workers |
| :------------------------------ | :---------------------------------------------- | :----------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload`<sup>\*1</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `IMMICH_WEB_ROOT` | Path of root index.html | `/usr/src/app/www` | server | api |
| `IMMICH_REVERSE_GEOCODING_ROOT` | Path of reverse geocoding dump directory | `/usr/src/resources` | server | microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| Variable | Description | Default | Services |
| :------------------------------ | :------------------------------------------- | :----------------------: | :-------------------------------------- |
| `TZ` | Timezone | | microservices |
| `NODE_ENV` | Environment (production, development) | `production` | server, microservices, machine learning |
| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices, machine learning |
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload`<sup>\*1</sup> | server, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server, microservices |
| `IMMICH_WEB_ROOT` | Path of root index.html | `/usr/src/app/www` | server |
| `IMMICH_REVERSE_GEOCODING_ROOT` | Path of reverse geocoding dump directory | `/usr/src/resources` | microservices |
\*1: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`.
It only need to be set if the Immich deployment method is changing.
@@ -55,39 +54,31 @@ It only need to be set if the Immich deployment method is changing.
:::tip
`TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
:::
## Workers
| Variable | Description | Default | Containers |
| :----------------------- | :--------------------------------------------------------------------------------------------------- | :-----: | :--------- |
| `IMMICH_WORKERS_INCLUDE` | Only run these workers. | | server |
| `IMMICH_WORKERS_EXCLUDE` | Do not run these workers. Matches against default workers, or `IMMICH_WORKERS_INCLUDE` if specified. | | server |
:::info
Information on the current workers can be found [here](/docs/administration/jobs-workers).
`TZ` is only used by `exiftool`, which is present in the microservices container, as a fallback in case the timezone cannot be determined from the image metadata.
:::
## Ports
| Variable | Description | Default |
| :------------ | :------------- | :----------------------------------------: |
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
| `IMMICH_PORT` | Listening port | `3001` (server), `3003` (machine learning) |
| Variable | Description | Default | Services |
| :---------------------- | :-------------------- | :-------: | :-------------------- |
| `HOST` | Host | `0.0.0.0` | server, microservices |
| `SERVER_PORT` | Server Port | `3001` | server |
| `MICROSERVICES_PORT` | Microservices Port | `3002` | microservices |
| `MACHINE_LEARNING_HOST` | Machine Learning Host | `0.0.0.0` | machine learning |
| `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning |
## Database
| Variable | Description | Default | Containers |
| :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :----------------------------- |
| `DB_URL` | Database URL | | server |
| `DB_HOSTNAME` | Database Host | `database` | server |
| `DB_PORT` | Database Port | `5432` | server |
| `DB_USERNAME` | Database User | `postgres` | server, database<sup>\*1</sup> |
| `DB_PASSWORD` | Database Password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database Name | `immich` | server, database<sup>\*1</sup> |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| Variable | Description | Default | Services |
| :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :-------------------------------------------- |
| `DB_URL` | Database URL | | server, microservices |
| `DB_HOSTNAME` | Database Host | `database` | server, microservices |
| `DB_PORT` | Database Port | `5432` | server, microservices |
| `DB_USERNAME` | Database User | `postgres` | server, microservices, database<sup>\*1</sup> |
| `DB_PASSWORD` | Database Password | `postgres` | server, microservices, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database Name | `immich` | server, microservices, database<sup>\*1</sup> |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server, microservices |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server, microservices |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
@@ -95,34 +86,30 @@ Information on the current workers can be found [here](/docs/administration/jobs
:::info
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
:::
## Redis
| Variable | Description | Default | Containers |
| :--------------- | :------------- | :-----: | :--------- |
| `REDIS_URL` | Redis URL | | server |
| `REDIS_SOCKET` | Redis Socket | | server |
| `REDIS_HOSTNAME` | Redis Host | `redis` | server |
| `REDIS_PORT` | Redis Port | `6379` | server |
| `REDIS_USERNAME` | Redis Username | | server |
| `REDIS_PASSWORD` | Redis Password | | server |
| `REDIS_DBINDEX` | Redis DB Index | `0` | server |
| Variable | Description | Default | Services |
| :--------------- | :------------- | :-----: | :-------------------- |
| `REDIS_URL` | Redis URL | | server, microservices |
| `REDIS_HOSTNAME` | Redis Host | `redis` | server, microservices |
| `REDIS_PORT` | Redis Port | `6379` | server, microservices |
| `REDIS_DBINDEX` | Redis DB Index | `0` | server, microservices |
| `REDIS_USERNAME` | Redis Username | | server, microservices |
| `REDIS_PASSWORD` | Redis Password | | server, microservices |
| `REDIS_SOCKET` | Redis Socket | | server, microservices |
:::info
All `REDIS_` variables must be provided to all Immich workers, including `api` and `microservices`.
`REDIS_URL` must start with `ioredis://` and then include a `base64` encoded JSON string for the configuration.
More info can be found in the upstream [ioredis][redis-api] documentation.
When `REDIS_URL` or `REDIS_SOCKET` are defined, the `REDIS_HOSTNAME`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`, and `REDIS_DBINDEX` variables are ignored.
- When `REDIS_URL` is defined, the other redis (`REDIS_*`) variables are ignored.
- When `REDIS_SOCKET` is defined, the other redis (`REDIS_*`) variables are ignored.
:::
Redis (Sentinel) URL example JSON before encoding:
@@ -154,7 +141,7 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
| Variable | Description | Default | Containers |
| Variable | Description | Default | Services |
| :----------------------------------------------- | :------------------------------------------------------------------- | :-----------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
@@ -179,13 +166,13 @@ Other machine learning parameters can be tuned from the admin UI.
## Prometheus
| Variable | Description | Default | Containers | Workers |
| :----------------------------- | :-------------------------------------------------------------------------------------------- | :-----: | :--------- | :----------------- |
| `IMMICH_METRICS`<sup>\*1</sup> | Toggle all metrics (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_API_METRICS` | Toggle metrics for endpoints and response times (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_HOST_METRICS` | Toggle metrics for CPU and memory utilization for host and process (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_IO_METRICS` | Toggle metrics for database queries, image processing, etc. (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_JOB_METRICS` | Toggle metrics for jobs and queues (one of [`true`, `false`]) | | server | api, microservices |
| Variable | Description | Default | Services |
| :----------------------------- | :-------------------------------------------------------------------------------------------- | :-----: | :-------------------- |
| `IMMICH_METRICS`<sup>\*1</sup> | Toggle all metrics (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_API_METRICS` | Toggle metrics for endpoints and response times (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_HOST_METRICS` | Toggle metrics for CPU and memory utilization for host and process (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_IO_METRICS` | Toggle metrics for database queries, image processing, etc. (one of [`true`, `false`]) | | server, microservices |
| `IMMICH_JOB_METRICS` | Toggle metrics for jobs and queues (one of [`true`, `false`]) | | server, microservices |
\*1: Overridden for a metric group when its corresponding environmental variable is set.

View File

@@ -100,9 +100,9 @@ const config = {
label: 'Docs',
},
{
to: '/roadmap',
to: '/milestones',
position: 'right',
label: 'Roadmap',
label: 'Milestones',
},
{
to: '/docs/api',

View File

@@ -56,6 +56,6 @@
"node": ">=20"
},
"volta": {
"node": "20.14.0"
"node": "20.13.1"
}
}

View File

@@ -10,17 +10,17 @@ interface CommunityGuidesProps {
const guides: CommunityGuidesProps[] = [
{
title: 'Cloudflare Tunnels with SSO/OAuth',
description: `Setting up Cloudflare Tunnels and a SaaS App for Immich.`,
description: `Setting up Cloudflare Tunnels and a SaaS App for immich.`,
url: 'https://github.com/immich-app/immich/discussions/8299',
},
{
title: 'Database backup in TrueNAS',
description: `Create a database backup with pgAdmin in TrueNAS.`,
title: 'Database backup in Truenas',
description: `Create a database backup with pgAdmin in Truenas.`,
url: 'https://github.com/immich-app/immich/discussions/8809',
},
{
title: 'Unraid backup scripts',
description: `Back up your assets in Unraid with a pre-prepared script.`,
description: `Back up your assets in Unarid with a pre-prepared script.`,
url: 'https://github.com/immich-app/immich/discussions/8416',
},
{

View File

@@ -10,7 +10,7 @@ interface CommunityProjectProps {
const projects: CommunityProjectProps[] = [
{
title: 'immich-go',
description: `An alternative to the immich-CLI that doesn't depend on nodejs. It specializes in importing Google Photos Takeout archives.`,
description: `An alternative to the immich-CLI command that doesn't depend on nodejs installation. It tries its best for importing google photos takeout archives.`,
url: 'https://github.com/simulot/immich-go',
},
{

View File

@@ -1,23 +1,28 @@
import useIsBrowser from '@docusaurus/useIsBrowser';
import { mdiCheckboxBlankCircle, mdiCheckboxMarkedCircle } from '@mdi/js';
import Icon from '@mdi/react';
import React from 'react';
import Icon from '@mdi/react';
import { mdiCheckboxMarkedCircleOutline } from '@mdi/js';
import useIsBrowser from '@docusaurus/useIsBrowser';
export type Item = {
export interface Item {
icon: string;
iconColor: string;
title: string;
description?: string;
link?: { url: string; text: string };
done?: false;
getDateLabel: (language: string) => string;
};
release?: string;
tag?: string;
date: Date;
dateType: DateType;
}
export enum DateType {
RELEASE = 'Release Date',
DATE = 'Date',
}
interface Props {
items: Item[];
}
export function Timeline({ items }: Props): JSX.Element {
export default function Timeline({ items }: Props): JSX.Element {
const isBrowser = useIsBrowser();
return (
@@ -25,15 +30,21 @@ export function Timeline({ items }: Props): JSX.Element {
{items.map((item, index) => {
const isFirst = index === 0;
const isLast = index === items.length - 1;
const done = item.done ?? true;
const dateLabel = item.getDateLabel(isBrowser ? navigator.language : 'en-US');
const timelineIcon = done ? mdiCheckboxMarkedCircle : mdiCheckboxBlankCircle;
const cardIcon = item.icon;
const classNames: string[] = [];
if (isFirst) {
classNames.push('');
}
if (isLast) {
classNames.push('rounded rounded-b-full');
}
return (
<li key={index} className={`flex min-h-24 w-[700px] max-w-[90vw] ${done ? '' : 'italic'}`}>
<li key={index} className="flex min-h-24 w-[700px] max-w-[90vw]">
<div className="md:flex justify-start w-36 mr-8 items-center dark:text-immich-dark-primary text-immich-primary hidden">
{dateLabel}
{isBrowser ? item.date.toLocaleDateString(navigator.language) : ''}
</div>
<div className={`${isFirst && 'relative top-[50%]'} ${isLast && 'relative bottom-[50%]'}`}>
<div
@@ -43,32 +54,33 @@ export function Timeline({ items }: Props): JSX.Element {
></div>
</div>
<div className="z-10 flex items-center bg-immich-primary dark:bg-immich-dark-primary border-2 border-solid rounded-full dark:text-black text-white relative top-[50%] left-[-3px] translate-y-[-50%] translate-x-[-50%] w-8 h-8 shadow-lg ">
{<Icon path={timelineIcon} size={1.25} />}
<Icon path={mdiCheckboxMarkedCircleOutline} size={1.25} />
</div>
<section className=" dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl flex flex-row w-full gap-2 p-4 md:ml-4 my-2 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 transition-all">
<div className="flex flex-col flex-grow justify-between gap-2">
<div className="flex gap-2 items-center">
{cardIcon === 'immich' ? (
<img src="img/immich-logo.svg" height="30" />
) : (
<Icon path={cardIcon} size={1} color={item.iconColor} />
)}
<p className="m-0 mt-1 text-lg items-start flex gap-2 place-items-center content-center">
<span>{item.title}</span>
</p>
</div>
<p className="m-0 text-sm text-gray-600 dark:text-gray-300">{item.description}</p>
</div>
<div className="flex flex-col justify-between place-items-end">
<section className=" dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl flex flex-col w-full gap-2 p-4 md:ml-4 my-2 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 transition-all">
<div className="m-0 text-lg flex w-full items-center justify-between gap-2">
<p className="m-0 items-start flex gap-2">
<Icon path={item.icon} size={1} />
<span>{item.title}</span>
</p>
<span className="dark:text-immich-dark-primary text-immich-primary">
{item.link && (
<a href={item.link.url} target="_blank" rel="noopener">
[{item.link.text}]
{item.tag ? (
<a
href={`https://github.com/immich-app/immich/releases/tag/${item.tag}`}
target="_blank"
rel="noopener"
>
[{item.release ?? item.tag}]{' '}
</a>
) : (
item.release && <span>[{item.release}]</span>
)}
</span>
<div className="md:hidden text-sm text-right">{dateLabel}</div>
</div>
<div className="md:hidden text-xs">
{`${item.dateType} - ${isBrowser ? item.date.toLocaleDateString(navigator.language) : ''}`}
</div>
<p className="m-0 text-sm text-gray-600 dark:text-gray-300">{item.description}</p>
</section>
</li>
);

View File

@@ -15,10 +15,6 @@ button {
font-family: 'Overpass', sans-serif;
}
img {
border-radius: 15px;
}
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #4250af;
@@ -46,7 +42,7 @@ img {
}
div[class^='announcementBar_'] {
min-height: 2rem;
height: 2rem;
}
.navbar__brand .navbar__title {

File diff suppressed because it is too large Load Diff

View File

@@ -28,4 +28,3 @@
/docs/features/search /docs/features/smart-search 301
/docs/guides/api-album-sync /docs/community-projects 301
/docs/guides/remove-offline-files /docs/community-projects 301
/milestones /roadmap 301

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1 +1 @@
20.14
20.13

View File

@@ -2,32 +2,40 @@ version: '3.8'
name: immich-e2e
x-server-build: &server-common
image: immich-server:latest
build:
context: ../
dockerfile: server/Dockerfile
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_METRICS=true
volumes:
- upload:/usr/src/app/upload
- ./test-assets:/test-assets
depends_on:
- redis
- database
services:
immich-server:
container_name: immich-e2e-server
command: ['./start.sh']
image: immich-server:latest
build:
context: ../
dockerfile: server/Dockerfile
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_METRICS=true
volumes:
- upload:/usr/src/app/upload
- ./test-assets:/test-assets
depends_on:
- redis
- database
command: ['./start.sh', 'immich']
<<: *server-common
ports:
- 2283:3001
immich-microservices:
container_name: immich-e2e-microservices
command: ['./start.sh', 'microservices']
<<: *server-common
redis:
image: redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0

324
e2e/package-lock.json generated
View File

@@ -11,7 +11,7 @@
"devDependencies": {
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@playwright/test": "^1.41.2",
"@types/luxon": "^3.4.2",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
@@ -23,7 +23,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^53.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"exiftool-vendored": "^26.0.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
@@ -65,7 +65,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^53.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
@@ -88,7 +88,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.12.13",
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
},
@@ -208,9 +208,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz",
"integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==",
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@@ -971,12 +971,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz",
"integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==",
"dev": true,
"dependencies": {
"playwright": "1.44.1"
"playwright": "1.44.0"
},
"bin": {
"playwright": "cli.js"
@@ -1217,6 +1217,12 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
@@ -1230,11 +1236,10 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.12.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.13.tgz",
"integrity": "sha512-gBGeanV41c1L171rR7wjbMiEpEI/l5XFQdLLfhr/REwpgDy/4U8y89+i8kRiLzDyZdOkXh+cRaTetUnCYutoXA==",
"version": "20.12.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz",
"integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -1322,6 +1327,12 @@
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/superagent": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.3.tgz",
@@ -1344,20 +1355,21 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz",
"integrity": "sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz",
"integrity": "sha512-gFTT+ezJmkwutUPmB0skOj3GZJtlEGnlssems4AjkVweUPGj7jRwwqg0Hhg7++kPGJqKtTYx+R05Ftww372aIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/type-utils": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/type-utils": "7.8.0",
"@typescript-eslint/utils": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
"semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
@@ -1378,16 +1390,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.11.0.tgz",
"integrity": "sha512-yimw99teuaXVWsBcPO1Ais02kwJ1jmNA1KxE7ng0aT7ndr1pT1wqj0OJnsYVGKKlc4QJai86l/025L6z8CljOg==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.8.0.tgz",
"integrity": "sha512-KgKQly1pv0l4ltcftP59uQZCi4HUYswCLbTqVZEJu7uLX8CTLyswqMLqLN+2QFz4jCptqWVV4SB7vdxcH2+0kQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1407,14 +1418,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz",
"integrity": "sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.8.0.tgz",
"integrity": "sha512-viEmZ1LmwsGcnr85gIq+FCYI7nO90DVbE37/ll51hjv9aG+YZMb4WDE2fyWpUR4O/UrhGRpYXK/XajcGTk2B8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0"
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1425,14 +1435,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz",
"integrity": "sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.8.0.tgz",
"integrity": "sha512-H70R3AefQDQpz9mGv13Uhi121FNMh+WEaRqcXTX09YEDky21km4dV1ZXJIp8QjXc4ZaVkXVdohvWDzbnbHDS+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"@typescript-eslint/utils": "7.8.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -1453,11 +1462,10 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz",
"integrity": "sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz",
"integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
@@ -1467,14 +1475,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz",
"integrity": "sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz",
"integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1500,7 +1507,6 @@
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -1510,7 +1516,6 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -1522,16 +1527,18 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz",
"integrity": "sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.8.0.tgz",
"integrity": "sha512-L0yFqOCflVqXxiZyXrDr80lnahQfSOfc9ELAAZ75sqicqp2i36kEZZGuUymHNFoYOqxRT05up760b4iGsl02nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0"
"@types/json-schema": "^7.0.15",
"@types/semver": "^7.5.8",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"semver": "^7.6.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1545,13 +1552,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz",
"integrity": "sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.8.0.tgz",
"integrity": "sha512-q4/gibTNBQNA0lGyYQCmWRS5D15n8rXh4QjK3KV+MBPlTYHpfBUT3D3PaPR/HeNiI9W6R7FvlkcGhNyAoP+caA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/types": "7.8.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -1779,7 +1785,6 @@
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -1831,13 +1836,12 @@
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
"fill-range": "^7.0.1"
},
"engines": {
"node": ">=8"
@@ -2117,12 +2121,12 @@
"dev": true
},
"node_modules/core-js-compat": {
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",
"integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==",
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz",
"integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==",
"dev": true,
"dependencies": {
"browserslist": "^4.23.0"
"browserslist": "^4.22.3"
},
"funding": {
"type": "opencollective",
@@ -2243,7 +2247,6 @@
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
@@ -2484,17 +2487,17 @@
}
},
"node_modules/eslint-plugin-unicorn": {
"version": "53.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz",
"integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==",
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.24.5",
"@babel/helper-validator-identifier": "^7.22.20",
"@eslint-community/eslint-utils": "^4.4.0",
"@eslint/eslintrc": "^3.0.2",
"@eslint/eslintrc": "^2.1.4",
"ci-info": "^4.0.0",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.37.0",
"core-js-compat": "^3.34.0",
"esquery": "^1.5.0",
"indent-string": "^4.0.0",
"is-builtin-module": "^3.2.1",
@@ -2503,11 +2506,11 @@
"read-pkg-up": "^7.0.1",
"regexp-tree": "^0.1.27",
"regjsparser": "^0.10.0",
"semver": "^7.6.1",
"semver": "^7.5.4",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=18.18"
"node": ">=16"
},
"funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
@@ -2516,70 +2519,6 @@
"eslint": ">=8.56.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/@eslint/eslintrc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz",
"integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==",
"dev": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/eslint-visitor-keys": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
"integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/espree": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz",
"integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==",
"dev": true,
"dependencies": {
"acorn": "^8.11.3",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@@ -2700,11 +2639,10 @@
}
},
"node_modules/exiftool-vendored": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.1.0.tgz",
"integrity": "sha512-Bhy2Ia86Agt3+PbJJhWeVMqJNXl74XJ0Oygef5F5uCL13fTxlmF8dECHiChyx8bBc3sxIw+2Q3ehWunJh3bs6w==",
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.0.0.tgz",
"integrity": "sha512-2TRxx21ovD95VvdSzHb/sTYYcwhiizQIhhVAbrgua9KoL902QRieREGvaUtfBZNjsptdjonuyku2kUBJCPqsgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@photostructure/tz-lookup": "^10.0.0",
"@types/luxon": "^3.4.2",
@@ -2713,27 +2651,25 @@
"luxon": "^3.4.4"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.85.0",
"exiftool-vendored.pl": "12.85.0"
"exiftool-vendored.exe": "12.84.0",
"exiftool-vendored.pl": "12.84.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.85.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.85.0.tgz",
"integrity": "sha512-rWsKVp9oXsS79S3bfCNXKeEo4av0xcd7slk/TfPpCa5pojg8ZVXSVfPZMAAlhOuK63YXrKN/e3jRNReeGP+2Gw==",
"version": "12.84.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.84.0.tgz",
"integrity": "sha512-9ocqJb0Pr9k0TownEMd75payF/XOQLF/swr/l0Ep49D+m609uIZsW09CtowhXmk1KrIFobS3+SkdXK04CSyUwQ==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/exiftool-vendored.pl": {
"version": "12.85.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.85.0.tgz",
"integrity": "sha512-AelZQCCfl0a0g7PYx90TqbNGlSu2zDbRfCTjGw6bBBYnJF0NUfUWVhTpa8XGe2lHx1KYikH8AkJaey3esAxMAg==",
"version": "12.84.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.84.0.tgz",
"integrity": "sha512-TxvMRaVYtd24Vupn48zy24LOYItIIWEu4dgt/VlqLwxQItTpvJTV9YH04iZRvaNh9ZdPRgVKWMuuUDBBHv+lAg==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"!win32"
@@ -2756,7 +2692,6 @@
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -2773,7 +2708,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -2821,11 +2755,10 @@
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -3068,7 +3001,6 @@
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
@@ -3344,7 +3276,6 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
@@ -3560,6 +3491,18 @@
"get-func-name": "^2.0.1"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/luxon": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
@@ -3618,7 +3561,6 @@
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
@@ -3633,13 +3575,12 @@
}
},
"node_modules/micromatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"braces": "^3.0.2",
"picomatch": "^2.3.1"
},
"engines": {
@@ -4106,7 +4047,6 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -4235,7 +4175,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -4255,12 +4194,12 @@
}
},
"node_modules/playwright": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz",
"integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.44.1"
"playwright-core": "1.44.0"
},
"bin": {
"playwright": "cli.js"
@@ -4273,9 +4212,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz",
"integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
@@ -4771,10 +4710,13 @@
]
},
"node_modules/semver": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
"integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -4867,7 +4809,6 @@
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -5194,7 +5135,6 @@
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},

View File

@@ -21,7 +21,7 @@
"devDependencies": {
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@playwright/test": "^1.41.2",
"@types/luxon": "^3.4.2",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
@@ -33,7 +33,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^53.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"exiftool-vendored": "^26.0.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
@@ -47,6 +47,6 @@
"vitest": "^1.3.0"
},
"volta": {
"node": "20.14.0"
"node": "20.13.1"
}
}

View File

@@ -2,7 +2,7 @@ import {
ActivityCreateDto,
AlbumResponseDto,
AlbumUserRole,
AssetMediaResponseDto,
AssetFileUploadResponseDto,
LoginResponseDto,
ReactionType,
createActivity as create,
@@ -14,10 +14,10 @@ import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/activities', () => {
describe('/activity', () => {
let admin: LoginResponseDto;
let nonOwner: LoginResponseDto;
let asset: AssetMediaResponseDto;
let asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
@@ -45,24 +45,22 @@ describe('/activities', () => {
await utils.resetDatabase(['activity']);
});
describe('GET /activities', () => {
describe('GET /activity', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/activities');
const { status, body } = await request(app).get('/activity');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require an albumId', async () => {
const { status, body } = await request(app)
.get('/activities')
.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'])));
});
it('should reject an invalid albumId', async () => {
const { status, body } = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
@@ -71,7 +69,7 @@ describe('/activities', () => {
it('should reject an invalid assetId', async () => {
const { status, body } = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
@@ -80,7 +78,7 @@ describe('/activities', () => {
it('should start off empty', async () => {
const { status, body } = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([]);
@@ -104,7 +102,7 @@ describe('/activities', () => {
]);
const { status, body } = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
@@ -123,7 +121,7 @@ describe('/activities', () => {
]);
const { status, body } = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: album.id, type: 'comment' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
@@ -142,7 +140,7 @@ describe('/activities', () => {
]);
const { status, body } = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: album.id, type: 'like' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
@@ -154,7 +152,7 @@ describe('/activities', () => {
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
const response1 = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: album.id, userId: uuidDto.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -162,7 +160,7 @@ describe('/activities', () => {
expect(response1.body.length).toBe(0);
const response2 = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -182,7 +180,7 @@ describe('/activities', () => {
]);
const { status, body } = await request(app)
.get('/activities')
.get('/activity')
.query({ albumId: album.id, assetId: asset.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
@@ -191,16 +189,16 @@ describe('/activities', () => {
});
});
describe('POST /activities', () => {
describe('POST /activity', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/activities');
const { status, body } = await request(app).post('/activity');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require an albumId', async () => {
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidDto.invalid });
expect(status).toEqual(400);
@@ -209,7 +207,7 @@ describe('/activities', () => {
it('should require a comment when type is comment', async () => {
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidDto.notFound, type: 'comment', comment: null });
expect(status).toEqual(400);
@@ -218,7 +216,7 @@ describe('/activities', () => {
it('should add a comment to an album', async () => {
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
albumId: album.id,
@@ -238,7 +236,7 @@ describe('/activities', () => {
it('should add a like to an album', async () => {
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
@@ -255,7 +253,7 @@ describe('/activities', () => {
it('should return a 200 for a duplicate like on the album', async () => {
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(200);
@@ -269,7 +267,7 @@ describe('/activities', () => {
type: ReactionType.Like,
});
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
@@ -278,7 +276,7 @@ describe('/activities', () => {
it('should add a comment to an asset', async () => {
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
albumId: album.id,
@@ -299,7 +297,7 @@ describe('/activities', () => {
it('should add a like to an asset', async () => {
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(201);
@@ -321,7 +319,7 @@ describe('/activities', () => {
});
const { status, body } = await request(app)
.post('/activities')
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(200);
@@ -329,16 +327,16 @@ describe('/activities', () => {
});
});
describe('DELETE /activities/:id', () => {
describe('DELETE /activity/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/activities/${uuidDto.notFound}`);
const { status, body } = await request(app).delete(`/activity/${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(`/activities/${uuidDto.invalid}`)
.delete(`/activity/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
@@ -351,7 +349,7 @@ describe('/activities', () => {
comment: 'This is a test comment',
});
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
@@ -362,7 +360,7 @@ describe('/activities', () => {
type: ReactionType.Like,
});
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
@@ -375,7 +373,7 @@ describe('/activities', () => {
});
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
@@ -389,7 +387,7 @@ describe('/activities', () => {
});
const { status, body } = await request(app)
.delete(`/activities/${reaction.id}`)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400);
@@ -407,7 +405,7 @@ describe('/activities', () => {
);
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(204);

View File

@@ -2,9 +2,9 @@ import {
addAssetsToAlbum,
AlbumResponseDto,
AlbumUserRole,
AssetMediaResponseDto,
AssetFileUploadResponseDto,
AssetOrder,
deleteUserAdmin,
deleteUser,
getAlbumInfo,
LoginResponseDto,
SharedLinkType,
@@ -23,11 +23,11 @@ const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
describe('/albums', () => {
describe('/album', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetMediaResponseDto;
let user1Asset1: AssetFileUploadResponseDto;
let user1Asset2: AssetFileUploadResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
@@ -107,19 +107,19 @@ describe('/albums', () => {
}),
]);
await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
await deleteUser({ id: user3.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /albums', () => {
describe('GET /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/albums');
const { status, body } = await request(app).get('/album');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should reject an invalid shared param', async () => {
const { status, body } = await request(app)
.get('/albums?shared=invalid')
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['shared must be a boolean value']));
@@ -127,7 +127,7 @@ describe('/albums', () => {
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(app)
.get('/albums?assetId=invalid')
.get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
@@ -135,7 +135,7 @@ describe('/albums', () => {
it("should not show other users' favorites", async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toEqual(200);
expect(body).toEqual({
@@ -146,7 +146,7 @@ describe('/albums', () => {
it('should not return shared albums with a deleted owner', async () => {
const { status, body } = await request(app)
.get('/albums?shared=true')
.get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -178,7 +178,7 @@ describe('/albums', () => {
});
it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(app).get('/albums').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(4);
expect(body).toEqual(
@@ -209,7 +209,7 @@ describe('/albums', () => {
it('should return the album collection filtered by shared', async () => {
const { status, body } = await request(app)
.get('/albums?shared=true')
.get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
@@ -241,7 +241,7 @@ describe('/albums', () => {
it('should return the album collection filtered by NOT shared', async () => {
const { status, body } = await request(app)
.get('/albums?shared=false')
.get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
@@ -258,7 +258,7 @@ describe('/albums', () => {
it('should return the album collection filtered by assetId', async () => {
const { status, body } = await request(app)
.get(`/albums?assetId=${user1Asset2.id}`)
.get(`/album?assetId=${user1Asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
@@ -266,7 +266,7 @@ describe('/albums', () => {
it('should return the album collection filtered by assetId and ignores shared=true', async () => {
const { status, body } = await request(app)
.get(`/albums?shared=true&assetId=${user1Asset1.id}`)
.get(`/album?shared=true&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(5);
@@ -274,23 +274,23 @@ describe('/albums', () => {
it('should return the album collection filtered by assetId and ignores shared=false', async () => {
const { status, body } = await request(app)
.get(`/albums?shared=false&assetId=${user1Asset1.id}`)
.get(`/album?shared=false&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(5);
});
});
describe('GET /albums/:id', () => {
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/albums/${user1Albums[0].id}`);
const { status, body } = await request(app).get(`/album/${user1Albums[0].id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return album info for own album', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}?withoutAssets=false`)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -302,7 +302,7 @@ describe('/albums', () => {
it('should return album info for shared album (editor)', async () => {
const { status, body } = await request(app)
.get(`/albums/${user2Albums[0].id}?withoutAssets=false`)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -311,7 +311,7 @@ describe('/albums', () => {
it('should return album info for shared album (viewer)', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[3].id}?withoutAssets=false`)
.get(`/album/${user1Albums[3].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
@@ -320,7 +320,7 @@ describe('/albums', () => {
it('should return album info with assets when withoutAssets is undefined', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}`)
.get(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -332,7 +332,7 @@ describe('/albums', () => {
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(app)
.get(`/albums/${user1Albums[0].id}?withoutAssets=true`)
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -344,16 +344,16 @@ describe('/albums', () => {
});
});
describe('GET /albums/count', () => {
describe('GET /album/count', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/albums/count');
const { status, body } = await request(app).get('/album/count');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(app)
.get('/albums/count')
.get('/album/count')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -361,16 +361,16 @@ describe('/albums', () => {
});
});
describe('POST /albums', () => {
describe('POST /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/albums').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);
});
it('should create an album', async () => {
const { status, body } = await request(app)
.post('/albums')
.post('/album')
.send({ albumName: 'New album' })
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(201);
@@ -383,6 +383,7 @@ describe('/albums', () => {
description: '',
albumThumbnailAssetId: null,
shared: false,
sharedUsers: [],
albumUsers: [],
hasSharedLink: false,
assets: [],
@@ -394,9 +395,9 @@ describe('/albums', () => {
});
});
describe('PUT /albums/:id/assets', () => {
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/albums/${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);
});
@@ -404,7 +405,7 @@ describe('/albums', () => {
it('should be able to add own asset to own album', async () => {
const asset = await utils.createAsset(user1.accessToken);
const { status, body } = await request(app)
.put(`/albums/${user1Albums[0].id}/assets`)
.put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
@@ -415,7 +416,7 @@ describe('/albums', () => {
it('should be able to add own asset to shared album', async () => {
const asset = await utils.createAsset(user1.accessToken);
const { status, body } = await request(app)
.put(`/albums/${user2Albums[0].id}/assets`)
.put(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
@@ -426,7 +427,7 @@ describe('/albums', () => {
it('should not be able to add assets to album as a viewer', async () => {
const asset = await utils.createAsset(user2.accessToken);
const { status, body } = await request(app)
.put(`/albums/${user1Albums[3].id}/assets`)
.put(`/album/${user1Albums[3].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [asset.id] });
@@ -437,7 +438,7 @@ describe('/albums', () => {
it('should add duplicate assets only once', async () => {
const asset = await utils.createAsset(user1.accessToken);
const { status, body } = await request(app)
.put(`/albums/${user1Albums[0].id}/assets`)
.put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id, asset.id] });
@@ -449,10 +450,10 @@ describe('/albums', () => {
});
});
describe('PATCH /albums/:id', () => {
describe('PATCH /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.patch(`/albums/${uuidDto.notFound}`)
.patch(`/album/${uuidDto.notFound}`)
.send({ albumName: 'New album name' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -463,7 +464,7 @@ describe('/albums', () => {
albumName: 'New album',
});
const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
albumName: 'New album name',
@@ -480,7 +481,7 @@ describe('/albums', () => {
it('should not be able to update as a viewer', async () => {
const { status, body } = await request(app)
.patch(`/albums/${user1Albums[3].id}`)
.patch(`/album/${user1Albums[3].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ albumName: 'New album name' });
@@ -490,7 +491,7 @@ describe('/albums', () => {
it('should not be able to update as an editor', async () => {
const { status, body } = await request(app)
.patch(`/albums/${user1Albums[0].id}`)
.patch(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ albumName: 'New album name' });
@@ -499,10 +500,10 @@ describe('/albums', () => {
});
});
describe('DELETE /albums/:id/assets', () => {
describe('DELETE /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[0].id}/assets`)
.delete(`/album/${user1Albums[0].id}/assets`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(401);
@@ -511,7 +512,7 @@ describe('/albums', () => {
it('should not be able to remove foreign asset from own album', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user2Albums[0].id}/assets`)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset1.id] });
@@ -527,7 +528,7 @@ describe('/albums', () => {
it('should not be able to remove foreign asset from foreign album', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[0].id}/assets`)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset1.id] });
@@ -543,7 +544,7 @@ describe('/albums', () => {
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[0].id}/assets`)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
@@ -553,7 +554,7 @@ describe('/albums', () => {
it('should be able to remove own asset from shared album', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user2Albums[0].id}/assets`)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
@@ -563,7 +564,7 @@ describe('/albums', () => {
it('should not be able to remove assets from album as a viewer', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[3].id}/assets`)
.delete(`/album/${user1Albums[3].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset1.id] });
@@ -573,7 +574,7 @@ describe('/albums', () => {
it('should remove duplicate assets only once', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[1].id}/assets`)
.delete(`/album/${user1Albums[1].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id, user1Asset1.id] });
@@ -595,7 +596,7 @@ describe('/albums', () => {
});
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/albums/${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);
@@ -603,25 +604,21 @@ describe('/albums', () => {
it('should be able to add user to own album', async () => {
const { status, body } = await request(app)
.put(`/albums/${album.id}/users`)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
albumUsers: [
expect.objectContaining({
user: expect.objectContaining({ id: user2.userId }),
}),
],
sharedUsers: [expect.objectContaining({ id: user2.userId })],
}),
);
});
it('should not be able to share album with owner', async () => {
const { status, body } = await request(app)
.put(`/albums/${album.id}/users`)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
@@ -631,12 +628,12 @@ describe('/albums', () => {
it('should not be able to add existing user to shared album', async () => {
await request(app)
.put(`/albums/${album.id}/users`)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
const { status, body } = await request(app)
.put(`/albums/${album.id}/users`)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
@@ -655,16 +652,14 @@ describe('/albums', () => {
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
const { status } = await request(app)
.put(`/albums/${album.id}/user/${user2.userId}`)
.put(`/album/${album.id}/user/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ role: AlbumUserRole.Editor });
expect(status).toBe(200);
// Get album to verify the role change
const { body } = await request(app)
.get(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
const { body } = await request(app).get(`/album/${album.id}`).set('Authorization', `Bearer ${user1.accessToken}`);
expect(body).toEqual(
expect.objectContaining({
albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })],
@@ -681,7 +676,7 @@ describe('/albums', () => {
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
const { status, body } = await request(app)
.put(`/albums/${album.id}/user/${user2.userId}`)
.put(`/album/${album.id}/user/${user2.userId}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ role: AlbumUserRole.Editor });

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from
import { asBearerAuth, utils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/audits', () => {
describe('/audit', () => {
let admin: LoginResponseDto;
beforeAll(async () => {

View File

@@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
import { readFile, writeFile } from 'node:fs/promises';
import { errorDto } from 'src/responses';
import { app, tempDir, utils } from 'src/utils';
@@ -7,8 +7,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/download', () => {
let admin: LoginResponseDto;
let asset1: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let asset1: AssetFileUploadResponseDto;
let asset2: AssetFileUploadResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
@@ -73,4 +73,22 @@ describe('/download', () => {
}
});
});
describe('POST /download/asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/download/asset/${asset1.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should download file', async () => {
const response = await request(app)
.post(`/download/asset/${asset1.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toEqual('image/png');
});
});
});

View File

@@ -1,4 +1,11 @@
import { LibraryResponseDto, LoginResponseDto, ScanLibraryDto, getAllLibraries, scanLibrary } from '@immich/sdk';
import {
LibraryResponseDto,
LibraryType,
LoginResponseDto,
ScanLibraryDto,
getAllLibraries,
scanLibrary,
} from '@immich/sdk';
import { cpSync, existsSync } from 'node:fs';
import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures';
@@ -11,7 +18,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) =>
scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) });
describe('/libraries', () => {
describe('/library', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let library: LibraryResponseDto;
@@ -22,7 +29,7 @@ describe('/libraries', () => {
admin = await utils.adminSetup();
await utils.resetAdminConfig(admin.accessToken);
user = await utils.userSetup(admin.accessToken, userDto.user1);
library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External });
websocket = await utils.connectWebsocket(admin.accessToken);
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`);
utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`);
@@ -37,26 +44,44 @@ describe('/libraries', () => {
utils.resetEvents();
});
describe('GET /libraries', () => {
describe('GET /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/libraries');
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 /libraries', () => {
describe('POST /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/libraries').send({});
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('/libraries')
.post('/library')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ ownerId: admin.userId });
.send({ ownerId: admin.userId, type: LibraryType.External });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
@@ -64,14 +89,15 @@ describe('/libraries', () => {
it('should create an external library with defaults', async () => {
const { status, body } = await request(app)
.post('/libraries')
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ownerId: admin.userId });
.send({ ownerId: admin.userId, 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,
@@ -83,10 +109,11 @@ describe('/libraries', () => {
it('should create an external library with options', async () => {
const { status, body } = await request(app)
.post('/libraries')
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
ownerId: admin.userId,
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**'],
@@ -103,10 +130,11 @@ describe('/libraries', () => {
it('should not create an external library with duplicate import paths', async () => {
const { status, body } = await request(app)
.post('/libraries')
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
ownerId: admin.userId,
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path', '/path'],
exclusionPatterns: ['**/Raw/**'],
@@ -118,10 +146,11 @@ describe('/libraries', () => {
it('should not create an external library with duplicate exclusion patterns', async () => {
const { status, body } = await request(app)
.post('/libraries')
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
ownerId: admin.userId,
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**', '**/Raw/**'],
@@ -130,18 +159,72 @@ describe('/libraries', () => {
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({ ownerId: admin.userId, 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({ ownerId: admin.userId, 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({ ownerId: admin.userId, 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({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
});
});
describe('PUT /libraries/:id', () => {
describe('PUT /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/libraries/${uuidDto.notFound}`).send({});
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(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'New Library Name' });
@@ -155,7 +238,7 @@ describe('/libraries', () => {
it('should not set an empty name', async () => {
const { status, body } = await request(app)
.put(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: '' });
@@ -165,7 +248,7 @@ describe('/libraries', () => {
it('should change the import paths', async () => {
const { status, body } = await request(app)
.put(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [testAssetDirInternal] });
@@ -179,7 +262,7 @@ describe('/libraries', () => {
it('should reject an empty import path', async () => {
const { status, body } = await request(app)
.put(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [''] });
@@ -189,7 +272,7 @@ describe('/libraries', () => {
it('should reject duplicate import paths', async () => {
const { status, body } = await request(app)
.put(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: ['/path', '/path'] });
@@ -199,7 +282,7 @@ describe('/libraries', () => {
it('should change the exclusion pattern', async () => {
const { status, body } = await request(app)
.put(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/Raw/**'] });
@@ -213,7 +296,7 @@ describe('/libraries', () => {
it('should reject duplicate exclusion patterns', async () => {
const { status, body } = await request(app)
.put(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
@@ -223,7 +306,7 @@ describe('/libraries', () => {
it('should reject an empty exclusion pattern', async () => {
const { status, body } = await request(app)
.put(`/libraries/${library.id}`)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: [''] });
@@ -232,9 +315,9 @@ describe('/libraries', () => {
});
});
describe('GET /libraries/:id', () => {
describe('GET /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}`);
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -242,23 +325,27 @@ describe('/libraries', () => {
it('should require admin access', async () => {
const { status, body } = await request(app)
.get(`/libraries/${uuidDto.notFound}`)
.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 utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
});
const { status, body } = await request(app)
.get(`/libraries/${library.id}`)
.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,
@@ -269,26 +356,41 @@ describe('/libraries', () => {
});
});
describe('GET /libraries/:id/statistics', () => {
describe('GET /library/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}/statistics`);
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /libraries/:id/scan', () => {
describe('POST /library/:id/scan', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/scan`).send({});
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/scan`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not scan an upload library', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.Upload,
});
const { status, body } = await request(app)
.post(`/library/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Can only refresh external libraries'));
});
it('should scan external library', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
});
@@ -304,6 +406,7 @@ describe('/libraries', () => {
it('should scan external library with exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
exclusionPatterns: ['**/directoryA'],
});
@@ -320,6 +423,7 @@ describe('/libraries', () => {
it('should scan multiple import paths', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
});
@@ -336,6 +440,7 @@ describe('/libraries', () => {
it('should pick up new files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -361,6 +466,7 @@ describe('/libraries', () => {
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`);
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -387,6 +493,7 @@ describe('/libraries', () => {
it('should scan new files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -414,6 +521,7 @@ describe('/libraries', () => {
it('should reimport modified files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -441,6 +549,7 @@ describe('/libraries', () => {
it('should not reimport unmodified files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -470,6 +579,7 @@ describe('/libraries', () => {
it('should reimport all files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -496,9 +606,9 @@ describe('/libraries', () => {
});
});
describe('POST /libraries/:id/removeOffline', () => {
describe('POST /library/:id/removeOffline', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({});
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/removeOffline`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -507,6 +617,7 @@ describe('/libraries', () => {
it('should remove offline files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -532,7 +643,7 @@ describe('/libraries', () => {
expect(offlineAssets.count).toBe(1);
const { status } = await request(app)
.post(`/libraries/${library.id}/removeOffline`)
.post(`/library/${library.id}/removeOffline`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
@@ -547,6 +658,7 @@ describe('/libraries', () => {
it('should not remove online files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -557,7 +669,7 @@ describe('/libraries', () => {
expect(assetsBefore.count).toBeGreaterThan(1);
const { status } = await request(app)
.post(`/libraries/${library.id}/removeOffline`)
.post(`/library/${library.id}/removeOffline`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
@@ -569,9 +681,9 @@ describe('/libraries', () => {
});
});
describe('POST /libraries/:id/validate', () => {
describe('POST /library/:id/validate', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/validate`).send({});
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/validate`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -617,25 +729,54 @@ describe('/libraries', () => {
});
});
describe('DELETE /libraries/:id', () => {
describe('DELETE /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/libraries/${uuidDto.notFound}`);
const { status, body } = await request(app).delete(`/library/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should delete an external library', async () => {
const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
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(`/libraries/${library.id}`)
.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 utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
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) });
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -648,6 +789,7 @@ describe('/libraries', () => {
it('should delete an external library with assets', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
@@ -655,13 +797,13 @@ describe('/libraries', () => {
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
const { status, body } = await request(app)
.delete(`/libraries/${library.id}`)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
expect(body).toEqual({});
const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) });
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({

View File

@@ -1,162 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/map', () => {
let websocket: Socket;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetMediaResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
websocket = await utils.connectWebsocket(admin.accessToken);
asset = await utils.createAsset(admin.accessToken);
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
utils.resetEvents();
const uploadFile = async (input: string) => {
const filepath = join(testAssetDir, input);
const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
};
await Promise.all(files.map((f) => uploadFile(f)));
});
afterAll(() => {
utils.disconnectWebsocket(websocket);
});
describe('GET /map/markers', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/markers');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
// TODO archive one of these assets
it('should get map markers for all non-archived assets', async () => {
const { status, body } = await request(app)
.get('/map/markers')
.query({ isArchived: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body).toEqual([
{
city: 'Palisade',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400_968),
state: 'Colorado',
},
{
city: 'Ralston',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071_625),
state: 'Nebraska',
},
]);
});
// TODO archive one of these assets
it('should get all map markers', async () => {
const { status, body } = await request(app)
.get('/map/markers')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{
city: 'Palisade',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400_968),
state: 'Colorado',
},
{
city: 'Ralston',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071_625),
state: 'Nebraska',
},
]);
});
});
describe('GET /map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
.get('/map/style.json')
.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']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});
it('should return the dark style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should not require admin authentication', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});
});

View File

@@ -1,5 +1,5 @@
import {
AssetMediaResponseDto,
AssetFileUploadResponseDto,
LoginResponseDto,
MemoryResponseDto,
MemoryType,
@@ -15,9 +15,9 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/memories', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let adminAsset: AssetMediaResponseDto;
let userAsset1: AssetMediaResponseDto;
let userAsset2: AssetMediaResponseDto;
let adminAsset: AssetFileUploadResponseDto;
let userAsset1: AssetFileUploadResponseDto;
let userAsset2: AssetFileUploadResponseDto;
let userMemory: MemoryResponseDto;
beforeAll(async () => {

View File

@@ -5,7 +5,7 @@ import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/partners', () => {
describe('/partner', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
@@ -28,9 +28,9 @@ describe('/partners', () => {
]);
});
describe('GET /partners', () => {
describe('GET /partner', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/partners');
const { status, body } = await request(app).get('/partner');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -38,7 +38,7 @@ describe('/partners', () => {
it('should get all partners shared by user', async () => {
const { status, body } = await request(app)
.get('/partners')
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: 'shared-by' });
@@ -48,7 +48,7 @@ describe('/partners', () => {
it('should get all partners that share with user', async () => {
const { status, body } = await request(app)
.get('/partners')
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: 'shared-with' });
@@ -57,9 +57,9 @@ describe('/partners', () => {
});
});
describe('POST /partners/:id', () => {
describe('POST /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/partners/${user3.userId}`);
const { status, body } = await request(app).post(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -67,7 +67,7 @@ describe('/partners', () => {
it('should share with new partner', async () => {
const { status, body } = await request(app)
.post(`/partners/${user3.userId}`)
.post(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(201);
@@ -76,7 +76,7 @@ describe('/partners', () => {
it('should not share with new partner if already sharing with this partner', async () => {
const { status, body } = await request(app)
.post(`/partners/${user2.userId}`)
.post(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
@@ -84,9 +84,9 @@ describe('/partners', () => {
});
});
describe('PUT /partners/:id', () => {
describe('PUT /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/partners/${user2.userId}`);
const { status, body } = await request(app).put(`/partner/${user2.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -94,7 +94,7 @@ describe('/partners', () => {
it('should update partner', async () => {
const { status, body } = await request(app)
.put(`/partners/${user2.userId}`)
.put(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: false });
@@ -103,9 +103,9 @@ describe('/partners', () => {
});
});
describe('DELETE /partners/:id', () => {
describe('DELETE /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/partners/${user3.userId}`);
const { status, body } = await request(app).delete(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -113,7 +113,7 @@ describe('/partners', () => {
it('should delete partner', async () => {
const { status } = await request(app)
.delete(`/partners/${user3.userId}`)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -121,7 +121,7 @@ describe('/partners', () => {
it('should throw a bad request if partner not found', async () => {
const { status, body } = await request(app)
.delete(`/partners/${user3.userId}`)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);

View File

@@ -12,7 +12,7 @@ const invalidBirthday = [
{ birthDate: new Date(9999, 0, 0).toISOString(), response: ['Birth date cannot be in the future'] },
];
describe('/people', () => {
describe('/person', () => {
let admin: LoginResponseDto;
let visiblePerson: PersonResponseDto;
let hiddenPerson: PersonResponseDto;
@@ -47,11 +47,11 @@ describe('/people', () => {
]);
});
describe('GET /people', () => {
describe('GET /person', () => {
beforeEach(async () => {});
it('should require authentication', async () => {
const { status, body } = await request(app).get('/people');
const { status, body } = await request(app).get('/person');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -59,7 +59,7 @@ describe('/people', () => {
it('should return all people (including hidden)', async () => {
const { status, body } = await request(app)
.get('/people')
.get('/person')
.set('Authorization', `Bearer ${admin.accessToken}`)
.query({ withHidden: true });
@@ -76,7 +76,7 @@ describe('/people', () => {
});
it('should return only visible people', async () => {
const { status, body } = await request(app).get('/people').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({
@@ -90,9 +90,9 @@ describe('/people', () => {
});
});
describe('GET /people/:id', () => {
describe('GET /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/people/${uuidDto.notFound}`);
const { status, body } = await request(app).get(`/person/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -100,7 +100,7 @@ describe('/people', () => {
it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(app)
.get(`/people/${uuidDto.notFound}`)
.get(`/person/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
@@ -109,7 +109,7 @@ describe('/people', () => {
it('should return person information', async () => {
const { status, body } = await request(app)
.get(`/people/${visiblePerson.id}`)
.get(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@@ -117,9 +117,9 @@ describe('/people', () => {
});
});
describe('GET /people/:id/statistics', () => {
describe('GET /person/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/people/${multipleAssetsPerson.id}/statistics`);
const { status, body } = await request(app).get(`/person/${multipleAssetsPerson.id}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -127,7 +127,7 @@ describe('/people', () => {
it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(app)
.get(`/people/${uuidDto.notFound}/statistics`)
.get(`/person/${uuidDto.notFound}/statistics`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
@@ -136,7 +136,7 @@ describe('/people', () => {
it('should return the correct number of assets', async () => {
const { status, body } = await request(app)
.get(`/people/${multipleAssetsPerson.id}/statistics`)
.get(`/person/${multipleAssetsPerson.id}/statistics`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@@ -144,9 +144,9 @@ describe('/people', () => {
});
});
describe('POST /people', () => {
describe('POST /person', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/people`);
const { status, body } = await request(app).post(`/person`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -154,7 +154,7 @@ describe('/people', () => {
for (const { birthDate, response } of invalidBirthday) {
it(`should not accept an invalid birth date [${birthDate}]`, async () => {
const { status, body } = await request(app)
.post(`/people`)
.post(`/person`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
@@ -164,7 +164,7 @@ describe('/people', () => {
it('should create a person', async () => {
const { status, body } = await request(app)
.post(`/people`)
.post(`/person`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
name: 'New Person',
@@ -179,9 +179,9 @@ describe('/people', () => {
});
});
describe('PUT /people/:id', () => {
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/people/${uuidDto.notFound}`);
const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -193,7 +193,7 @@ describe('/people', () => {
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
@@ -204,7 +204,7 @@ describe('/people', () => {
for (const { birthDate, response } of invalidBirthday) {
it(`should not accept an invalid birth date [${birthDate}]`, async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
@@ -214,7 +214,7 @@ describe('/people', () => {
it('should update a date of birth', async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: '1990-01-01T05:00:00.000Z' });
expect(status).toBe(200);
@@ -223,7 +223,7 @@ describe('/people', () => {
it('should clear a date of birth', async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: null });
expect(status).toBe(200);

View File

@@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk';
import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
@@ -13,25 +13,25 @@ describe('/search', () => {
let admin: LoginResponseDto;
let websocket: Socket;
let assetFalcon: AssetMediaResponseDto;
let assetDenali: AssetMediaResponseDto;
let assetCyclamen: AssetMediaResponseDto;
let assetNotocactus: AssetMediaResponseDto;
let assetSilver: AssetMediaResponseDto;
let assetDensity: AssetMediaResponseDto;
// let assetPhiladelphia: AssetMediaResponseDto;
// let assetOrychophragmus: AssetMediaResponseDto;
// let assetRidge: AssetMediaResponseDto;
// let assetPolemonium: AssetMediaResponseDto;
// let assetWood: AssetMediaResponseDto;
// let assetGlarus: AssetMediaResponseDto;
let assetHeic: AssetMediaResponseDto;
let assetRocks: AssetMediaResponseDto;
let assetOneJpg6: AssetMediaResponseDto;
let assetOneHeic6: AssetMediaResponseDto;
let assetOneJpg5: AssetMediaResponseDto;
let assetSprings: AssetMediaResponseDto;
let assetLast: AssetMediaResponseDto;
let assetFalcon: AssetFileUploadResponseDto;
let assetDenali: AssetFileUploadResponseDto;
let assetCyclamen: AssetFileUploadResponseDto;
let assetNotocactus: AssetFileUploadResponseDto;
let assetSilver: AssetFileUploadResponseDto;
let assetDensity: AssetFileUploadResponseDto;
// let assetPhiladelphia: AssetFileUploadResponseDto;
// let assetOrychophragmus: AssetFileUploadResponseDto;
// let assetRidge: AssetFileUploadResponseDto;
// let assetPolemonium: AssetFileUploadResponseDto;
// let assetWood: AssetFileUploadResponseDto;
// let assetGlarus: AssetFileUploadResponseDto;
let assetHeic: AssetFileUploadResponseDto;
let assetRocks: AssetFileUploadResponseDto;
let assetOneJpg6: AssetFileUploadResponseDto;
let assetOneHeic6: AssetFileUploadResponseDto;
let assetOneJpg5: AssetFileUploadResponseDto;
let assetSprings: AssetFileUploadResponseDto;
let assetLast: AssetFileUploadResponseDto;
let cities: string[];
let states: string[];
let countries: string[];
@@ -66,7 +66,7 @@ describe('/search', () => {
// last asset
{ filename: '/albums/nature/wood_anemones.jpg' },
];
const assets: AssetMediaResponseDto[] = [];
const assets: AssetFileUploadResponseDto[] = [];
for (const { filename, dto } of files) {
const bytes = await readFile(join(testAssetDir, filename));
assets.push(
@@ -134,7 +134,7 @@ describe('/search', () => {
// assetWood,
] = assets;
assetLast = assets.at(-1) as AssetMediaResponseDto;
assetLast = assets.at(-1) as AssetFileUploadResponseDto;
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });

View File

@@ -15,16 +15,16 @@ describe('/server-info', () => {
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
describe('GET /server-info/storage', () => {
describe('GET /server-info', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/server-info/storage');
const { status, body } = await request(app).get('/server-info');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return the disk information', async () => {
const { status, body } = await request(app)
.get('/server-info/storage')
.get('/server-info')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
@@ -66,7 +66,6 @@ describe('/server-info', () => {
expect(body).toEqual({
smartSearch: false,
configFile: false,
duplicateDetection: false,
facialRecognition: false,
map: true,
reverseGeocoding: true,

View File

@@ -1,11 +1,11 @@
import {
AlbumResponseDto,
AssetMediaResponseDto,
AssetFileUploadResponseDto,
LoginResponseDto,
SharedLinkResponseDto,
SharedLinkType,
createAlbum,
deleteUserAdmin,
deleteUser,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@@ -13,10 +13,10 @@ import { app, asBearerAuth, shareUrl, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/shared-links', () => {
describe('/shared-link', () => {
let admin: LoginResponseDto;
let asset1: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let asset1: AssetFileUploadResponseDto;
let asset2: AssetFileUploadResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let album: AlbumResponseDto;
@@ -86,7 +86,7 @@ describe('/shared-links', () => {
}),
]);
await deleteUserAdmin({ id: user2.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
await deleteUser({ id: user2.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /share/${key}', () => {
@@ -114,9 +114,9 @@ describe('/shared-links', () => {
});
});
describe('GET /shared-links', () => {
describe('GET /shared-link', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/shared-links');
const { status, body } = await request(app).get('/shared-link');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -124,7 +124,7 @@ describe('/shared-links', () => {
it('should get all shared links created by user', async () => {
const { status, body } = await request(app)
.get('/shared-links')
.get('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -142,7 +142,7 @@ describe('/shared-links', () => {
it('should not get shared links created by other users', async () => {
const { status, body } = await request(app)
.get('/shared-links')
.get('/shared-link')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@@ -150,15 +150,15 @@ describe('/shared-links', () => {
});
});
describe('GET /shared-links/me', () => {
describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => {
const { status } = await request(app).get('/shared-links/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-links/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(
@@ -172,7 +172,7 @@ describe('/shared-links', () => {
it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(app)
.get('/shared-links/me')
.get('/shared-link/me')
.query({ key: linkWithAlbum.key + 'foo' });
expect(status).toBe(401);
@@ -180,14 +180,14 @@ describe('/shared-links', () => {
});
it('should return unauthorized if target has been soft deleted', async () => {
const { status, body } = await request(app).get('/shared-links/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-links/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);
@@ -195,7 +195,7 @@ describe('/shared-links', () => {
it('should get data for correct password protected link', async () => {
const { status, body } = await request(app)
.get('/shared-links/me')
.get('/shared-link/me')
.query({ key: linkWithPassword.key, password: 'foo' });
expect(status).toBe(200);
@@ -209,7 +209,7 @@ describe('/shared-links', () => {
});
it('should return metadata for album shared link', async () => {
const { status, body } = await request(app).get('/shared-links/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,7 +225,7 @@ describe('/shared-links', () => {
});
it('should not return metadata for album shared link without metadata', async () => {
const { status, body } = await request(app).get('/shared-links/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);
@@ -239,9 +239,9 @@ describe('/shared-links', () => {
});
});
describe('GET /shared-links/:id', () => {
describe('GET /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/shared-links/${linkWithAlbum.id}`);
const { status, body } = await request(app).get(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -249,7 +249,7 @@ describe('/shared-links', () => {
it('should get shared link by id', async () => {
const { status, body } = await request(app)
.get(`/shared-links/${linkWithAlbum.id}`)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
@@ -264,7 +264,7 @@ describe('/shared-links', () => {
it('should not get shared link by id if user has not created the link or it does not exist', async () => {
const { status, body } = await request(app)
.get(`/shared-links/${linkWithAlbum.id}`)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
@@ -272,10 +272,10 @@ describe('/shared-links', () => {
});
});
describe('POST /shared-links', () => {
describe('POST /shared-link', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.post('/shared-link')
.send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
expect(status).toBe(401);
@@ -284,7 +284,7 @@ describe('/shared-links', () => {
it('should require a type and the correspondent asset/album id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
@@ -293,7 +293,7 @@ describe('/shared-links', () => {
it('should require an asset/album id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.Album });
@@ -303,7 +303,7 @@ describe('/shared-links', () => {
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
@@ -313,7 +313,7 @@ describe('/shared-links', () => {
it('should create a shared link', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.Album, albumId: album.id });
@@ -327,10 +327,10 @@ describe('/shared-links', () => {
});
});
describe('PATCH /shared-links/:id', () => {
describe('PATCH /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.patch(`/shared-links/${linkWithAlbum.id}`)
.patch(`/shared-link/${linkWithAlbum.id}`)
.send({ description: 'foo' });
expect(status).toBe(401);
@@ -339,7 +339,7 @@ describe('/shared-links', () => {
it('should fail if invalid link', async () => {
const { status, body } = await request(app)
.patch(`/shared-links/${uuidDto.notFound}`)
.patch(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
@@ -349,7 +349,7 @@ describe('/shared-links', () => {
it('should update shared link', async () => {
const { status, body } = await request(app)
.patch(`/shared-links/${linkWithAlbum.id}`)
.patch(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
@@ -364,10 +364,10 @@ describe('/shared-links', () => {
});
});
describe('PUT /shared-links/:id/assets', () => {
describe('PUT /shared-link/:id/assets', () => {
it('should not add assets to shared link (album)', async () => {
const { status, body } = await request(app)
.put(`/shared-links/${linkWithAlbum.id}/assets`)
.put(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
@@ -377,7 +377,7 @@ describe('/shared-links', () => {
it('should add an assets to a shared link (individual)', async () => {
const { status, body } = await request(app)
.put(`/shared-links/${linkWithAssets.id}/assets`)
.put(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
@@ -386,10 +386,10 @@ describe('/shared-links', () => {
});
});
describe('DELETE /shared-links/:id/assets', () => {
describe('DELETE /shared-link/:id/assets', () => {
it('should not remove assets from a shared link (album)', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAlbum.id}/assets`)
.delete(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
@@ -399,7 +399,7 @@ describe('/shared-links', () => {
it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
.delete(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
@@ -408,9 +408,9 @@ describe('/shared-links', () => {
});
});
describe('DELETE /shared-links/:id', () => {
describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/shared-links/${linkWithAlbum.id}`);
const { status, body } = await request(app).delete(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -418,7 +418,7 @@ describe('/shared-links', () => {
it('should fail if invalid link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${uuidDto.notFound}`)
.delete(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
@@ -427,7 +427,7 @@ describe('/shared-links', () => {
it('should delete a shared link', async () => {
const { status } = await request(app)
.delete(`/shared-links/${linkWithAlbum.id}`)
.delete(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);

View File

@@ -1,4 +1,5 @@
import { LoginResponseDto, getConfig } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, getConfig } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
@@ -8,10 +9,74 @@ const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAu
describe('/system-config', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetFileUploadResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
asset = await utils.createAsset(admin.accessToken);
});
describe('GET /system-config/map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/system-config/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app)
.get(`/system-config/map/style.json?key=${sharedLink.key}`)
.query({ theme: 'dark' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
.get('/system-config/map/style.json')
.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']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(app)
.get('/system-config/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});
it('should return the dark style.json', async () => {
const { status, body } = await request(app)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should not require admin authentication', async () => {
const { status, body } = await request(app)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});
describe('PUT /system-config', () => {

View File

@@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
import { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@@ -19,7 +19,7 @@ describe('/timeline', () => {
let user: LoginResponseDto;
let timeBucketUser: LoginResponseDto;
let userAssets: AssetMediaResponseDto[];
let userAssets: AssetFileUploadResponseDto[];
beforeAll(async () => {
await utils.resetDatabase();

View File

@@ -1,4 +1,4 @@
import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk';
import { LoginResponseDto, getAllAssets } from '@immich/sdk';
import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
@@ -31,16 +31,17 @@ describe('/trash', () => {
const { id: assetId } = await utils.createAsset(admin.accessToken);
await utils.deleteAssets(admin.accessToken, [assetId]);
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
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 utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
expect(after.total).toBe(0);
const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
expect(after.length).toBe(0);
});
});
@@ -56,14 +57,14 @@ describe('/trash', () => {
const { id: assetId } = await utils.createAsset(admin.accessToken);
await utils.deleteAssets(admin.accessToken, [assetId]);
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
const before = await utils.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 getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false }));
const after = await utils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(false);
});
});

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