Merge branch 'immich-app:main' into fix-back-scroll

This commit is contained in:
Calum Dingwall 2024-09-03 18:03:08 -04:00 committed by GitHub
commit 8bf733ac57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
947 changed files with 59933 additions and 33953 deletions

View File

@ -22,6 +22,7 @@ open-api/typescript-sdk/node_modules/
server/coverage/ server/coverage/
server/node_modules/ server/node_modules/
server/upload/ server/upload/
server/src/queries
server/dist/ server/dist/
server/www/ server/www/

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://buy.immich.app']

View File

@ -83,7 +83,6 @@ body:
2. 2.
3. 3.
... ...
render: bash
validations: validations:
required: true required: true

40
.github/release.yml vendored
View File

@ -1,41 +1,29 @@
changelog: changelog:
categories: categories:
- title: ⚠️ Breaking Changes - title: 🚨 Breaking Changes
labels: labels:
- breaking-change - changelog:breaking-change
- title: 🗄️ Server - title: 🔒 Security
labels: labels:
- 🗄server - changelog:security
- title: 📱 Mobile - title: 🚀 Features
labels: labels:
- 📱mobile - changelog:feature
- title: 🖥️ Web - title: 🌟 Enhancements
labels: labels:
- 🖥web - changelog:enhancement
- title: 🧠 Machine Learning - title: 🐛 Bug fixes
labels: labels:
- 🧠machine-learning - changelog:bugfix
- title: ⚡ CLI - title: 📚 Documentation
labels: labels:
- cli - changelog:documentation
- title: 📓 Documentation - title: 🌐 Translations
labels: labels:
- documentation - changelog:translation
- title: 🔨 Maintenance
labels:
- deployment
- dependencies
- renovate
- maintenance
- tech-debt
- title: Other changes
labels:
- "*"

View File

@ -16,10 +16,28 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
pre-job:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- id: found_paths
uses: dorny/paths-filter@v3
with:
filters: |
mobile:
- 'mobile/**'
- name: Check if we should force jobs to run
id: should_force
run: echo "should_force=${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
build-sign-android: build-sign-android:
name: Build and sign Android name: Build and sign Android
needs: pre-job
# Skip when PR from a fork # Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' }} if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
runs-on: macos-14 runs-on: macos-14
steps: steps:

View File

@ -59,7 +59,7 @@ jobs:
uses: docker/setup-qemu-action@v3.2.0 uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.5.0 uses: docker/setup-buildx-action@v3.6.1
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@ -88,7 +88,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.5.0 uses: docker/build-push-action@v6.7.0
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

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

View File

@ -17,47 +17,58 @@ permissions:
packages: write packages: write
jobs: jobs:
build_and_push: pre-job:
name: Build and Push
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- id: found_paths
uses: dorny/paths-filter@v3
with:
filters: |
server:
- 'server/**'
- 'openapi/**'
- 'web/**'
machine-learning:
- 'machine-learning/**'
- name: Check if we should force jobs to run
id: should_force
run: echo "should_force=${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
build_and_push_ml:
name: Build and Push ML
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
runs-on: ubuntu-latest
env:
image: immich-machine-learning
context: machine-learning
file: machine-learning/Dockerfile
strategy: strategy:
# Prevent a failure in one image from stopping the other builds # Prevent a failure in one image from stopping the other builds
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- image: immich-machine-learning - platforms: linux/amd64,linux/arm64
context: machine-learning
file: machine-learning/Dockerfile
platforms: linux/amd64,linux/arm64
device: cpu device: cpu
- image: immich-machine-learning - platforms: linux/amd64
context: machine-learning
file: machine-learning/Dockerfile
platforms: linux/amd64
device: cuda device: cuda
suffix: -cuda suffix: -cuda
- image: immich-machine-learning - platforms: linux/amd64
context: machine-learning
file: machine-learning/Dockerfile
platforms: linux/amd64
device: openvino device: openvino
suffix: -openvino suffix: -openvino
- image: immich-machine-learning - platforms: linux/arm64
context: machine-learning
file: machine-learning/Dockerfile
platforms: linux/arm64
device: armnn device: armnn
suffix: -armnn suffix: -armnn
- image: immich-server
context: .
file: server/Dockerfile
platforms: linux/amd64,linux/arm64
device: cpu
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -66,7 +77,7 @@ jobs:
uses: docker/setup-qemu-action@v3.2.0 uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.5.0 uses: docker/setup-buildx-action@v3.6.1
- name: Login to Docker Hub - name: Login to Docker Hub
# Only push to Docker Hub when making a release # Only push to Docker Hub when making a release
@ -93,8 +104,8 @@ jobs:
# Disable latest tag # Disable latest tag
latest=false latest=false
images: | images: |
name=ghcr.io/${{ github.repository_owner }}/${{matrix.image}} name=ghcr.io/${{ github.repository_owner }}/${{env.image}}
name=altran1502/${{matrix.image}},enable=${{ github.event_name == 'release' }} name=altran1502/${{env.image}},enable=${{ github.event_name == 'release' }}
tags: | tags: |
# Tag with branch name # Tag with branch name
type=ref,event=branch,suffix=${{ matrix.suffix }} type=ref,event=branch,suffix=${{ matrix.suffix }}
@ -111,18 +122,109 @@ jobs:
# Essentially just ignore the cache output (PR can't write to registry cache) # Essentially just ignore the cache output (PR can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else else
echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ env.image }}" >> $GITHUB_OUTPUT
fi fi
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.5.0 uses: docker/build-push-action@v6.7.0
with: with:
context: ${{ matrix.context }} context: ${{ env.context }}
file: ${{ matrix.file }} file: ${{ env.file }}
platforms: ${{ matrix.platforms }} platforms: ${{ matrix.platforms }}
# Skip pushing when PR from a fork # Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }} push: ${{ !github.event.pull_request.head.repo.fork }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}} cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{env.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
build-args: |
DEVICE=${{ matrix.device }}
BUILD_ID=${{ github.run_id }}
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }}
build_and_push_server:
name: Build and Push Server
runs-on: ubuntu-latest
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
env:
image: immich-server
context: .
file: server/Dockerfile
strategy:
fail-fast: false
matrix:
include:
- platforms: linux/amd64,linux/arm64
device: cpu
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1
- name: Login to Docker Hub
# Only push to Docker Hub when making a release
if: ${{ github.event_name == 'release' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v5
with:
flavor: |
# Disable latest tag
latest=false
images: |
name=ghcr.io/${{ github.repository_owner }}/${{env.image}}
name=altran1502/${{env.image}},enable=${{ github.event_name == 'release' }}
tags: |
# Tag with branch name
type=ref,event=branch,suffix=${{ matrix.suffix }}
# Tag with pr-number
type=ref,event=pr,suffix=${{ matrix.suffix }}
# Tag with git tag on release
type=ref,event=tag,suffix=${{ matrix.suffix }}
type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }}
- name: Determine build cache output
id: cache-target
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Essentially just ignore the cache output (PR can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else
echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ env.image }}" >> $GITHUB_OUTPUT
fi
- name: Build and push image
uses: docker/build-push-action@v6.7.0
with:
context: ${{ env.context }}
file: ${{ env.file }}
platforms: ${{ matrix.platforms }}
# Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{env.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }} cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}

View File

@ -2,12 +2,8 @@ name: Docs build
on: on:
push: push:
branches: [main] branches: [main]
paths:
- "docs/**"
pull_request: pull_request:
branches: [main] branches: [main]
paths:
- "docs/**"
release: release:
types: [published] types: [published]
@ -16,7 +12,26 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
pre-job:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- id: found_paths
uses: dorny/paths-filter@v3
with:
filters: |
docs:
- 'docs/**'
- name: Check if we should force jobs to run
id: should_force
run: echo "should_force=${{ github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
build: build:
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:

View File

@ -10,10 +10,28 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
parameters: ${{ steps.parameters.outputs.result }} parameters: ${{ steps.parameters.outputs.result }}
artifact: ${{ steps.get-artifact.outputs.result }}
steps: steps:
- if: ${{ github.event.workflow_run.conclusion == 'failure' }} - if: ${{ github.event.workflow_run.conclusion != 'success' }}
run: echo 'The triggering workflow failed' && exit 1 run: echo 'The triggering workflow did not succeed' && exit 1
- name: Get artifact
id: get-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];
if (!matchArtifact) {
console.log("No artifact found with the name docs-build-output, build job was skipped")
return { found: false };
}
return { found: true, id: matchArtifact.id };
- name: Determine deploy parameters - name: Determine deploy parameters
id: parameters id: parameters
uses: actions/github-script@v7 uses: actions/github-script@v7
@ -75,7 +93,7 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: checks needs: checks
if: ${{ fromJson(needs.checks.outputs.parameters).shouldDeploy }} if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -98,18 +116,11 @@ jobs:
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
script: | script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ let artifact = ${{ needs.checks.outputs.artifact }};
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({ let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
artifact_id: matchArtifact.id, artifact_id: artifact.id,
archive_format: 'zip', archive_format: 'zip',
}); });
let fs = require('fs'); let fs = require('fs');

View File

@ -0,0 +1,21 @@
name: PR Label Validation
on:
pull_request_target:
types: [opened, labeled, unlabeled, synchronize]
jobs:
validate-release-label:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@v5
with:
mode: exactly
count: 1
use_regex: true
labels: "changelog:.*"
add_comment: true

View File

@ -29,10 +29,17 @@ jobs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }} ref: ${{ steps.push-tag.outputs.commit_long_sha }}
steps: steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
token: ${{ secrets.ORG_RELEASE_TOKEN }} token: ${{ steps.generate-token.outputs.token }}
- name: Install Poetry - name: Install Poetry
run: pipx install poetry run: pipx install poetry
@ -44,10 +51,8 @@ jobs:
id: push-tag id: push-tag
uses: EndBug/add-and-commit@v9 uses: EndBug/add-and-commit@v9
with: with:
author_name: Alex The Bot default_author: github_actions
author_email: alex.tran1502@gmail.com message: 'chore: version ${{ env.IMMICH_VERSION }}'
default_author: user_info
message: 'Version ${{ env.IMMICH_VERSION }}'
tag: ${{ env.IMMICH_VERSION }} tag: ${{ env.IMMICH_VERSION }}
push: true push: true

View File

@ -10,8 +10,27 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
pre-job:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- id: found_paths
uses: dorny/paths-filter@v3
with:
filters: |
mobile:
- 'mobile/**'
- name: Check if we should force jobs to run
id: should_force
run: echo "should_force=${{ github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
mobile-dart-analyze: mobile-dart-analyze:
name: Run Dart Code Analysis name: Run Dart Code Analysis
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -10,8 +10,47 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
pre-job:
runs-on: ubuntu-latest
outputs:
should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_e2e: ${{ steps.found_paths.outputs.e2e == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_mobile: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_e2e_web: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_e2e_server_cli: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.server == 'true' || steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- id: found_paths
uses: dorny/paths-filter@v3
with:
filters: |
web:
- 'web/**'
- 'open-api/typescript-sdk/**'
server:
- 'server/**'
cli:
- 'cli/**'
- 'open-api/typescript-sdk/**'
e2e:
- 'e2e/**'
mobile:
- 'mobile/**'
machine-learning:
- 'machine-learning/**'
- name: Check if we should force jobs to run
id: should_force
run: echo "should_force=${{ github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
server-unit-tests: server-unit-tests:
name: Server name: Server
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -47,6 +86,8 @@ jobs:
cli-unit-tests: cli-unit-tests:
name: CLI name: CLI
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -86,6 +127,8 @@ jobs:
cli-unit-tests-win: cli-unit-tests-win:
name: CLI (Windows) name: CLI (Windows)
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
runs-on: windows-latest runs-on: windows-latest
defaults: defaults:
run: run:
@ -118,6 +161,8 @@ jobs:
web-unit-tests: web-unit-tests:
name: Web name: Web
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -159,13 +204,54 @@ jobs:
run: npm run test:cov run: npm run test:cov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
e2e-tests: e2e-tests-lint:
name: End-to-End Tests name: End-to-End Lint
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: './e2e/.nvmrc'
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
if: ${{ !cancelled() }}
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
if: ${{ !cancelled() }}
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
runs-on: mich
defaults:
run:
working-directory: ./e2e
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -191,16 +277,41 @@ jobs:
run: npm ci run: npm ci
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run linter - name: Docker build
run: npm run lint run: docker compose build
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run e2e tests (api & cli)
run: npm run format run: npm run test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc e2e-tests-web:
run: npm run check name: End-to-End Tests (Web)
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
runs-on: mich
defaults:
run:
working-directory: ./e2e
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: './e2e/.nvmrc'
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install Playwright Browsers - name: Install Playwright Browsers
@ -211,16 +322,14 @@ jobs:
run: docker compose build run: docker compose build
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
run: npm run test
if: ${{ !cancelled() }}
- name: Run e2e tests (web) - name: Run e2e tests (web)
run: npx playwright test run: npx playwright test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
mobile-unit-tests: mobile-unit-tests:
name: Mobile name: Mobile
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -235,6 +344,8 @@ jobs:
ml-unit-tests: ml-unit-tests:
name: Machine Learning name: Machine Learning
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:

2
.gitmodules vendored
View File

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

View File

@ -1 +0,0 @@
/dist

View File

@ -1,28 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'],
root: true,
env: {
node: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-process-exit': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
},
};

View File

@ -1 +1 @@
20.15.1 20.17.0

View File

@ -1,4 +1,4 @@
FROM node:20.16.0-alpine3.20@sha256:aada767bf3e4b4a1437642b81db7d8bb99a6dba27627088e4608772f1f02ebc0 as core FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS core
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

View File

@ -4,8 +4,18 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma
# For developers # For developers
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
$ npm install
$ npm run build
Then, to build the open-api client run the following in the open-api folder:
$ ./bin/generate-open-api.sh
To run the Immich CLI from source, run the following in the cli folder: To run the Immich CLI from source, run the following in the cli folder:
$ npm install
$ npm run build $ npm run build
$ ts-node . $ ts-node .
@ -17,3 +27,4 @@ You can also build and install the CLI using
$ npm run build $ npm run build
$ npm install -g . $ npm install -g .
****

61
cli/eslint.config.mjs Normal file
View File

@ -0,0 +1,61 @@
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: ['eslint.config.mjs', 'dist'],
},
...compat.extends(
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:unicorn/recommended',
),
{
plugins: {
'@typescript-eslint': typescriptEslint,
},
languageOptions: {
globals: {
...globals.node,
},
parser: tsParser,
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-process-exit': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
'object-shorthand': ['error', 'always'],
},
},
];

1813
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.11", "version": "2.2.17",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@ -13,30 +13,33 @@
"cli" "cli"
], ],
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^20.14.12", "@types/node": "^20.16.2",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^7.0.0", "@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^1.2.2", "@vitest/coverage-v8": "^2.0.5",
"byte-size": "^8.1.1", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"eslint": "^8.56.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^54.0.0", "eslint-plugin-unicorn": "^55.0.0",
"globals": "^15.9.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.12", "vite": "^5.0.12",
"vite-tsconfig-paths": "^4.3.2", "vite-tsconfig-paths": "^5.0.0",
"vitest": "^1.2.2", "vitest": "^2.0.5",
"vitest-fetch-mock": "^0.2.2", "vitest-fetch-mock": "^0.3.0",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
"scripts": { "scripts": {
@ -64,6 +67,6 @@
"lodash-es": "^4.17.21" "lodash-es": "^4.17.21"
}, },
"volta": { "volta": {
"node": "20.15.1" "node": "20.17.0"
} }
} }

View File

@ -2,37 +2,37 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.37.0" version = "4.40.0"
constraints = "4.37.0" constraints = "4.40.0"
hashes = [ hashes = [
"h1:0gOI8arnh2CTcHfGH8iwAe6qz2BRSytmbOiNXZjnrHc=", "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=",
"h1:0h0qRJYPHL92Dx3NYZO2WJ21cxyZGEoldzw9aYhPnew=", "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=",
"h1:6ri7vZ1MLtQbooicIO4catyIuRq4LHAsIcgd3vGq3AE=", "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=",
"h1:7BwVaqxSD9VsmLzs6jDJBJvHPq0dz4I8rCeJAK63Dc4=", "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=",
"h1:8tVm+BJvzI14pRbEyt00AvH6oIyqiLRZQ9KxcBeSDhE=", "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=",
"h1:FTll1M9rPA7RxEyLB6etQqaqynWWl3WkiwJtHMjPr3Y=", "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=",
"h1:L7ysGftn0fstXMjCt3/XEz2giRdEwBsGrdvi4Zw8uzM=", "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=",
"h1:PsbAKy7LdSpwZMJZ7bO3lI04hLDTlXke/LCkrKXYwwE=", "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=",
"h1:Sjkpr8CKs0rXGcdis5q4Kbqmo5mmosgirnQi65G4sM8=", "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=",
"h1:YxJRQdVSzMZR5Ce5M3Gs1SPutXpednxuRwtSSiReHDY=", "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=",
"h1:bJrJeBKWEwt4hGQ+3VJR69dsqHORovE8LzuQt9+NTug=", "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=",
"h1:hPC7Vk0ZGXCDJ1y5dOepVo1c0PoUulnJUarrMv4gQIQ=", "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=",
"h1:joMURZCLUJ2eSlj645xqHWKYbRBYqvajCkhaz7qzi8g=", "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=",
"h1:uqo0WgG5lCcG8+gf99VnsKKbJMM1urNZq1FbAT6u3S0=", "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=",
"zh:012a6c3e8bf4aca0ebe0884e15bd42fd018659193f2159d5d2bf9948a9be1bc4", "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd",
"zh:079666c0a079237af46ed19ffc4143655ee0e8920a274868e44fbc3db88f346d", "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f",
"zh:08e7ff86f6848f3109d59ad46f8c0987178eff2f70c8ef03f2d44ae68e42dfb3", "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396",
"zh:1ce8a499fdf8f484f7d18ec91566bc0759b07d0ca710990cd60d32b222e416b1", "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb",
"zh:348e72338095bffccf7c46c7e6b9d0e063a22d9ae761061b0b31dea1aad22cd9", "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c",
"zh:47d39343dea1ef469a2c8e51c8d5993687af427a132da5379796fec27acb5710", "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2",
"zh:4cdf8e9579f9af3c72270088fc6e22208f0f91fd4382bc4a860d16040c86917b", "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce",
"zh:4fbebb21ecebc7e5ac0ea9e341c5dbea3094fc0579e4dc5b40bfe693164e022e", "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4",
"zh:778578dda7dd98576a3fe228132c8b60f646f4cf113638c94f1c40e2b11c027c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:894071f0f42571f820918d1a4316704923e29c5b2392704c1cbd063a04a641b8", "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f",
"zh:8d11dd73dd499c74d89f77a7e1b3d4a077ac88b0c9c3412e9a6a1b4efe17d107", "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d",
"zh:991e088be8381a73872cd33bb659e9dd69d7ab1f1f8d89b3cd17ffe59dffc65f", "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62",
"zh:9c0848b9c7e6799c9ffcf3afa70ad94a027f3e15a94679d56790714de0b072c5", "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e",
"zh:ad71ae800065ffc24b94d994250136ae8a9f6da704cf91b0dc9e14989e947369", "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6",
"zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e",
] ]
} }

View File

@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.37.0" version = "4.40.0"
} }
} }
} }

View File

@ -9,6 +9,6 @@ resource "cloudflare_record" "immich_app_release_domain" {
proxied = true proxied = true
ttl = 1 ttl = 1
type = "CNAME" type = "CNAME"
value = data.terraform_remote_state.cloudflare_immich_app_docs.outputs.immich_app_branch_pages_hostname content = 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 zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
} }

View File

@ -2,37 +2,37 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.37.0" version = "4.40.0"
constraints = "4.37.0" constraints = "4.40.0"
hashes = [ hashes = [
"h1:0gOI8arnh2CTcHfGH8iwAe6qz2BRSytmbOiNXZjnrHc=", "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=",
"h1:0h0qRJYPHL92Dx3NYZO2WJ21cxyZGEoldzw9aYhPnew=", "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=",
"h1:6ri7vZ1MLtQbooicIO4catyIuRq4LHAsIcgd3vGq3AE=", "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=",
"h1:7BwVaqxSD9VsmLzs6jDJBJvHPq0dz4I8rCeJAK63Dc4=", "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=",
"h1:8tVm+BJvzI14pRbEyt00AvH6oIyqiLRZQ9KxcBeSDhE=", "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=",
"h1:FTll1M9rPA7RxEyLB6etQqaqynWWl3WkiwJtHMjPr3Y=", "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=",
"h1:L7ysGftn0fstXMjCt3/XEz2giRdEwBsGrdvi4Zw8uzM=", "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=",
"h1:PsbAKy7LdSpwZMJZ7bO3lI04hLDTlXke/LCkrKXYwwE=", "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=",
"h1:Sjkpr8CKs0rXGcdis5q4Kbqmo5mmosgirnQi65G4sM8=", "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=",
"h1:YxJRQdVSzMZR5Ce5M3Gs1SPutXpednxuRwtSSiReHDY=", "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=",
"h1:bJrJeBKWEwt4hGQ+3VJR69dsqHORovE8LzuQt9+NTug=", "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=",
"h1:hPC7Vk0ZGXCDJ1y5dOepVo1c0PoUulnJUarrMv4gQIQ=", "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=",
"h1:joMURZCLUJ2eSlj645xqHWKYbRBYqvajCkhaz7qzi8g=", "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=",
"h1:uqo0WgG5lCcG8+gf99VnsKKbJMM1urNZq1FbAT6u3S0=", "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=",
"zh:012a6c3e8bf4aca0ebe0884e15bd42fd018659193f2159d5d2bf9948a9be1bc4", "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd",
"zh:079666c0a079237af46ed19ffc4143655ee0e8920a274868e44fbc3db88f346d", "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f",
"zh:08e7ff86f6848f3109d59ad46f8c0987178eff2f70c8ef03f2d44ae68e42dfb3", "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396",
"zh:1ce8a499fdf8f484f7d18ec91566bc0759b07d0ca710990cd60d32b222e416b1", "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb",
"zh:348e72338095bffccf7c46c7e6b9d0e063a22d9ae761061b0b31dea1aad22cd9", "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c",
"zh:47d39343dea1ef469a2c8e51c8d5993687af427a132da5379796fec27acb5710", "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2",
"zh:4cdf8e9579f9af3c72270088fc6e22208f0f91fd4382bc4a860d16040c86917b", "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce",
"zh:4fbebb21ecebc7e5ac0ea9e341c5dbea3094fc0579e4dc5b40bfe693164e022e", "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4",
"zh:778578dda7dd98576a3fe228132c8b60f646f4cf113638c94f1c40e2b11c027c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:894071f0f42571f820918d1a4316704923e29c5b2392704c1cbd063a04a641b8", "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f",
"zh:8d11dd73dd499c74d89f77a7e1b3d4a077ac88b0c9c3412e9a6a1b4efe17d107", "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d",
"zh:991e088be8381a73872cd33bb659e9dd69d7ab1f1f8d89b3cd17ffe59dffc65f", "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62",
"zh:9c0848b9c7e6799c9ffcf3afa70ad94a027f3e15a94679d56790714de0b072c5", "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e",
"zh:ad71ae800065ffc24b94d994250136ae8a9f6da704cf91b0dc9e14989e947369", "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6",
"zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e",
] ]
} }

View File

@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.37.0" version = "4.40.0"
} }
} }
} }

View File

@ -9,7 +9,7 @@ resource "cloudflare_record" "immich_app_branch_subdomain" {
proxied = true proxied = true
ttl = 1 ttl = 1
type = "CNAME" 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}" content = "${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 zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
} }

View File

@ -46,6 +46,8 @@ services:
depends_on: depends_on:
- redis - redis
- database - database
healthcheck:
disable: false
immich-web: immich-web:
container_name: immich_web container_name: immich_web
@ -91,6 +93,8 @@ services:
depends_on: depends_on:
- database - database
restart: unless-stopped restart: unless-stopped
healthcheck:
disable: false
redis: redis:
container_name: immich_redis container_name: immich_redis

View File

@ -21,6 +21,8 @@ services:
- redis - redis
- database - database
restart: always restart: always
healthcheck:
disable: false
immich-machine-learning: immich-machine-learning:
container_name: immich_machine_learning container_name: immich_machine_learning
@ -40,6 +42,8 @@ services:
env_file: env_file:
- .env - .env
restart: always restart: always
healthcheck:
disable: false
redis: redis:
container_name: immich_redis container_name: immich_redis
@ -67,7 +71,7 @@ services:
interval: 5m interval: 5m
start_interval: 30s start_interval: 30s
start_period: 5m 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"] 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 restart: always
# set IMMICH_METRICS=true in .env to enable metrics # set IMMICH_METRICS=true in .env to enable metrics
@ -75,7 +79,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:f20d3127bf2876f4a1df76246fca576b41ddf1125ed1c546fbd8b16ea55117e6 image: prom/prometheus@sha256:f6639335d34a77d9d9db382b92eeb7fc00934be8eae81dbc03b31cfe90411a94
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
@ -87,7 +91,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:11.1.0-ubuntu@sha256:c7fc29ec783d5e7fc1bdfaad6f92345a345cffbc5d21c388ca228175006fc107 image: grafana/grafana:11.2.0-ubuntu@sha256:8e2c13739563c3da9d45de96c6bcb63ba617cac8c571c060112c7fc8ad6914e9
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@ -16,6 +16,7 @@ services:
# file: hwaccel.transcoding.yml # file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes: volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
env_file: env_file:
@ -26,6 +27,8 @@ services:
- redis - redis
- database - database
restart: always restart: always
healthcheck:
disable: false
immich-machine-learning: immich-machine-learning:
container_name: immich_machine_learning container_name: immich_machine_learning
@ -40,10 +43,12 @@ services:
env_file: env_file:
- .env - .env
restart: always restart: always
healthcheck:
disable: false
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:328fe6a5822256d065debb36617a8169dbfbd77b797c525288e465f56c1d392b image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@ -57,13 +62,14 @@ services:
POSTGRES_DB: ${DB_DATABASE_NAME} POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums' POSTGRES_INITDB_ARGS: '--data-checksums'
volumes: volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
healthcheck: healthcheck:
test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
interval: 5m interval: 5m
start_interval: 30s start_interval: 30s
start_period: 5m 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"] 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 restart: always
volumes: volumes:

View File

@ -12,6 +12,7 @@ DB_DATA_LOCATION=./postgres
IMMICH_VERSION=release IMMICH_VERSION=release
# Connection secret for postgres. You should change it to a random password # Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres DB_PASSWORD=postgres
# The values below this line do not need to be changed # The values below this line do not need to be changed

View File

@ -1 +1 @@
20.15.1 20.17.0

View File

@ -52,14 +52,25 @@ On iOS (iPhone and iPad), the operating system determines if a particular app ca
- Disable Background App Refresh for apps that don't need background tasks to run. This will reduce the competition for background task invocation for Immich. - Disable Background App Refresh for apps that don't need background tasks to run. This will reduce the competition for background task invocation for Immich.
- Use the Immich app more often. - Use the Immich app more often.
### Why are features not working with a self-signed cert or mTLS?
Due to limitations in the upstream app/video library, using a self-signed TLS certificate or mutual TLS may break video playback or asset upload (both foreground and/or background).
We recommend using a real SSL certificate from a free provider, for example [Let's Encrypt](https://letsencrypt.org/).
--- ---
## Assets ## Assets
### Does Immich change the file? ### Does Immich change the file?
No, Immich does not touch the original file under any circumstances, No, Immich does not modify the original files.
all edited metadata are saved in the companion sidecar file and the database. All edited metadata is saved in companion `.xmp` sidecar files and the database.
However, Immich will delete original files that have been trashed when the trash is emptied in the Immich UI.
### Why do my file names appear as a random string in the file manager?
When Storage Template is off (default) Immich saves the file names in a random string (also known as random UUIDs) to prevent duplicate file names. To retrieve the original file names, you must enable the Storage Template and then run the STORAGE TEMPLATE MIGRATION job.
It is recommended to read about [Storage Template](https://immich.app/docs/administration/storage-template) before activation.
### Can I add my existing photo library? ### Can I add my existing photo library?
@ -157,6 +168,19 @@ We haven't implemented an official mechanism for creating albums from external l
Duplicate checking only exists for upload libraries, using the file hash. Furthermore, duplicate checking is not global, but _per library_. Therefore, a situation where the same file appears twice in the timeline is possible, especially for external libraries. Duplicate checking only exists for upload libraries, using the file hash. Furthermore, duplicate checking is not global, but _per library_. Therefore, a situation where the same file appears twice in the timeline is possible, especially for external libraries.
### Why are my edits to files not being saved in read-only external libraries?
Images in read-write external libraries (the default) can be edited as normal.
In read-only libraries (`:ro` in the `docker-compose.yml`), Immich is unable to create the `.xmp` sidecar files to store edited file metadata.
For this reason, the metadata (timestamp, location, description, star rating, etc.) cannot be edited for files in read-only external libraries.
### How are deletions of files handled in external libraries?
Immich will attempt to delete original files that have been trashed when the trash is emptied.
In read-write external libraries (the default), Immich will delete the original file.
In read-only libraries (`:ro` in the `docker-compose.yml`), files can still be trashed in the UI.
However, when the trash is emptied, the files will re-appear in the main timeline since Immich is unable to delete the original file.
--- ---
## Machine Learning ## Machine Learning
@ -294,6 +318,12 @@ You need to enable WebSockets on your reverse proxy.
Immich components are typically deployed using docker. To see logs for deployed docker containers, you can use the [Docker CLI](https://docs.docker.com/engine/reference/commandline/cli/), specifically the `docker logs` command. For examples, see [Docker Help](/docs/guides/docker-help.md). Immich components are typically deployed using docker. To see logs for deployed docker containers, you can use the [Docker CLI](https://docs.docker.com/engine/reference/commandline/cli/), specifically the `docker logs` command. For examples, see [Docker Help](/docs/guides/docker-help.md).
### How can I reduce the log verbosity of Redis?
To decrease Redis logs, you can add the following line to the `redis:` section of the `docker-compose.yml`:
` command: redis-server --loglevel warning`
### How can I run Immich as a non-root user? ### How can I run Immich as a non-root user?
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service. You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.

View File

@ -45,7 +45,7 @@ docker compose up -d # Start remainder of Immich apps
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)"> <TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
```powershell title='Backup' ```powershell title='Backup'
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "\path\to\backup\dump.sql" docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | Set-Content -Encoding utf8 "C:\path\to\backup\dump.sql"
``` ```
```powershell title='Restore' ```powershell title='Restore'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -52,4 +52,4 @@ Additionally, some jobs run on a schedule, which is every night at midnight. Thi
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. 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" /> <img src={require('./img/admin-jobs.webp').default} width="60%" title="Admin jobs" />

View File

@ -3,7 +3,7 @@
This page contains details about using OAuth in Immich. This page contains details about using OAuth in Immich.
:::tip :::tip
Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution. Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution.
::: :::
## Overview ## Overview
@ -30,7 +30,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured
The **Sign-in redirect URIs** should include: The **Sign-in redirect URIs** should include:
- `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx) - `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client - `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client - `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
@ -38,7 +38,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured
Mobile Mobile
- `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly) - `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly)
Localhost Localhost
@ -96,16 +96,16 @@ When Auto Launch is enabled, the login page will automatically redirect the user
## Mobile Redirect URI ## Mobile Redirect URI
The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: The redirect URI for the mobile app is `app.immich:///oauth-callback`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following:
1. Configure an http(s) endpoint to forwards requests to `app.immich:/` 1. Configure an http(s) endpoint to forwards requests to `app.immich:///oauth-callback`
2. Whitelist the new endpoint as a valid redirect URI with your provider. 2. Whitelist the new endpoint as a valid redirect URI with your provider.
3. Specify the new endpoint as the `Mobile Redirect URI Override`, in the OAuth settings. 3. Specify the new endpoint as the `Mobile Redirect URI Override`, in the OAuth settings.
With these steps in place, you should be able to use OAuth from the [Mobile App](/docs/features/mobile-app.mdx) without a custom scheme redirect URI. With these steps in place, you should be able to use OAuth from the [Mobile App](/docs/features/mobile-app.mdx) without a custom scheme redirect URI.
:::info :::info
Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:/`, and can be used for step 1. Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:///oauth-callback`, and can be used for step 1.
::: :::
## Example Configuration ## Example Configuration
@ -154,21 +154,21 @@ Configuration of Authorised redirect URIs (Google Console)
Configuration of OAuth in Immich System Settings Configuration of OAuth in Immich System Settings
| Setting | Value | | Setting | Value |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ | | ---------------------------- | ---------------------------------------------------------------------------- |
| Issuer URL | [https://accounts.google.com](https://accounts.google.com) | | Issuer URL | `https://accounts.google.com` |
| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | | Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com |
| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | | Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO |
| Scope | openid email profile | | Scope | openid email profile |
| Signing Algorithm | RS256 | | Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username | | Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota | | Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | | Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Button Text | Sign in with Google (optional) | | Button Text | Sign in with Google (optional) |
| Auto Register | Enabled (optional) | | Auto Register | Enabled (optional) |
| Auto Launch | Enabled | | Auto Launch | Enabled |
| Mobile Redirect URI Override | Enabled (required) | | 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) | | Mobile Redirect URI | `https://example.immich.app/api/oauth/mobile-redirect` |
</details> </details>

View File

@ -104,7 +104,7 @@ You can choose to disable a certain type of machine learning, for example smart
### Smart Search ### Smart Search
The smart search settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the The [smart search](/docs/features/smart-search) settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the
Smart Search job on all images to fully apply the change. Smart Search job on all images to fully apply the change.
:::info Internet connection :::info Internet connection
@ -113,15 +113,23 @@ After downloading, there is no need for Immich to connect to the network
Unless version checking has been enabled in the settings. Unless version checking has been enabled in the settings.
::: :::
### Duplicate Detection
Use CLIP embeddings to find likely duplicates. The maximum detection distance can be configured in order to improve / reduce the level of accuracy.
- **Maximum detection distance -** Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives.
### Facial Recognition ### Facial Recognition
Under these settings, you can change the facial recognition settings Under these settings, you can change the facial recognition settings
Editable settings: Editable settings:
- **Facial Recognition Model -** Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model. - **Facial Recognition Model**
- **Min Detection Score -** Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives. - **Min Detection Score**
- **Max Recognition Distance -** Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible. - **Max Recognition Distance**
- **Min Recognized Faces -** The minimum number of recognized faces for a person to be created (AKA: Core face). Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person. - **Min Recognized Faces**
You can learn more about these options on the [Facial Recognition page](/docs/features/facial-recognition#how-face-detection-works)
:::info :::info
When changing the values in Min Detection Score, Max Recognition Distance, and Min Recognized Faces. When changing the values in Min Detection Score, Max Recognition Distance, and Min Recognized Faces.
@ -153,7 +161,7 @@ SMTP server setup, for user creation notifications, new albums, etc. More inform
### External Domain ### External Domain
When set, will override the domain name used when viewing and copying a shared link. Overrides the domain name in shared links and email notifications. The URL should not include a trailing slash.
### Welcome Message ### Welcome Message

View File

@ -4,7 +4,8 @@
### Unit tests ### Unit tests
Unit are run by calling `npm run test` from the `server` directory. Unit are run by calling `npm run test` from the `server/` directory.
You need to run `npm install` (in `server/`) before _once_.
### End to end tests ### End to end tests
@ -14,6 +15,11 @@ The e2e tests can be run by first starting up a test production environment via:
make e2e make e2e
``` ```
Before you can run the tests, you need to run the following commands _once_:
- `npm install` (in `e2e/`)
- `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via: Once the test environment is running, the e2e tests can be run via:
```bash ```bash

View File

@ -104,15 +104,16 @@ The `immich-server` container will need access to the gallery. Modify your docke
immich-server: immich-server:
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/nas/christmas-trip:/mnt/nas/christmas-trip:ro + - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/home/user/old-pics:ro + - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos:ro + - /mnt/media/videos:/mnt/media/videos:ro
+ - /mnt/media/videos2:/mnt/media/videos2 # the files in this folder can be deleted, as it does not end with :ro + - /mnt/media/videos2:/mnt/media/videos2 # the files in this folder can be deleted, as it does not end with :ro
+ - "C:/Users/user_name/Desktop/my media:/mnt/media/my-media:ro" # import path in Windows system. + - "C:/Users/user_name/Desktop/my media:/mnt/media/my-media:ro" # import path in Windows system.
``` ```
:::tip :::tip
The `ro` flag at the end only gives read-only access to the volumes. This will disallow the images from being deleted in the web UI. The `ro` flag at the end only gives read-only access to the volumes.
This will disallow the images from being deleted in the web UI, or adding metadata to the library ([XMP sidecars](/docs/features/xmp-sidecars)).
::: :::
:::info :::info

View File

@ -16,7 +16,7 @@ When sharing shared albums, whats shared is:
- Download all assets as zip file (Web only). - Download all assets as zip file (Web only).
:::info Archive size limited. :::info Archive size limited.
If the size of the album exceeds 4GB, the archive files will be divided into 4GB each. If the size of the album exceeds 4GB, the archive files will by default be divided into 4GB each. This can be changed on the user settings page.
::: :::
- Add a description to the album (Web only). - Add a description to the album (Web only).
- Slideshow view (Web only). - Slideshow view (Web only).
@ -73,14 +73,14 @@ You can edit the link properties, options include;
- **Allow public user to download -** whether to allow whoever has the link to download all the images or a certain image (optional). - **Allow public user to download -** whether to allow whoever has the link to download all the images or a certain image (optional).
- **Allow public user to upload -** whether to allow whoever has the link to upload assets to the album (optional). - **Allow public user to upload -** whether to allow whoever has the link to upload assets to the album (optional).
:::info :::info
whoever has the link and have uploaded files cannot delete them. Whoever has the link and have uploaded files cannot delete them.
::: :::
- **Expire after -** adding an expiration date to the link (optional). - **Expire after -** adding an expiration date to the link (optional).
## Share Specific Assets ## Share Specific Assets
A user can share specific assets without linking them to a specific album. A user can share specific assets without linking them to a specific album.
in order to do so; In order to do this:
1. Go to the timeline 1. Go to the timeline
2. Select the assets (Shift can be used for multiple selection) 2. Select the assets (Shift can be used for multiple selection)
@ -152,7 +152,7 @@ Some of the features are not available on mobile, to understand what the full fe
## Sharing Between Users ## Sharing Between Users
#### Add or remove users from the album. #### Add or remove users from the album
:::info remove user(s) :::info remove user(s)
When a user is removed from the album, the photos he uploaded will still appear in the album. When a user is removed from the album, the photos he uploaded will still appear in the album.

View File

@ -13,14 +13,14 @@ In our `.env` file, we will define variables that will help us in the future whe
# Custom location where your uploaded, thumbnails, and transcoded video files are stored # Custom location where your uploaded, thumbnails, and transcoded video files are stored
- UPLOAD_LOCATION=./library - UPLOAD_LOCATION=./library
+ UPLOAD_LOCATION=/custom/location/on/your/system/immich/immich_files + UPLOAD_LOCATION=/custom/path/immich/immich_files
+ THUMB_LOCATION=/custom/location/on/your/system/immich/thumbs + THUMB_LOCATION=/custom/path/immich/thumbs
+ ENCODED_VIDEO_LOCATION=/custom/location/on/your/system/immich/encoded-video + ENCODED_VIDEO_LOCATION=/custom/path/immich/encoded-video
+ PROFILE_LOCATION=/custom/location/on/your/system/immich/profile + PROFILE_LOCATION=/custom/path/immich/profile
... ...
``` ```
After defining the locations for these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` and `immich-microservices` containers. After defining the locations for these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` container.
```diff title="docker-compose.yml" ```diff title="docker-compose.yml"
services: services:
@ -29,16 +29,6 @@ services:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs + - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video + - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video
+ - ${PROFILE_LOCATION}:/usr/src/app/upload/profile
- /etc/localtime:/etc/localtime:ro
...
immich-microservices:
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video
+ - ${PROFILE_LOCATION}:/usr/src/app/upload/profile + - ${PROFILE_LOCATION}:/usr/src/app/upload/profile
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
``` ```
@ -46,7 +36,6 @@ services:
Restart Immich to register the changes. Restart Immich to register the changes.
``` ```
docker compose down
docker compose up -d docker compose up -d
``` ```

View File

@ -1,8 +1,22 @@
# Create Custom Map Styles for Immich Using Maptiler # Custom Map Styles
You may decide that you'd like to modify the style document which is used to draw the maps in Immich. This can be done easily using Maptiler, if you do not want to write an entire JSON document by hand. You may decide that you'd like to modify the style document which is used to
draw the maps in Immich. In addition to visual customization, this also allows
you to pick your own map tile provider instead of the default one. The default
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
can be used as a basis for creating your own style.
## Steps There are several sources for already-made `style.json` map themes, as well as
online generators you can use.
1. In **Immich**, navigate to **Administration --> Settings --> Map & GPS Settings** and expand the **Map Settings** subsection.
2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.)
3. Save your selections. Reload the map, and enjoy your custom map style!
## Use Maptiler to build a custom style
Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand.
1. Create a free account at https://cloud.maptiler.com 1. Create a free account at https://cloud.maptiler.com
2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there. 2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there.
@ -11,6 +25,3 @@ You may decide that you'd like to modify the style document which is used to dra
5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>![Maptiler Publication Settings](img/immich_map_styles_publish.png) 5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>![Maptiler Publication Settings](img/immich_map_styles_publish.png)
6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay. 6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler. 7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler.
8. In **Immich**, navigate to **Administration --> Settings --> Map & GPS Settings** and expand the **Map Settings** subsection.
9. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.
10. Save your selections. Reload the map, and enjoy your custom map style!

View File

@ -5,7 +5,7 @@ Keep in mind that mucking around in the database might set the moon on fire. Avo
::: :::
:::tip :::tip
Run `docker exec -it immich_postgres psql immich <DB_USERNAME>` to connect to the database via the container directly. Run `docker exec -it immich_postgres psql --dbname=immich --username=<DB_USERNAME>` to connect to the database via the container directly.
(Replace `<DB_USERNAME>` with the value from your [`.env` file](/docs/install/environment-variables#database)). (Replace `<DB_USERNAME>` with the value from your [`.env` file](/docs/install/environment-variables#database)).
::: :::
@ -23,7 +23,7 @@ SELECT * FROM "assets" WHERE "originalFileName" LIKE '%_2023_%'; -- all files wi
``` ```
```sql title="Find by path" ```sql title="Find by path"
SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_20230903_232542848.jpg'; SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_2023.jpg';
SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
``` ```
@ -37,6 +37,12 @@ SELECT * FROM "assets" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e
SELECT * FROM "assets" WHERE "checksum" = '\x69de19c87658c4c15d9cacb9967b8e033bf74dd1'; -- alternate notation SELECT * FROM "assets" WHERE "checksum" = '\x69de19c87658c4c15d9cacb9967b8e033bf74dd1'; -- alternate notation
``` ```
```sql title="Find duplicate assets with identical checksum (SHA-1) (excluding trashed files)"
SELECT T1."checksum", array_agg(T2."id") ids FROM "assets" T1
INNER JOIN "assets" T2 ON T1."checksum" = T2."checksum" AND T1."id" != T2."id" AND T2."deletedAt" IS NULL
WHERE T1."deletedAt" IS NULL GROUP BY T1."checksum";
```
```sql title="Live photos" ```sql title="Live photos"
SELECT * FROM "assets" WHERE "livePhotoVideoId" IS NOT NULL; SELECT * FROM "assets" WHERE "livePhotoVideoId" IS NOT NULL;
``` ```
@ -79,8 +85,7 @@ SELECT "assets"."type", COUNT(*) FROM "assets" GROUP BY "assets"."type";
```sql title="Count by type (per user)" ```sql title="Count by type (per user)"
SELECT "users"."email", "assets"."type", COUNT(*) FROM "assets" SELECT "users"."email", "assets"."type", COUNT(*) FROM "assets"
JOIN "users" ON "assets"."ownerId" = "users"."id" JOIN "users" ON "assets"."ownerId" = "users"."id"
GROUP BY "assets"."type", "users"."email" GROUP BY "assets"."type", "users"."email" ORDER BY "users"."email";
ORDER BY "users"."email";
``` ```
```sql title="Failed file movements" ```sql title="Failed file movements"
@ -106,3 +111,9 @@ SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config';
```sql title="Delete person and unset it for the faces it was associated with" ```sql title="Delete person and unset it for the faces it was associated with"
DELETE FROM "person" WHERE "name" = 'PersonNameHere'; DELETE FROM "person" WHERE "name" = 'PersonNameHere';
``` ```
## Postgres internal
```sql title="Change DB_PASSWORD"
ALTER USER <DB_USERNAME> WITH ENCRYPTED PASSWORD 'newpasswordhere';
```

View File

@ -7,7 +7,7 @@ in a directory on the same machine.
# Mount the directory into the containers. # Mount the directory into the containers.
Edit `docker-compose.yml` to add one or more new mount points in the section `immich-server:` under `volumes:`. Edit `docker-compose.yml` to add one or more new mount points in the section `immich-server:` under `volumes:`.
If you want Immich to be able to delete the images in the external library, remove `:ro` from the end of the mount point. If you want Immich to be able to delete the images in the external library or add metadata ([XMP sidecars](/docs/features/xmp-sidecars)), remove `:ro` from the end of the mount point.
```diff ```diff
immich-server: immich-server:

View File

@ -11,13 +11,13 @@ Never forward port 2283 directly to the internet without additional configuratio
You may use a VPN service to open an encrypted connection to your Immich instance. OpenVPN and Wireguard are two popular VPN solutions. Here is a guide on setting up VPN access to your server - [Pihole documentation](https://docs.pi-hole.net/guides/vpn/wireguard/overview/) You may use a VPN service to open an encrypted connection to your Immich instance. OpenVPN and Wireguard are two popular VPN solutions. Here is a guide on setting up VPN access to your server - [Pihole documentation](https://docs.pi-hole.net/guides/vpn/wireguard/overview/)
### Pros: ### Pros
- Simple to set up and very secure. - Simple to set up and very secure.
- Single point of potential failure, i.e., the VPN software itself. Even if there is a zero-day vulnerability on Immich, you will not be at risk. - Single point of potential failure, i.e., the VPN software itself. Even if there is a zero-day vulnerability on Immich, you will not be at risk.
- Both Wireguard and OpenVPN are independently security-audited, so the risk of serious zero-day exploits are minimal. - Both Wireguard and OpenVPN are independently security-audited, so the risk of serious zero-day exploits are minimal.
### Cons: ### Cons
- If you don't have a static IP address, you would need to set up a [Dynamic DNS](https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/). [DuckDNS](https://www.duckdns.org/) is a free DDNS provider. - If you don't have a static IP address, you would need to set up a [Dynamic DNS](https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/). [DuckDNS](https://www.duckdns.org/) is a free DDNS provider.
- VPN software needs to be installed and active on both server-side and client-side. - VPN software needs to be installed and active on both server-side and client-side.
@ -27,6 +27,10 @@ You may use a VPN service to open an encrypted connection to your Immich instanc
If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation).
:::tip Video toturial
You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created.
:::
### Pros ### Pros
- Minimal configuration needed on server and client sides. - Minimal configuration needed on server and client sides.
@ -44,7 +48,7 @@ A reverse proxy is a service that sits between web servers and clients. A revers
If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](/docs/administration/reverse-proxy.md). If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](/docs/administration/reverse-proxy.md).
You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accesible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser. You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accessible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser.
A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/) increases security by hiding the server IP address, which makes targeted attacks like [DDoS](https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/) harder. A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/) increases security by hiding the server IP address, which makes targeted attacks like [DDoS](https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/) harder.

View File

@ -11,6 +11,10 @@ To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-imm
Smart Search and Face Detection will use this feature, but Facial Recognition is handled in the server. Smart Search and Face Detection will use this feature, but Facial Recognition is handled in the server.
::: :::
:::danger
When using remote machine learning, the thumbnails are sent to the remote machine learning container. Use this option carefully when running this on a public computer or a paid processing cloud.
:::
```yaml ```yaml
name: immich_remote_ml name: immich_remote_ml

View File

@ -56,7 +56,8 @@ Optionally, you can enable hardware acceleration for machine learning and transc
- Populate custom database information if necessary. - Populate custom database information if necessary.
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
- Consider changing `DB_PASSWORD` to something randomly generated - Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication.
To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`.
### Step 3 - Start the containers ### Step 3 - Start the containers
@ -108,7 +109,7 @@ Immich is currently under heavy development, which means you can expect [breakin
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml [compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env [env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
[watchtower]: https://containrrr.dev/watchtower/ [watchtower]: https://containrrr.dev/watchtower/
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Abreaking-change+sort%3Adate_created [breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry [container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry
[releases]: https://github.com/immich-app/immich/releases [releases]: https://github.com/immich-app/immich/releases
[docker-repo]: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository [docker-repo]: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository

View File

@ -38,21 +38,23 @@ Regardless of filesystem, it is not recommended to use a network share for your
## General ## General
| Variable | Description | Default | Containers | Workers | | Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :-------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | | :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | | server | microservices | | `TZ` | Timezone | | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, 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_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_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**<sup>\*1</sup>⚠️ | `./upload`<sup>\*2</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | | | `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api | | `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | | `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | | `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api |
\*1: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. \*1: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead.
\*2: 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. It only need to be set if the Immich deployment method is changing.
:::tip :::tip
@ -157,26 +159,29 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning ## Machine Learning
| Variable | Description | Default | Containers | | Variable | Description | Default | Containers |
| :----------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------------: | :--------------- | | :-------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | | `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 | | `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | | `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | | `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | | `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | | `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning | | `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO image) | machine learning | | `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning | | `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO image) | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | | `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
\*2: Since each process duplicates models in memory, changing this is not recommended unless you have abundant memory to go around. \*2: Since each process duplicates models in memory, changing this is not recommended unless you have abundant memory to go around.
\*3: For scenarios like HPA in K8S. https://github.com/immich-app/immich/discussions/12064
:::info :::info
Other machine learning parameters can be tuned from the admin UI. Other machine learning parameters can be tuned from the admin UI.

View File

@ -4,7 +4,7 @@ sidebar_position: 10
# Requirements # Requirements
Hardware and software requirements for Immich Hardware and software requirements for Immich:
## Software ## Software

View File

@ -45,7 +45,7 @@ width="70%"
alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
/> />
3. Select the cog ⚙️ next to Immich then click "**Edit Stack**" 3. Select the cogwheel ⚙️ next to Immich and click "**Edit Stack**"
4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor. Remove any text that may be in the text area by default. Note that Unraid v6.12.10 uses version 24.0.9 of the Docker Engine, which does not support healthcheck `start_interval` as defined in the `database` service of the Docker compose file (version 25 or higher is needed). This parameter defines an initial waiting period before starting health checks, to give the container time to start up. Commenting out the `start_interval` and `start_period` parameters will allow the containers to start up normally. The only downside to this is that the database container will not receive an initial health check until `interval` time has passed. 4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor. Remove any text that may be in the text area by default. Note that Unraid v6.12.10 uses version 24.0.9 of the Docker Engine, which does not support healthcheck `start_interval` as defined in the `database` service of the Docker compose file (version 25 or higher is needed). This parameter defines an initial waiting period before starting health checks, to give the container time to start up. Commenting out the `start_interval` and `start_period` parameters will allow the containers to start up normally. The only downside to this is that the database container will not receive an initial health check until `interval` time has passed.
<details > <details >
@ -130,7 +130,7 @@ For more information on how to use the application once installed, please refer
## Updating Steps ## Updating Steps
Updating is extremely easy however it's important to be aware that containers managed via the Docker Compose Manager plugin do not integrate with Unraid's native dockerman ui, the label "_update ready_" will always be present on containers installed via the Docker Compose Manager. Updating is extremely easy however it's important to be aware that containers managed via the Docker Compose Manager plugin do not integrate with Unraid's native dockerman UI, the label "_update ready_" will always be present on containers installed via the Docker Compose Manager.
<img <img
src={require('./img/unraid09.png').default} src={require('./img/unraid09.png').default}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -145,28 +145,36 @@ const config = {
label: 'Installation', label: 'Installation',
to: '/docs/install/requirements', to: '/docs/install/requirements',
}, },
{
label: 'Contributing',
to: '/docs/overview/support-the-project',
},
{
label: 'Privacy Policy',
to: '/privacy-policy',
},
], ],
}, },
{ {
title: 'Community', title: 'Documentation',
items: [ items: [
{ {
label: 'Discord', label: 'Roadmap',
href: 'https://discord.immich.app', to: '/roadmap',
}, },
{ {
label: 'Reddit', label: 'API',
href: 'https://www.reddit.com/r/immich/', to: '/docs/api',
},
{
label: 'Cursed Knowledge',
to: '/cursed-knowledge',
}, },
], ],
}, },
{ {
title: 'Links', title: 'Links',
items: [ items: [
// {
// label: "Blog",
// to: "/blog",
// },
{ {
label: 'GitHub', label: 'GitHub',
href: 'https://github.com/immich-app/immich', href: 'https://github.com/immich-app/immich',
@ -175,6 +183,14 @@ const config = {
label: 'YouTube', label: 'YouTube',
href: 'https://www.youtube.com/@immich-app', href: 'https://www.youtube.com/@immich-app',
}, },
{
label: 'Discord',
href: 'https://discord.immich.app',
},
{
label: 'Reddit',
href: 'https://www.reddit.com/r/immich/',
},
], ],
}, },
], ],

568
docs/package-lock.json generated
View File

@ -2155,9 +2155,9 @@
} }
}, },
"node_modules/@docusaurus/core": { "node_modules/@docusaurus/core": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.5.2.tgz",
"integrity": "sha512-g+0wwmN2UJsBqy2fQRQ6fhXruoEa62JDeEa5d8IdTJlMoaDaEDfHh7WjwGRn4opuTQWpjAwP/fbcgyHKlE+64w==", "integrity": "sha512-4Z1WkhCSkX4KO0Fw5m/Vuc7Q3NxBG53NE5u59Rs96fWkMPZVSrzEPP16/Nk6cWb/shK7xXPndTmalJtw7twL/w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.23.3", "@babel/core": "^7.23.3",
@ -2170,12 +2170,12 @@
"@babel/runtime": "^7.22.6", "@babel/runtime": "^7.22.6",
"@babel/runtime-corejs3": "^7.22.6", "@babel/runtime-corejs3": "^7.22.6",
"@babel/traverse": "^7.22.8", "@babel/traverse": "^7.22.8",
"@docusaurus/cssnano-preset": "3.4.0", "@docusaurus/cssnano-preset": "3.5.2",
"@docusaurus/logger": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/mdx-loader": "3.4.0", "@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-common": "3.4.0", "@docusaurus/utils-common": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"babel-loader": "^9.1.3", "babel-loader": "^9.1.3",
"babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-dynamic-import-node": "^2.3.3",
@ -2236,14 +2236,15 @@
"node": ">=18.0" "node": ">=18.0"
}, },
"peerDependencies": { "peerDependencies": {
"@mdx-js/react": "^3.0.0",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0" "react-dom": "^18.0.0"
} }
}, },
"node_modules/@docusaurus/cssnano-preset": { "node_modules/@docusaurus/cssnano-preset": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.5.2.tgz",
"integrity": "sha512-qwLFSz6v/pZHy/UP32IrprmH5ORce86BGtN0eBtG75PpzQJAzp9gefspox+s8IEOr0oZKuQ/nhzZ3xwyc3jYJQ==", "integrity": "sha512-D3KiQXOMA8+O0tqORBrTOEQyQxNIfPm9jEaJoALjjSjc2M/ZAWcUfPQEnwr2JB2TadHw2gqWgpZckQmrVWkytA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cssnano-preset-advanced": "^6.1.2", "cssnano-preset-advanced": "^6.1.2",
@ -2256,9 +2257,9 @@
} }
}, },
"node_modules/@docusaurus/logger": { "node_modules/@docusaurus/logger": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.5.2.tgz",
"integrity": "sha512-bZwkX+9SJ8lB9kVRkXw+xvHYSMGG4bpYHKGXeXFvyVc79NMeeBSGgzd4TQLHH+DYeOJoCdl8flrFJVxlZ0wo/Q==", "integrity": "sha512-LHC540SGkeLfyT3RHK3gAMK6aS5TRqOD4R72BEU/DE2M/TY8WwEUAMY576UUc/oNJXv8pGhBmQB6N9p3pt8LQw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "^4.1.2", "chalk": "^4.1.2",
@ -2269,14 +2270,14 @@
} }
}, },
"node_modules/@docusaurus/mdx-loader": { "node_modules/@docusaurus/mdx-loader": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.5.2.tgz",
"integrity": "sha512-kSSbrrk4nTjf4d+wtBA9H+FGauf2gCax89kV8SUSJu3qaTdSIKdWERlngsiHaCFgZ7laTJ8a67UFf+xlFPtuTw==", "integrity": "sha512-ku3xO9vZdwpiMIVd8BzWV0DCqGEbCP5zs1iHfKX50vw6jX8vQo0ylYo1YJMZyz6e+JFJ17HYHT5FzVidz2IflA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/logger": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"@mdx-js/mdx": "^3.0.0", "@mdx-js/mdx": "^3.0.0",
"@slorber/remark-comment": "^1.0.0", "@slorber/remark-comment": "^1.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
@ -2308,12 +2309,12 @@
} }
}, },
"node_modules/@docusaurus/module-type-aliases": { "node_modules/@docusaurus/module-type-aliases": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.5.2.tgz",
"integrity": "sha512-A1AyS8WF5Bkjnb8s+guTDuYmUiwJzNrtchebBHpc0gz0PyHJNMaybUlSrmJjHVcGrya0LKI4YcR3lBDQfXRYLw==", "integrity": "sha512-Z+Xu3+2rvKef/YKTMxZHsEXp1y92ac0ngjDiExRdqGTmEKtCUpkbNYH8v5eXo5Ls+dnW88n6WTa+Q54kLOkwPg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/types": "3.4.0", "@docusaurus/types": "3.5.2",
"@types/history": "^4.7.11", "@types/history": "^4.7.11",
"@types/react": "*", "@types/react": "*",
"@types/react-router-config": "*", "@types/react-router-config": "*",
@ -2326,52 +2327,21 @@
"react-dom": "*" "react-dom": "*"
} }
}, },
"node_modules/@docusaurus/plugin-content-blog": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.4.0.tgz",
"integrity": "sha512-vv6ZAj78ibR5Jh7XBUT4ndIjmlAxkijM3Sx5MAAzC1gyv0vupDQNhzuFg1USQmQVj3P5I6bquk12etPV3LJ+Xw==",
"license": "MIT",
"dependencies": {
"@docusaurus/core": "3.4.0",
"@docusaurus/logger": "3.4.0",
"@docusaurus/mdx-loader": "3.4.0",
"@docusaurus/types": "3.4.0",
"@docusaurus/utils": "3.4.0",
"@docusaurus/utils-common": "3.4.0",
"@docusaurus/utils-validation": "3.4.0",
"cheerio": "^1.0.0-rc.12",
"feed": "^4.2.2",
"fs-extra": "^11.1.1",
"lodash": "^4.17.21",
"reading-time": "^1.5.0",
"srcset": "^4.0.0",
"tslib": "^2.6.0",
"unist-util-visit": "^5.0.0",
"utility-types": "^3.10.0",
"webpack": "^5.88.1"
},
"engines": {
"node": ">=18.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@docusaurus/plugin-content-docs": { "node_modules/@docusaurus/plugin-content-docs": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.5.2.tgz",
"integrity": "sha512-HkUCZffhBo7ocYheD9oZvMcDloRnGhBMOZRyVcAQRFmZPmNqSyISlXA1tQCIxW+r478fty97XXAGjNYzBjpCsg==", "integrity": "sha512-Bt+OXn/CPtVqM3Di44vHjE7rPCEsRCB/DMo2qoOuozB9f7+lsdrHvD0QCHdBs0uhz6deYJDppAr2VgqybKPlVQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/logger": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/mdx-loader": "3.4.0", "@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/module-type-aliases": "3.4.0", "@docusaurus/module-type-aliases": "3.5.2",
"@docusaurus/types": "3.4.0", "@docusaurus/theme-common": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils-common": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-common": "3.5.2",
"@docusaurus/utils-validation": "3.5.2",
"@types/react-router-config": "^5.0.7", "@types/react-router-config": "^5.0.7",
"combine-promises": "^1.1.0", "combine-promises": "^1.1.0",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
@ -2389,38 +2359,15 @@
"react-dom": "^18.0.0" "react-dom": "^18.0.0"
} }
}, },
"node_modules/@docusaurus/plugin-content-pages": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.4.0.tgz",
"integrity": "sha512-h2+VN/0JjpR8fIkDEAoadNjfR3oLzB+v1qSXbIAKjQ46JAHx3X22n9nqS+BWSQnTnp1AjkjSvZyJMekmcwxzxg==",
"license": "MIT",
"dependencies": {
"@docusaurus/core": "3.4.0",
"@docusaurus/mdx-loader": "3.4.0",
"@docusaurus/types": "3.4.0",
"@docusaurus/utils": "3.4.0",
"@docusaurus/utils-validation": "3.4.0",
"fs-extra": "^11.1.1",
"tslib": "^2.6.0",
"webpack": "^5.88.1"
},
"engines": {
"node": ">=18.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@docusaurus/plugin-debug": { "node_modules/@docusaurus/plugin-debug": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.5.2.tgz",
"integrity": "sha512-uV7FDUNXGyDSD3PwUaf5YijX91T5/H9SX4ErEcshzwgzWwBtK37nUWPU3ZLJfeTavX3fycTOqk9TglpOLaWkCg==", "integrity": "sha512-kBK6GlN0itCkrmHuCS6aX1wmoWc5wpd5KJlqQ1FyrF0cLDnvsYSnh7+ftdwzt7G6lGBho8lrVwkkL9/iQvaSOA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/types": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"react-json-view-lite": "^1.2.0", "react-json-view-lite": "^1.2.0",
"tslib": "^2.6.0" "tslib": "^2.6.0"
@ -2434,14 +2381,14 @@
} }
}, },
"node_modules/@docusaurus/plugin-google-analytics": { "node_modules/@docusaurus/plugin-google-analytics": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.5.2.tgz",
"integrity": "sha512-mCArluxEGi3cmYHqsgpGGt3IyLCrFBxPsxNZ56Mpur0xSlInnIHoeLDH7FvVVcPJRPSQ9/MfRqLsainRw+BojA==", "integrity": "sha512-rjEkJH/tJ8OXRE9bwhV2mb/WP93V441rD6XnM6MIluu7rk8qg38iSxS43ga2V2Q/2ib53PcqbDEJDG/yWQRJhQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/types": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"tslib": "^2.6.0" "tslib": "^2.6.0"
}, },
"engines": { "engines": {
@ -2453,14 +2400,14 @@
} }
}, },
"node_modules/@docusaurus/plugin-google-gtag": { "node_modules/@docusaurus/plugin-google-gtag": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.5.2.tgz",
"integrity": "sha512-Dsgg6PLAqzZw5wZ4QjUYc8Z2KqJqXxHxq3vIoyoBWiLEEfigIs7wHR+oiWUQy3Zk9MIk6JTYj7tMoQU0Jm3nqA==", "integrity": "sha512-lm8XL3xLkTPHFKKjLjEEAHUrW0SZBSHBE1I+i/tmYMBsjCcUB5UJ52geS5PSiOCFVR74tbPGcPHEV/gaaxFeSA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/types": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"@types/gtag.js": "^0.0.12", "@types/gtag.js": "^0.0.12",
"tslib": "^2.6.0" "tslib": "^2.6.0"
}, },
@ -2473,14 +2420,14 @@
} }
}, },
"node_modules/@docusaurus/plugin-google-tag-manager": { "node_modules/@docusaurus/plugin-google-tag-manager": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.5.2.tgz",
"integrity": "sha512-O9tX1BTwxIhgXpOLpFDueYA9DWk69WCbDRrjYoMQtFHSkTyE7RhNgyjSPREUWJb9i+YUg3OrsvrBYRl64FCPCQ==", "integrity": "sha512-QkpX68PMOMu10Mvgvr5CfZAzZQFx8WLlOiUQ/Qmmcl6mjGK6H21WLT5x7xDmcpCoKA/3CegsqIqBR+nA137lQg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/types": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"tslib": "^2.6.0" "tslib": "^2.6.0"
}, },
"engines": { "engines": {
@ -2492,17 +2439,17 @@
} }
}, },
"node_modules/@docusaurus/plugin-sitemap": { "node_modules/@docusaurus/plugin-sitemap": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.5.2.tgz",
"integrity": "sha512-+0VDvx9SmNrFNgwPoeoCha+tRoAjopwT0+pYO1xAbyLcewXSemq+eLxEa46Q1/aoOaJQ0qqHELuQM7iS2gp33Q==", "integrity": "sha512-DnlqYyRAdQ4NHY28TfHuVk414ft2uruP4QWCH//jzpHjqvKyXjj2fmDtI8RPUBh9K8iZKFMHRnLtzJKySPWvFA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/logger": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/types": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-common": "3.4.0", "@docusaurus/utils-common": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"sitemap": "^7.1.1", "sitemap": "^7.1.1",
"tslib": "^2.6.0" "tslib": "^2.6.0"
@ -2516,24 +2463,81 @@
} }
}, },
"node_modules/@docusaurus/preset-classic": { "node_modules/@docusaurus/preset-classic": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.5.2.tgz",
"integrity": "sha512-Ohj6KB7siKqZaQhNJVMBBUzT3Nnp6eTKqO+FXO3qu/n1hJl3YLwVKTWBg28LF7MWrKu46UuYavwMRxud0VyqHg==", "integrity": "sha512-3ihfXQ95aOHiLB5uCu+9PRy2gZCeSZoDcqpnDvf3B+sTrMvMTr8qRUzBvWkoIqc82yG5prCboRjk1SVILKx6sg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/plugin-content-blog": "3.4.0", "@docusaurus/plugin-content-blog": "3.5.2",
"@docusaurus/plugin-content-docs": "3.4.0", "@docusaurus/plugin-content-docs": "3.5.2",
"@docusaurus/plugin-content-pages": "3.4.0", "@docusaurus/plugin-content-pages": "3.5.2",
"@docusaurus/plugin-debug": "3.4.0", "@docusaurus/plugin-debug": "3.5.2",
"@docusaurus/plugin-google-analytics": "3.4.0", "@docusaurus/plugin-google-analytics": "3.5.2",
"@docusaurus/plugin-google-gtag": "3.4.0", "@docusaurus/plugin-google-gtag": "3.5.2",
"@docusaurus/plugin-google-tag-manager": "3.4.0", "@docusaurus/plugin-google-tag-manager": "3.5.2",
"@docusaurus/plugin-sitemap": "3.4.0", "@docusaurus/plugin-sitemap": "3.5.2",
"@docusaurus/theme-classic": "3.4.0", "@docusaurus/theme-classic": "3.5.2",
"@docusaurus/theme-common": "3.4.0", "@docusaurus/theme-common": "3.5.2",
"@docusaurus/theme-search-algolia": "3.4.0", "@docusaurus/theme-search-algolia": "3.5.2",
"@docusaurus/types": "3.4.0" "@docusaurus/types": "3.5.2"
},
"engines": {
"node": ">=18.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-blog": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz",
"integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==",
"license": "MIT",
"dependencies": {
"@docusaurus/core": "3.5.2",
"@docusaurus/logger": "3.5.2",
"@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/theme-common": "3.5.2",
"@docusaurus/types": "3.5.2",
"@docusaurus/utils": "3.5.2",
"@docusaurus/utils-common": "3.5.2",
"@docusaurus/utils-validation": "3.5.2",
"cheerio": "1.0.0-rc.12",
"feed": "^4.2.2",
"fs-extra": "^11.1.1",
"lodash": "^4.17.21",
"reading-time": "^1.5.0",
"srcset": "^4.0.0",
"tslib": "^2.6.0",
"unist-util-visit": "^5.0.0",
"utility-types": "^3.10.0",
"webpack": "^5.88.1"
},
"engines": {
"node": ">=18.0"
},
"peerDependencies": {
"@docusaurus/plugin-content-docs": "*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-pages": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz",
"integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==",
"license": "MIT",
"dependencies": {
"@docusaurus/core": "3.5.2",
"@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/types": "3.5.2",
"@docusaurus/utils": "3.5.2",
"@docusaurus/utils-validation": "3.5.2",
"fs-extra": "^11.1.1",
"tslib": "^2.6.0",
"webpack": "^5.88.1"
}, },
"engines": { "engines": {
"node": ">=18.0" "node": ">=18.0"
@ -2544,27 +2548,27 @@
} }
}, },
"node_modules/@docusaurus/theme-classic": { "node_modules/@docusaurus/theme-classic": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.5.2.tgz",
"integrity": "sha512-0IPtmxsBYv2adr1GnZRdMkEQt1YW6tpzrUPj02YxNpvJ5+ju4E13J5tB4nfdaen/tfR1hmpSPlTFPvTf4kwy8Q==", "integrity": "sha512-XRpinSix3NBv95Rk7xeMF9k4safMkwnpSgThn0UNQNumKvmcIYjfkwfh2BhwYh/BxMXQHJ/PdmNh22TQFpIaYg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/mdx-loader": "3.4.0", "@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/module-type-aliases": "3.4.0", "@docusaurus/module-type-aliases": "3.5.2",
"@docusaurus/plugin-content-blog": "3.4.0", "@docusaurus/plugin-content-blog": "3.5.2",
"@docusaurus/plugin-content-docs": "3.4.0", "@docusaurus/plugin-content-docs": "3.5.2",
"@docusaurus/plugin-content-pages": "3.4.0", "@docusaurus/plugin-content-pages": "3.5.2",
"@docusaurus/theme-common": "3.4.0", "@docusaurus/theme-common": "3.5.2",
"@docusaurus/theme-translations": "3.4.0", "@docusaurus/theme-translations": "3.5.2",
"@docusaurus/types": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-common": "3.4.0", "@docusaurus/utils-common": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"@mdx-js/react": "^3.0.0", "@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"copy-text-to-clipboard": "^3.2.0", "copy-text-to-clipboard": "^3.2.0",
"infima": "0.2.0-alpha.43", "infima": "0.2.0-alpha.44",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"postcss": "^8.4.26", "postcss": "^8.4.26",
@ -2583,19 +2587,73 @@
"react-dom": "^18.0.0" "react-dom": "^18.0.0"
} }
}, },
"node_modules/@docusaurus/theme-common": { "node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-blog": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz",
"integrity": "sha512-0A27alXuv7ZdCg28oPE8nH/Iz73/IUejVaCazqu9elS4ypjiLhK3KfzdSQBnL/g7YfHSlymZKdiOHEo8fJ0qMA==", "integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/mdx-loader": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/module-type-aliases": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/plugin-content-blog": "3.4.0", "@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/plugin-content-docs": "3.4.0", "@docusaurus/theme-common": "3.5.2",
"@docusaurus/plugin-content-pages": "3.4.0", "@docusaurus/types": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-common": "3.4.0", "@docusaurus/utils-common": "3.5.2",
"@docusaurus/utils-validation": "3.5.2",
"cheerio": "1.0.0-rc.12",
"feed": "^4.2.2",
"fs-extra": "^11.1.1",
"lodash": "^4.17.21",
"reading-time": "^1.5.0",
"srcset": "^4.0.0",
"tslib": "^2.6.0",
"unist-util-visit": "^5.0.0",
"utility-types": "^3.10.0",
"webpack": "^5.88.1"
},
"engines": {
"node": ">=18.0"
},
"peerDependencies": {
"@docusaurus/plugin-content-docs": "*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-pages": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz",
"integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==",
"license": "MIT",
"dependencies": {
"@docusaurus/core": "3.5.2",
"@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/types": "3.5.2",
"@docusaurus/utils": "3.5.2",
"@docusaurus/utils-validation": "3.5.2",
"fs-extra": "^11.1.1",
"tslib": "^2.6.0",
"webpack": "^5.88.1"
},
"engines": {
"node": ">=18.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@docusaurus/theme-common": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.5.2.tgz",
"integrity": "sha512-QXqlm9S6x9Ibwjs7I2yEDgsCocp708DrCrgHgKwg2n2AY0YQ6IjU0gAK35lHRLOvAoJUfCKpQAwUykB0R7+Eew==",
"license": "MIT",
"dependencies": {
"@docusaurus/mdx-loader": "3.5.2",
"@docusaurus/module-type-aliases": "3.5.2",
"@docusaurus/utils": "3.5.2",
"@docusaurus/utils-common": "3.5.2",
"@types/history": "^4.7.11", "@types/history": "^4.7.11",
"@types/react": "*", "@types/react": "*",
"@types/react-router-config": "*", "@types/react-router-config": "*",
@ -2609,24 +2667,25 @@
"node": ">=18.0" "node": ">=18.0"
}, },
"peerDependencies": { "peerDependencies": {
"@docusaurus/plugin-content-docs": "*",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0" "react-dom": "^18.0.0"
} }
}, },
"node_modules/@docusaurus/theme-search-algolia": { "node_modules/@docusaurus/theme-search-algolia": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.5.2.tgz",
"integrity": "sha512-aiHFx7OCw4Wck1z6IoShVdUWIjntC8FHCw9c5dR8r3q4Ynh+zkS8y2eFFunN/DL6RXPzpnvKCg3vhLQYJDmT9Q==", "integrity": "sha512-qW53kp3VzMnEqZGjakaV90sst3iN1o32PH+nawv1uepROO8aEGxptcq2R5rsv7aBShSRbZwIobdvSYKsZ5pqvA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docsearch/react": "^3.5.2", "@docsearch/react": "^3.5.2",
"@docusaurus/core": "3.4.0", "@docusaurus/core": "3.5.2",
"@docusaurus/logger": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/plugin-content-docs": "3.4.0", "@docusaurus/plugin-content-docs": "3.5.2",
"@docusaurus/theme-common": "3.4.0", "@docusaurus/theme-common": "3.5.2",
"@docusaurus/theme-translations": "3.4.0", "@docusaurus/theme-translations": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-validation": "3.4.0", "@docusaurus/utils-validation": "3.5.2",
"algoliasearch": "^4.18.0", "algoliasearch": "^4.18.0",
"algoliasearch-helper": "^3.13.3", "algoliasearch-helper": "^3.13.3",
"clsx": "^2.0.0", "clsx": "^2.0.0",
@ -2645,9 +2704,9 @@
} }
}, },
"node_modules/@docusaurus/theme-translations": { "node_modules/@docusaurus/theme-translations": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.5.2.tgz",
"integrity": "sha512-zSxCSpmQCCdQU5Q4CnX/ID8CSUUI3fvmq4hU/GNP/XoAWtXo9SAVnM3TzpU8Gb//H3WCsT8mJcTfyOk3d9ftNg==", "integrity": "sha512-GPZLcu4aT1EmqSTmbdpVrDENGR2yObFEX8ssEFYTCiAIVc0EihNSdOIBTazUvgNqwvnoU1A8vIs1xyzc3LITTw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
@ -2658,9 +2717,9 @@
} }
}, },
"node_modules/@docusaurus/types": { "node_modules/@docusaurus/types": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.5.2.tgz",
"integrity": "sha512-4jcDO8kXi5Cf9TcyikB/yKmz14f2RZ2qTRerbHAsS+5InE9ZgSLBNLsewtFTcTOXSVcbU3FoGOzcNWAmU1TR0A==", "integrity": "sha512-N6GntLXoLVUwkZw7zCxwy9QiuEXIcTVzA9AkmNw16oc0AP3SXLrMmDMMBIfgqwuKWa6Ox6epHol9kMtJqekACw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@mdx-js/mdx": "^3.0.0", "@mdx-js/mdx": "^3.0.0",
@ -2679,13 +2738,13 @@
} }
}, },
"node_modules/@docusaurus/utils": { "node_modules/@docusaurus/utils": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.5.2.tgz",
"integrity": "sha512-fRwnu3L3nnWaXOgs88BVBmG1yGjcQqZNHG+vInhEa2Sz2oQB+ZjbEMO5Rh9ePFpZ0YDiDUhpaVjwmS+AU2F14g==", "integrity": "sha512-33QvcNFh+Gv+C2dP9Y9xWEzMgf3JzrpL2nW9PopidiohS1nDcyknKRx2DWaFvyVTTYIkkABVSr073VTj/NITNA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/logger": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/utils-common": "3.4.0", "@docusaurus/utils-common": "3.5.2",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
@ -2718,9 +2777,9 @@
} }
}, },
"node_modules/@docusaurus/utils-common": { "node_modules/@docusaurus/utils-common": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.5.2.tgz",
"integrity": "sha512-NVx54Wr4rCEKsjOH5QEVvxIqVvm+9kh7q8aYTU5WzUU9/Hctd6aTrcZ3G0Id4zYJ+AeaG5K5qHA4CY5Kcm2iyQ==", "integrity": "sha512-i0AZjHiRgJU6d7faQngIhuHKNrszpL/SHQPgF1zH4H+Ij6E9NBYGy6pkcGWToIv7IVPbs+pQLh1P3whn0gWXVg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.6.0" "tslib": "^2.6.0"
@ -2738,14 +2797,14 @@
} }
}, },
"node_modules/@docusaurus/utils-validation": { "node_modules/@docusaurus/utils-validation": {
"version": "3.4.0", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.5.2.tgz",
"integrity": "sha512-hYQ9fM+AXYVTWxJOT1EuNaRnrR2WGpRdLDQG07O8UOpsvCPWUVOeo26Rbm0JWY2sGLfzAb+tvJ62yF+8F+TV0g==", "integrity": "sha512-m+Foq7augzXqB6HufdS139PFxDC5d5q2QKZy8q0qYYvGdI6nnlNsGH4cIGsgBnV7smz+mopl3g4asbSDvMV0jA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@docusaurus/logger": "3.4.0", "@docusaurus/logger": "3.5.2",
"@docusaurus/utils": "3.4.0", "@docusaurus/utils": "3.5.2",
"@docusaurus/utils-common": "3.4.0", "@docusaurus/utils-common": "3.5.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"joi": "^17.9.2", "joi": "^17.9.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@ -4237,9 +4296,9 @@
} }
}, },
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.19", "version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -4254,12 +4313,13 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.23.0", "browserslist": "^4.23.3",
"caniuse-lite": "^1.0.30001599", "caniuse-lite": "^1.0.30001646",
"fraction.js": "^4.3.7", "fraction.js": "^4.3.7",
"normalize-range": "^0.1.2", "normalize-range": "^0.1.2",
"picocolors": "^1.0.0", "picocolors": "^1.0.1",
"postcss-value-parser": "^4.2.0" "postcss-value-parser": "^4.2.0"
}, },
"bin": { "bin": {
@ -4531,9 +4591,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.23.0", "version": "4.23.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
"integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -4548,11 +4608,12 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001587", "caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.4.668", "electron-to-chromium": "^1.5.4",
"node-releases": "^2.0.14", "node-releases": "^2.0.18",
"update-browserslist-db": "^1.0.13" "update-browserslist-db": "^1.1.0"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@ -4699,9 +4760,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001614", "version": "1.0.30001651",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
"integrity": "sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==", "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -4715,7 +4776,8 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
] ],
"license": "CC-BY-4.0"
}, },
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
@ -6342,9 +6404,10 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.751", "version": "1.5.6",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.751.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz",
"integrity": "sha512-2DEPi++qa89SMGRhufWTiLmzqyuGmNF3SK4+PQetW1JKiZdEpF4XQonJXJCzyuYSA6mauiMhbyVhqYAP45Hvfw==" "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==",
"license": "ISC"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
@ -8763,9 +8826,10 @@
} }
}, },
"node_modules/infima": { "node_modules/infima": {
"version": "0.2.0-alpha.43", "version": "0.2.0-alpha.44",
"resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.43.tgz", "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.44.tgz",
"integrity": "sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ==", "integrity": "sha512-tuRkUSO/lB3rEhLJk25atwAjgLuzq070+pOW8XcvpHky/YbENnRRdPd85IBkyeTgttmOy5ah+yHYsK1HhUd4lQ==",
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -11958,9 +12022,10 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.14", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"license": "MIT"
}, },
"node_modules/nopt": { "node_modules/nopt": {
"version": "1.0.10", "version": "1.0.10",
@ -12754,9 +12819,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.39", "version": "8.4.40",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -13600,9 +13665,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.3.2", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -13747,9 +13812,10 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.12.1", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.0.6" "side-channel": "^1.0.6"
}, },
@ -16014,9 +16080,9 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.4", "version": "3.4.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
"integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==", "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
@ -16376,9 +16442,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.5.3", "version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -16607,9 +16673,9 @@
} }
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.0.13", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -16624,9 +16690,10 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"escalade": "^3.1.1", "escalade": "^3.1.2",
"picocolors": "^1.0.0" "picocolors": "^1.0.1"
}, },
"bin": { "bin": {
"update-browserslist-db": "cli.js" "update-browserslist-db": "cli.js"
@ -16714,12 +16781,16 @@
} }
}, },
"node_modules/url": { "node_modules/url": {
"version": "0.11.3", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
"integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
"license": "MIT",
"dependencies": { "dependencies": {
"punycode": "^1.4.1", "punycode": "^1.4.1",
"qs": "^6.11.2" "qs": "^6.12.3"
},
"engines": {
"node": ">= 0.4"
} }
}, },
"node_modules/url-loader": { "node_modules/url-loader": {
@ -16783,7 +16854,8 @@
"node_modules/url/node_modules/punycode": { "node_modules/url/node_modules/punycode": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
}, },
"node_modules/util": { "node_modules/util": {
"version": "0.10.4", "version": "0.10.4",

View File

@ -56,6 +56,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "20.15.1" "node": "20.17.0"
} }
} }

View File

@ -43,6 +43,11 @@ const guides: CommunityGuidesProps[] = [
description: 'Access your local Immich installation over the internet using your own domain', description: 'Access your local Immich installation over the internet using your own domain',
url: 'https://github.com/ppr88/immich-guides/blob/main/open-immich-custom-domain.md', url: 'https://github.com/ppr88/immich-guides/blob/main/open-immich-custom-domain.md',
}, },
{
title: 'Nginx caching map server',
description: 'Increase privacy by using nginx as a caching proxy in front of a map tile server',
url: 'https://github.com/pcouy/pcouy.github.io/blob/main/_posts/2024-08-30-proxying-a-map-tile-server-for-increased-privacy.md',
},
]; ];
function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element { function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element {

View File

@ -28,11 +28,6 @@ const projects: CommunityProjectProps[] = [
description: 'A simple way to remove orphaned offline assets from the Immich database', description: 'A simple way to remove orphaned offline assets from the Immich database',
url: 'https://github.com/Thoroslives/immich_remove_offline_files', url: 'https://github.com/Thoroslives/immich_remove_offline_files',
}, },
{
title: 'Create albums from folders',
description: 'A Python script to create albums based on the folder structure of an external library.',
url: 'https://github.com/Salvoxia/immich-folder-album-creator',
},
{ {
title: 'Immich-Tools', title: 'Immich-Tools',
description: 'Provides scripts for handling problems on the repair page.', description: 'Provides scripts for handling problems on the repair page.',
@ -43,6 +38,11 @@ const projects: CommunityProjectProps[] = [
description: 'Lightroom plugin to publish photos from Lightroom collections to Immich albums.', description: 'Lightroom plugin to publish photos from Lightroom collections to Immich albums.',
url: 'https://github.com/midzelis/mi.Immich.Publisher', url: 'https://github.com/midzelis/mi.Immich.Publisher',
}, },
{
title: 'Lightroom Immich Plugin: lrc-immich-plugin',
description: 'Another Lightroom plugin to publish or export photos from Lightroom to Immich.',
url: 'https://github.com/bmachek/lrc-immich-plugin',
},
{ {
title: 'Immich Duplicate Finder', title: 'Immich Duplicate Finder',
description: 'Webapp that uses machine learning to identify near-duplicate images.', description: 'Webapp that uses machine learning to identify near-duplicate images.',
@ -58,11 +58,31 @@ const projects: CommunityProjectProps[] = [
description: 'Unofficial Immich Android TV app.', description: 'Unofficial Immich Android TV app.',
url: 'https://github.com/giejay/Immich-Android-TV', url: 'https://github.com/giejay/Immich-Android-TV',
}, },
{
title: 'Create albums from folders',
description: 'A Python script to create albums based on the folder structure of an external library.',
url: 'https://github.com/Salvoxia/immich-folder-album-creator',
},
{ {
title: 'Powershell Module PSImmich', title: 'Powershell Module PSImmich',
description: 'Powershell Module for the Immich API', description: 'Powershell Module for the Immich API',
url: 'https://github.com/hanpq/PSImmich', url: 'https://github.com/hanpq/PSImmich',
}, },
{
title: 'Immich Distribution',
description: 'Snap package for easy install and zero-care auto updates of Immich. Self-hosted photo management.',
url: 'https://immich-distribution.nsg.cc',
},
{
title: 'Immich Kiosk',
description: 'Lightweight slideshow to run on kiosk devices and browsers.',
url: 'https://github.com/damongolding/immich-kiosk',
},
{
title: 'Immich Power Tools',
description: 'Power tools for organizing your immich library.',
url: 'https://github.com/varun-raj/immich-power-tools',
},
]; ];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {

View File

@ -1,4 +1,3 @@
import '@docusaurus/theme-classic/lib/theme/Unlisted/index';
import { useWindowSize } from '@docusaurus/theme-common'; import { useWindowSize } from '@docusaurus/theme-common';
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';

View File

@ -1,4 +1,13 @@
import { mdiCalendarToday, mdiLeadPencil, mdiLockOutline, mdiSpeedometerSlow, mdiWeb } from '@mdi/js'; import {
mdiCalendarToday,
mdiCrosshairsOff,
mdiLeadPencil,
mdiLockOff,
mdiLockOutline,
mdiSpeedometerSlow,
mdiWeb,
mdiWrap,
} from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import React from 'react'; import React from 'react';
import { Item as TimelineItem, Timeline } from '../components/timeline'; import { Item as TimelineItem, Timeline } from '../components/timeline';
@ -8,6 +17,41 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date }; type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
const items: Item[] = [ const items: Item[] = [
{
icon: mdiWrap,
iconColor: 'gray',
title: 'Carriage returns in bash scripts are cursed',
description: 'Git can be configured to automatically convert LF to CRLF on checkout and CRLF breaks bash scripts.',
link: {
url: 'https://github.com/immich-app/immich/pull/11613',
text: '#11613',
},
date: new Date(2024, 7, 7),
},
{
icon: mdiLockOff,
iconColor: 'red',
title: 'Fetch inside Cloudflare Workers is cursed',
description:
'Fetch requests in Cloudflare Workers use http by default, even if you explicitly specify https, which can often cause redirect loops.',
link: {
url: 'https://community.cloudflare.com/t/does-cloudflare-worker-allow-secure-https-connection-to-fetch-even-on-flexible-ssl/68051/5',
text: 'Cloudflare',
},
date: new Date(2024, 7, 7),
},
{
icon: mdiCrosshairsOff,
iconColor: 'gray',
title: 'GPS sharing on mobile is cursed',
description:
'Some phones will silently strip GPS data from images when apps without location permission try to access them.',
link: {
url: 'https://github.com/immich-app/immich/discussions/11268',
text: '#11268',
},
date: new Date(2024, 6, 21),
},
{ {
icon: mdiLeadPencil, icon: mdiLeadPencil,
iconColor: 'gold', iconColor: 'gold',

View File

@ -0,0 +1,114 @@
import React from 'react';
import Link from '@docusaurus/Link';
import Layout from '@theme/Layout';
import { useColorMode } from '@docusaurus/theme-common';
function HomepageHeader() {
const { isDarkTheme } = useColorMode();
return (
<header>
<section className="max-w-[900px] m-4 p-4 md:p-6 md:m-auto md:my-12 border border-red-400 rounded-2xl bg-slate-200 dark:bg-immich-dark-gray">
<section>
<h1>Privacy Policy</h1>
<p>Last updated: July 31st 2024</p>
<p>
Welcome to Immich. We are committed to respecting your privacy. This Privacy Policy sets out how we collect,
use, and share information when you use our Immich app.
</p>
</section>
{/* 1. Scope of This Policy */}
<section>
<h2>1. Scope of This Policy</h2>
<p>
This Privacy Policy applies to the Immich app ("we", "our", or "us") and covers our collection, use, and
disclosure of your information. This Policy does not cover any third-party websites, services, or
applications that can be accessed through our app, or third-party services you may access through Immich.
</p>
</section>
{/* 2. Information We Collect */}
<section>
<h2>2. Information We Collect</h2>
<div>
<p>
<strong>Locally Stored Data</strong>: Immich stores all your photos, albums, settings, and locally on your
device. We do not have access to this data, nor do we transmit or store it on any of our servers.
</p>
</div>
<div>
<p>
<strong>Purchase Information:</strong> When you make a purchase within the{' '}
<a href="https://buy.immich.app">https://buy.immich.app</a>, we collect the following information for tax
calculation purposes:
</p>
<ul>
<li>Country of origin</li>
<li>Postal code (if the user is from Canada or the United States)</li>
</ul>
</div>
</section>
{/* 3. Use of Your Information */}
<section>
<h2>3. Use of Your Information</h2>
<p>
<strong>Tax Calculation:</strong> The country of origin and postal code (for users from Canada or the United
States) are collected solely for determining the applicable tax rates on your purchase.
</p>
</section>
{/* 4. Sharing of Your Information */}
<section>
<h2>4. Sharing of Your Information</h2>
<ul>
<li>
<strong>Tax Authorities:</strong> The purchase information may be shared with tax authorities as required
by law.
</li>
<li>
<strong>Payment Providers:</strong> The purchase information may be shared with payment providers where
required.
</li>
</ul>
</section>
{/* 5. Changes to This Policy */}
<section>
<h2>5. Changes to This Policy</h2>
<p>
We may update our Privacy Policy from time to time. If we make any changes, we will notify you by revising
the "Last updated" date at the top of this policy. It's encouraged that users frequently check this page for
any changes to stay informed about how we are helping to protect the personal information we collect.
</p>
</section>
{/* 6. Contact Us */}
<section>
<h2>6. Contact Us</h2>
<p>
If you have any questions about this Privacy Policy, please contact us at{' '}
<a href="mailto:immich@futo.org">immich@futo.org</a>
</p>
</section>
</section>
</header>
);
}
export default function Home(): JSX.Element {
return (
<Layout
title="Home"
description="immich Self-hosted photo and video backup solution directly from your mobile phone "
noFooter={true}
>
<HomepageHeader />
<div className="flex flex-col place-items-center place-content-center">
<p>This project is available under GNU AGPL v3 license.</p>
<p className="text-xs">Privacy should not be a luxury</p>
</div>
</Layout>
);
}

View File

@ -15,6 +15,7 @@ import {
mdiCloudUploadOutline, mdiCloudUploadOutline,
mdiCollage, mdiCollage,
mdiContentDuplicate, mdiContentDuplicate,
mdiCrop,
mdiDevices, mdiDevices,
mdiEmailOutline, mdiEmailOutline,
mdiExpansionCard, mdiExpansionCard,
@ -26,6 +27,7 @@ import {
mdiFileSearch, mdiFileSearch,
mdiFlash, mdiFlash,
mdiFolder, mdiFolder,
mdiFolderMultiple,
mdiForum, mdiForum,
mdiHandshakeOutline, mdiHandshakeOutline,
mdiHeart, mdiHeart,
@ -36,6 +38,7 @@ import {
mdiImageMultipleOutline, mdiImageMultipleOutline,
mdiImageSearch, mdiImageSearch,
mdiKeyboardSettingsOutline, mdiKeyboardSettingsOutline,
mdiLicense,
mdiLockOutline, mdiLockOutline,
mdiMagnify, mdiMagnify,
mdiMagnifyScan, mdiMagnifyScan,
@ -55,25 +58,29 @@ import {
mdiScaleBalance, mdiScaleBalance,
mdiSecurity, mdiSecurity,
mdiServer, mdiServer,
mdiShare,
mdiShareAll, mdiShareAll,
mdiShareCircle, mdiShareCircle,
mdiStar, mdiStar,
mdiStarOutline,
mdiTableKey, mdiTableKey,
mdiTag, mdiTag,
mdiTagMultiple,
mdiText, mdiText,
mdiThemeLightDark, mdiThemeLightDark,
mdiTrashCanOutline, mdiTrashCanOutline,
mdiVectorCombine, mdiVectorCombine,
mdiVideo, mdiVideo,
mdiWeb, mdiWeb,
mdiLicense,
} from '@mdi/js'; } from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import React from 'react'; import React from 'react';
import { Item, Timeline } from '../components/timeline'; import { Item, Timeline } from '../components/timeline';
const releases = { const releases = {
// TODO 'v1.113.0': new Date(2024, 7, 30),
'v1.112.0': new Date(2024, 7, 14),
'v1.111.0': new Date(2024, 6, 26),
'v1.110.0': new Date(2024, 5, 11), 'v1.110.0': new Date(2024, 5, 11),
'v1.109.0': new Date(2024, 6, 18), 'v1.109.0': new Date(2024, 6, 18),
'v1.106.1': new Date(2024, 5, 11), 'v1.106.1': new Date(2024, 5, 11),
@ -224,6 +231,47 @@ const roadmap: Item[] = [
]; ];
const milestones: Item[] = [ const milestones: Item[] = [
withRelease({
icon: mdiTagMultiple,
iconColor: 'orange',
title: 'Tags',
description: 'Tag your photos and videos',
release: 'v1.113.0',
}),
withRelease({
icon: mdiFolderMultiple,
iconColor: 'brown',
title: 'Folders',
description: 'View your photos and videos in folders',
release: 'v1.113.0',
}),
withRelease({
icon: mdiPalette,
title: 'Theming (mobile)',
description: 'Pick a primary color for the mobile app',
release: 'v1.112.0',
}),
withRelease({
icon: mdiStarOutline,
iconColor: 'gold',
title: 'Star rating',
description: 'Rate your photos and videos',
release: 'v1.112.0',
}),
withRelease({
icon: mdiCrop,
iconColor: 'royalblue',
title: 'Editor (mobile)',
description: 'Crop and rotate on mobile',
release: 'v1.111.0',
}),
withRelease({
icon: mdiMap,
iconColor: 'green',
title: 'Deploy tiles.immich.cloud',
description: 'Dedicated tile server for Immich',
release: 'v1.111.0',
}),
{ {
icon: mdiStar, icon: mdiStar,
iconColor: 'gold', iconColor: 'gold',
@ -231,6 +279,12 @@ const milestones: Item[] = [
description: 'Reached 40K Stars on GitHub!', description: 'Reached 40K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2024, 6, 21)), getDateLabel: withLanguage(new Date(2024, 6, 21)),
}, },
withRelease({
icon: mdiShare,
title: 'Deploy my.immich.app',
description: 'Url router for immich links',
release: 'v1.109.0',
}),
withRelease({ withRelease({
icon: mdiLicense, icon: mdiLicense,
iconColor: 'gold', iconColor: 'gold',

View File

@ -1,4 +1,28 @@
[ [
{
"label": "v1.113.1",
"url": "https://v1.113.1.archive.immich.app"
},
{
"label": "v1.113.0",
"url": "https://v1.113.0.archive.immich.app"
},
{
"label": "v1.112.1",
"url": "https://v1.112.1.archive.immich.app"
},
{
"label": "v1.112.0",
"url": "https://v1.112.0.archive.immich.app"
},
{
"label": "v1.111.0",
"url": "https://v1.111.0.archive.immich.app"
},
{
"label": "v1.110.0",
"url": "https://v1.110.0.archive.immich.app"
},
{ {
"label": "v1.109.2", "label": "v1.109.2",
"url": "https://v1.109.2.archive.immich.app" "url": "https://v1.109.2.archive.immich.app"

View File

@ -4,7 +4,7 @@ module.exports = {
corePlugins: { corePlugins: {
preflight: false, // disable Tailwind's reset preflight: false, // disable Tailwind's reset
}, },
content: ['./src/**/*.{js,jsx,ts,tsx}', '../docs/**/*.mdx'], // my markdown stuff is in ../docs, not /src content: ['./src/**/*.{js,jsx,ts,tsx}', './{docs,blog}/**/*.{md,mdx}'], // my markdown stuff is in ../docs, not /src
darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns
theme: { theme: {
extend: { extend: {

View File

@ -1,32 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'],
root: true,
env: {
node: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
},
};

View File

@ -1 +1 @@
20.15.1 20.17.0

View File

@ -1,5 +1,3 @@
version: '3.8'
name: immich-e2e name: immich-e2e
services: services:
@ -32,7 +30,7 @@ services:
- redis - redis
- database - database
ports: ports:
- 2283:3001 - 2285:3001
redis: redis:
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
@ -45,7 +43,7 @@ services:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: immich POSTGRES_DB: immich
ports: ports:
- 5433:5432 - 5435:5432
volumes: volumes:
model-cache: model-cache:

65
e2e/eslint.config.mjs Normal file
View File

@ -0,0 +1,65 @@
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: ['eslint.config.mjs'],
},
...compat.extends(
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:unicorn/recommended',
),
{
plugins: {
'@typescript-eslint': typescriptEslint,
},
languageOptions: {
globals: {
...globals.node,
},
parser: tsParser,
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
'object-shorthand': ['error', 'always'],
},
},
];

1899
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.109.2", "version": "1.113.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@ -19,23 +19,26 @@
"author": "", "author": "",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@immich/cli": "file:../cli", "@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^20.14.12", "@types/node": "^20.16.2",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^7.1.0", "@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^1.3.0", "@vitest/coverage-v8": "^2.0.5",
"eslint": "^8.57.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^54.0.0", "eslint-plugin-unicorn": "^55.0.0",
"exiftool-vendored": "^27.0.0", "exiftool-vendored": "^28.0.0",
"globals": "^15.9.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"oidc-provider": "^8.5.1", "oidc-provider": "^8.5.1",
@ -47,9 +50,9 @@
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"utimes": "^5.2.1", "utimes": "^5.2.1",
"vitest": "^1.3.0" "vitest": "^2.0.5"
}, },
"volta": { "volta": {
"node": "20.15.1" "node": "20.17.0"
} }
} }

View File

@ -8,7 +8,7 @@ export default defineConfig({
workers: 1, workers: 1,
reporter: 'html', reporter: 'html',
use: { use: {
baseURL: 'http://127.0.0.1:2283', baseURL: 'http://127.0.0.1:2285',
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
@ -54,7 +54,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'docker compose up --build -V --remove-orphans', command: 'docker compose up --build -V --remove-orphans',
url: 'http://127.0.0.1:2283', url: 'http://127.0.0.1:2285',
reuseExistingServer: true, reuseExistingServer: true,
}, },
}); });

View File

@ -344,16 +344,16 @@ describe('/albums', () => {
}); });
}); });
describe('GET /albums/count', () => { describe('GET /albums/statistics', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/albums/count'); const { status, body } = await request(app).get('/albums/statistics');
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should return total count of albums the user has access to', async () => { it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/albums/count') .get('/albums/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);

View File

@ -0,0 +1,258 @@
import { LoginResponseDto, Permission, createApiKey } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const create = (accessToken: string, permissions: Permission[]) =>
createApiKey({ apiKeyCreateDto: { name: 'api key', permissions } }, { headers: asBearerAuth(accessToken) });
describe('/api-keys', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
beforeEach(async () => {
await utils.resetDatabase(['api_keys']);
});
describe('POST /api-keys', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/api-keys').send({ name: 'API Key' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not work without permission', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]);
const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('apiKey.create'));
});
it('should work with apiKey.create', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({
name: 'API Key',
permissions: [Permission.ApiKeyRead],
});
expect(body).toEqual({
secret: expect.any(String),
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.ApiKeyRead],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
});
expect(status).toBe(201);
});
it('should not create an api key with all permissions', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({ name: 'API Key', permissions: [Permission.All] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have'));
});
it('should not create an api key with more permissions', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({ name: 'API Key', permissions: [Permission.ApiKeyRead] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have'));
});
it('should create an api key', async () => {
const { status, body } = await request(app)
.post('/api-keys')
.send({ name: 'API Key', permissions: [Permission.All] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual({
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
secret: expect.any(String),
});
expect(status).toEqual(201);
});
});
describe('GET /api-keys', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/api-keys');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should start off empty', async () => {
const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([]);
expect(status).toEqual(200);
});
it('should return a list of api keys', async () => {
const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
]);
const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(3);
expect(body).toEqual(expect.arrayContaining([apiKey1, apiKey2, apiKey3]));
expect(status).toEqual(200);
});
});
describe('GET /api-keys/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/api-keys/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('API Key not found'));
});
it('should require a valid uuid', async () => {
const { status, body } = await request(app)
.get(`/api-keys/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should get api key details', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
id: expect.any(String),
name: 'api key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
});
describe('PUT /api-keys/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/api-keys/${uuidDto.notFound}`).send({ name: 'new name' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('API Key not found'));
});
it('should require a valid uuid', async () => {
const { status, body } = await request(app)
.put(`/api-keys/${uuidDto.invalid}`)
.send({ name: 'new name' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should update api key details', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name' })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
id: expect.any(String),
name: 'new name',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
});
describe('DELETE /api-keys/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/api-keys/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('API Key not found'));
});
it('should require a valid uuid', async () => {
const { status, body } = await request(app)
.delete(`/api-keys/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should delete an api key', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(204);
});
});
describe('authentication', () => {
it('should work as a header', async () => {
const { secret } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app).get('/api-keys').set('x-api-key', secret);
expect(body).toHaveLength(1);
expect(status).toBe(200);
});
it('should work as a query param', async () => {
const { secret } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app).get(`/api-keys?apiKey=${secret}`);
expect(body).toHaveLength(1);
expect(status).toBe(200);
});
});
});

View File

@ -7,7 +7,6 @@ import {
SharedLinkType, SharedLinkType,
getAssetInfo, getAssetInfo,
getMyUser, getMyUser,
updateAssets,
} from '@immich/sdk'; } from '@immich/sdk';
import { exiftool } from 'exiftool-vendored'; import { exiftool } from 'exiftool-vendored';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -43,6 +42,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
const readTags = async (bytes: Buffer, filename: string) => { const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename); const filepath = join(tempDir, filename);
@ -66,25 +66,23 @@ describe('/asset', () => {
let timeBucketUser: LoginResponseDto; let timeBucketUser: LoginResponseDto;
let quotaUser: LoginResponseDto; let quotaUser: LoginResponseDto;
let statsUser: LoginResponseDto; let statsUser: LoginResponseDto;
let stackUser: LoginResponseDto;
let user1Assets: AssetMediaResponseDto[]; let user1Assets: AssetMediaResponseDto[];
let user2Assets: AssetMediaResponseDto[]; let user2Assets: AssetMediaResponseDto[];
let stackAssets: AssetMediaResponseDto[];
let locationAsset: AssetMediaResponseDto; let locationAsset: AssetMediaResponseDto;
let ratingAsset: AssetMediaResponseDto;
const setupTests = async () => { const setupTests = async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false }); admin = await utils.adminSetup({ onboarding: false });
[websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ [websocket, user1, user2, statsUser, quotaUser, timeBucketUser] = await Promise.all([
utils.connectWebsocket(admin.accessToken), utils.connectWebsocket(admin.accessToken),
utils.userSetup(admin.accessToken, createUserDto.create('1')), utils.userSetup(admin.accessToken, createUserDto.create('1')),
utils.userSetup(admin.accessToken, createUserDto.create('2')), utils.userSetup(admin.accessToken, createUserDto.create('2')),
utils.userSetup(admin.accessToken, createUserDto.create('stats')), utils.userSetup(admin.accessToken, createUserDto.create('stats')),
utils.userSetup(admin.accessToken, createUserDto.userQuota), utils.userSetup(admin.accessToken, createUserDto.userQuota),
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
utils.userSetup(admin.accessToken, createUserDto.create('stack')),
]); ]);
await utils.createPartner(user1.accessToken, user2.userId); await utils.createPartner(user1.accessToken, user2.userId);
@ -99,6 +97,16 @@ describe('/asset', () => {
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id }); await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id });
// asset rating
ratingAsset = await utils.createAsset(admin.accessToken, {
assetData: {
filename: 'mongolels.jpg',
bytes: await readFile(ratingAssetFilepath),
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: ratingAsset.id });
user1Assets = await Promise.all([ user1Assets = await Promise.all([
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
@ -137,20 +145,6 @@ describe('/asset', () => {
}), }),
]); ]);
// stacks
stackAssets = await Promise.all([
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
]);
await updateAssets(
{ assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } },
{ headers: asBearerAuth(stackUser.accessToken) },
);
const person1 = await utils.createPerson(user1.accessToken, { const person1 = await utils.createPerson(user1.accessToken, {
name: 'Test Person', name: 'Test Person',
}); });
@ -214,6 +208,22 @@ describe('/asset', () => {
expect(body).toMatchObject({ id: user1Assets[0].id }); expect(body).toMatchObject({ id: user1Assets[0].id });
}); });
it('should get the asset rating', async () => {
await utils.waitForWebsocketEvent({
event: 'assetUpload',
id: ratingAsset.id,
});
const { status, body } = await request(app)
.get(`/assets/${ratingAsset.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: ratingAsset.id,
exifInfo: expect.objectContaining({ rating: 3 }),
});
});
it('should work with a shared link', async () => { it('should work with a shared link', async () => {
const sharedLink = await utils.createSharedLink(user1.accessToken, { const sharedLink = await utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual, type: SharedLinkType.Individual,
@ -353,6 +363,8 @@ describe('/asset', () => {
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
]); ]);
await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
}); });
it('should require authentication', async () => { it('should require authentication', async () => {
@ -389,17 +401,14 @@ describe('/asset', () => {
} }
}); });
it.each(TEN_TIMES)( it.skip('should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => {
'should return 1 asset if there are 10 assets in the database but user 2 only has 1', const { status, body } = await request(app)
async () => { .get('/assets/random')
const { status, body } = await request(app) .set('Authorization', `Bearer ${user2.accessToken}`);
.get('/assets/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]); expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
}, });
);
it('should return error', async () => { it('should return error', async () => {
const { status } = await request(app) const { status } = await request(app)
@ -575,6 +584,31 @@ describe('/asset', () => {
expect(status).toEqual(200); expect(status).toEqual(200);
}); });
it('should set the rating', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ rating: 2 });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
rating: 2,
}),
});
expect(status).toEqual(200);
});
it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.send(test)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
}
});
it('should return tagged people', async () => { it('should return tagged people', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
@ -773,145 +807,8 @@ describe('/asset', () => {
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require a valid parent id', async () => {
const { status, body } = await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID']));
});
it('should require access to the parent', async () => {
const { status, body } = await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should add stack children', async () => {
const { status } = await request(app)
.put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })]));
});
it('should remove stack children', async () => {
const { status } = await request(app)
.put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ removeParent: true, ids: [stackAssets[1].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: stackAssets[2].id }),
expect.objectContaining({ id: stackAssets[3].id }),
]),
);
});
it('should remove all stack children', async () => {
const { status } = await request(app)
.put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).toBeUndefined();
});
it('should merge stack children', async () => {
// create stack after previous test removed stack children
await updateAssets(
{ assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } },
{ headers: asBearerAuth(stackUser.accessToken) },
);
const { status } = await request(app)
.put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: stackAssets[0].id }),
expect.objectContaining({ id: stackAssets[1].id }),
expect.objectContaining({ id: stackAssets[2].id }),
]),
);
});
}); });
describe('PUT /assets/stack/parent', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put('/assets/stack/parent');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put('/assets/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
it('should require access', async () => {
const { status, body } = await request(app)
.put('/assets/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should make old parent child of new parent', async () => {
const { status } = await request(app)
.put('/assets/stack/parent')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
expect(status).toBe(200);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
// new parent
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: stackAssets[1].id }),
expect.objectContaining({ id: stackAssets[2].id }),
expect.objectContaining({ id: stackAssets[3].id }),
]),
);
});
});
describe('POST /assets', () => { describe('POST /assets', () => {
beforeAll(setupTests, 30_000); beforeAll(setupTests, 30_000);
@ -940,13 +837,12 @@ describe('/asset', () => {
expect(body).toEqual(errorDto.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
it.each([ const tests = [
{ {
input: 'formats/avif/8bit-sRGB.avif', input: 'formats/avif/8bit-sRGB.avif',
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: '8bit-sRGB.avif', originalFileName: '8bit-sRGB.avif',
resized: true,
exifInfo: { exifInfo: {
description: '', description: '',
exifImageHeight: 1080, exifImageHeight: 1080,
@ -962,7 +858,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: 'el_torcal_rocks.jpg', originalFileName: 'el_torcal_rocks.jpg',
resized: true,
exifInfo: { exifInfo: {
dateTimeOriginal: '2012-08-05T11:39:59.000Z', dateTimeOriginal: '2012-08-05T11:39:59.000Z',
exifImageWidth: 512, exifImageWidth: 512,
@ -986,7 +881,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: '8bit-sRGB.jxl', originalFileName: '8bit-sRGB.jxl',
resized: true,
exifInfo: { exifInfo: {
description: '', description: '',
exifImageHeight: 1080, exifImageHeight: 1080,
@ -1002,7 +896,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: 'IMG_2682.heic', originalFileName: 'IMG_2682.heic',
resized: true,
fileCreatedAt: '2019-03-21T16:04:22.348Z', fileCreatedAt: '2019-03-21T16:04:22.348Z',
exifInfo: { exifInfo: {
dateTimeOriginal: '2019-03-21T16:04:22.348Z', dateTimeOriginal: '2019-03-21T16:04:22.348Z',
@ -1027,7 +920,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: 'density_plot.png', originalFileName: 'density_plot.png',
resized: true,
exifInfo: { exifInfo: {
exifImageWidth: 800, exifImageWidth: 800,
exifImageHeight: 800, exifImageHeight: 800,
@ -1042,7 +934,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: 'glarus.nef', originalFileName: 'glarus.nef',
resized: true,
fileCreatedAt: '2010-07-20T17:27:12.000Z', fileCreatedAt: '2010-07-20T17:27:12.000Z',
exifInfo: { exifInfo: {
make: 'NIKON CORPORATION', make: 'NIKON CORPORATION',
@ -1064,7 +955,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: 'philadelphia.nef', originalFileName: 'philadelphia.nef',
resized: true,
fileCreatedAt: '2016-09-22T22:10:29.060Z', fileCreatedAt: '2016-09-22T22:10:29.060Z',
exifInfo: { exifInfo: {
make: 'NIKON CORPORATION', make: 'NIKON CORPORATION',
@ -1087,7 +977,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: '4_3.rw2', originalFileName: '4_3.rw2',
resized: true,
fileCreatedAt: '2018-05-10T08:42:37.842Z', fileCreatedAt: '2018-05-10T08:42:37.842Z',
exifInfo: { exifInfo: {
make: 'Panasonic', make: 'Panasonic',
@ -1111,7 +1000,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: '12bit-compressed-(3_2).arw', originalFileName: '12bit-compressed-(3_2).arw',
resized: true,
fileCreatedAt: '2016-09-27T10:51:44.000Z', fileCreatedAt: '2016-09-27T10:51:44.000Z',
exifInfo: { exifInfo: {
make: 'SONY', make: 'SONY',
@ -1136,7 +1024,6 @@ describe('/asset', () => {
expected: { expected: {
type: AssetTypeEnum.Image, type: AssetTypeEnum.Image,
originalFileName: '14bit-uncompressed-(3_2).arw', originalFileName: '14bit-uncompressed-(3_2).arw',
resized: true,
fileCreatedAt: '2016-01-08T14:08:01.000Z', fileCreatedAt: '2016-01-08T14:08:01.000Z',
exifInfo: { exifInfo: {
make: 'SONY', make: 'SONY',
@ -1156,21 +1043,32 @@ describe('/asset', () => {
}, },
}, },
}, },
])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => { ];
const filepath = join(testAssetDir, input);
const { id, status } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
});
expect(status).toBe(AssetMediaStatus.Created); it(`should upload and generate a thumbnail for different file types`, async () => {
// upload in parallel
const assets = await Promise.all(
tests.map(async ({ input }) => {
const filepath = join(testAssetDir, input);
return utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
});
}),
);
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); for (const { id, status } of assets) {
expect(status).toBe(AssetMediaStatus.Created);
await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
}
const asset = await utils.getAssetInfo(admin.accessToken, id); for (const [i, { id }] of assets.entries()) {
const { expected } = tests[i];
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined(); expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo).toMatchObject(expected.exifInfo); expect(asset.exifInfo).toMatchObject(expected.exifInfo);
expect(asset).toMatchObject(expected); expect(asset).toMatchObject(expected);
}
}); });
it('should handle a duplicate', async () => { it('should handle a duplicate', async () => {

View File

@ -353,7 +353,7 @@ describe('/libraries', () => {
expect(assets.count).toBe(2); expect(assets.count).toBe(2);
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
await scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 });
@ -361,11 +361,11 @@ describe('/libraries', () => {
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(newAssets.count).toBe(3); expect(newAssets.count).toBe(3);
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
}); });
it('should offline missing files', async () => { it('should offline a file missing from disk', async () => {
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
@ -374,7 +374,40 @@ describe('/libraries', () => {
await scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(3);
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(newAssets.count).toBe(3);
expect(newAssets.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: true,
originalFileName: 'assetC.png',
}),
]),
);
});
it('should offline a file outside of import paths', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
});
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await request(app)
.put(`/libraries/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] });
await scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
@ -383,6 +416,45 @@ describe('/libraries', () => {
expect(assets.items).toEqual( expect(assets.items).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({
isOffline: false,
originalFileName: 'assetA.png',
}),
expect.objectContaining({
isOffline: true,
originalFileName: 'assetB.png',
}),
]),
);
});
it('should offline a file covered by an exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp`],
});
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await request(app)
.put(`/libraries/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/directoryB/**'] });
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(2);
expect(assets.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: false,
originalFileName: 'assetA.png',
}),
expect.objectContaining({ expect.objectContaining({
isOffline: true, isOffline: true,
originalFileName: 'assetB.png', originalFileName: 'assetB.png',
@ -434,6 +506,8 @@ describe('/libraries', () => {
await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 });
expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true);
utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
}); });
it('should scan new files', async () => { it('should scan new files', async () => {
@ -445,14 +519,14 @@ describe('/libraries', () => {
await scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`);
await scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`);
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(3);
expect(assets.items).toEqual( expect(assets.items).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@ -460,6 +534,8 @@ describe('/libraries', () => {
}), }),
]), ]),
); );
utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`);
}); });
describe('with refreshModifiedFiles=true', () => { describe('with refreshModifiedFiles=true', () => {
@ -559,10 +635,11 @@ describe('/libraries', () => {
it('should remove offline files', async () => { it('should remove offline files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline2`], importPaths: [`${testAssetDirInternal}/temp/offline`],
}); });
utils.createImageFile(`${testAssetDir}/temp/offline2/assetA.png`); utils.createImageFile(`${testAssetDir}/temp/offline/online.png`);
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
@ -570,9 +647,9 @@ describe('/libraries', () => {
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, {
libraryId: library.id, libraryId: library.id,
}); });
expect(initialAssets.count).toBe(1); expect(initialAssets.count).toBe(2);
utils.removeImageFile(`${testAssetDir}/temp/offline2/assetA.png`); utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
@ -593,7 +670,54 @@ describe('/libraries', () => {
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(0); expect(assets.count).toBe(1);
utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`);
});
it('should remove offline files from trash', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline`],
});
utils.createImageFile(`${testAssetDir}/temp/offline/online.png`);
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, {
libraryId: library.id,
});
expect(initialAssets.count).toBe(2);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, {
libraryId: library.id,
isOffline: true,
});
expect(offlineAssets.count).toBe(1);
const { status } = await request(app)
.post(`/libraries/${library.id}/removeOffline`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
expect(assets.items[0].isOffline).toBe(false);
expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`);
utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`);
}); });
it('should not remove online files', async () => { it('should not remove online files', async () => {

View File

@ -159,4 +159,75 @@ describe('/map', () => {
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
}); });
}); });
describe('GET /map/reverse-geocode', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/reverse-geocode');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should throw an error if a lat is not provided', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lon=123')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
});
it('should throw an error if a lat is not a number', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
});
it('should throw an error if a lat is out of range', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
});
it('should throw an error if a lon is not provided', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180']));
});
const reverseGeocodeTestCases = [
{
name: 'Vaucluse',
lat: -33.858_977_058_663_13,
lon: 151.278_490_730_270_48,
results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }],
},
{
name: 'Ravenhall',
lat: -37.765_732_399_174_75,
lon: 144.752_453_164_883_3,
results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }],
},
{
name: 'Scarborough',
lat: -31.894_346_156_789_997,
lon: 115.757_617_103_904_64,
results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }],
},
];
it.each(reverseGeocodeTestCases)(`should resolve to $name`, async ({ lat, lon, results }) => {
const { status, body } = await request(app)
.get(`/map/reverse-geocode?lat=${lat}&lon=${lon}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(results.length);
expect(body).toEqual(results);
});
});
}); });

View File

@ -92,14 +92,14 @@ describe(`/oauth`, () => {
it('should return a redirect uri', async () => { it('should return a redirect uri', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/oauth/authorize') .post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2283/auth/login' }); .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(201); expect(status).toBe(201);
expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) }); expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) });
const params = new URL(body.url).searchParams; const params = new URL(body.url).searchParams;
expect(params.get('client_id')).toBe('client-default'); expect(params.get('client_id')).toBe('client-default');
expect(params.get('response_type')).toBe('code'); expect(params.get('response_type')).toBe('code');
expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2283/auth/login'); expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login');
expect(params.get('state')).toBeDefined(); expect(params.get('state')).toBeDefined();
}); });
}); });

View File

@ -6,10 +6,19 @@ import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const invalidBirthday = [ const invalidBirthday = [
{ birthDate: 'false', response: 'birthDate must be a date string' }, {
{ birthDate: '123567', response: 'birthDate must be a date string' }, birthDate: 'false',
{ birthDate: 123_567, response: 'birthDate must be a date string' }, response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
{ birthDate: new Date(9999, 0, 0).toISOString(), response: ['Birth date cannot be in the future'] }, },
{
birthDate: '123567',
response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
},
{
birthDate: 123_567,
response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
},
{ birthDate: '9999-01-01', response: ['Birth date cannot be in the future'] },
]; ];
describe('/people', () => { describe('/people', () => {
@ -185,13 +194,13 @@ describe('/people', () => {
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ .send({
name: 'New Person', name: 'New Person',
birthDate: '1990-01-01T05:00:00.000Z', birthDate: '1990-01-01',
}); });
expect(status).toBe(201); expect(status).toBe(201);
expect(body).toMatchObject({ expect(body).toMatchObject({
id: expect.any(String), id: expect.any(String),
name: 'New Person', name: 'New Person',
birthDate: '1990-01-01T05:00:00.000Z', birthDate: '1990-01-01',
}); });
}); });
}); });
@ -233,7 +242,7 @@ describe('/people', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`) .put(`/people/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: '1990-01-01T05:00:00.000Z' }); .send({ birthDate: '1990-01-01' });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01' }); expect(body).toMatchObject({ birthDate: '1990-01-01' });
}); });

View File

@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk'; import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@ -32,9 +32,6 @@ describe('/search', () => {
let assetOneJpg5: AssetMediaResponseDto; let assetOneJpg5: AssetMediaResponseDto;
let assetSprings: AssetMediaResponseDto; let assetSprings: AssetMediaResponseDto;
let assetLast: AssetMediaResponseDto; let assetLast: AssetMediaResponseDto;
let cities: string[];
let states: string[];
let countries: string[];
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
@ -85,7 +82,7 @@ describe('/search', () => {
// note: the coordinates here are not the actual coordinates of the images and are random for most of them // note: the coordinates here are not the actual coordinates of the images and are random for most of them
const coordinates = [ const coordinates = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris { latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 63.0695, longitude: -151.0074 }, // denali { latitude: 35.6895, longitude: 139.691_71 }, // tokyo
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin { latitude: 52.524_37, longitude: 13.410_53 }, // berlin
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore { latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul { latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
@ -101,16 +98,15 @@ describe('/search', () => {
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh { latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge { latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg { latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
]; ];
const updates = assets.map((asset, i) => const updates = coordinates.map((dto, i) =>
updateAsset({ id: asset.id, updateAssetDto: coordinates[i] }, { headers: asBearerAuth(admin.accessToken) }), updateAsset({ id: assets[i].id, updateAssetDto: dto }, { headers: asBearerAuth(admin.accessToken) }),
); );
await Promise.all(updates); await Promise.all(updates);
for (const asset of assets) { for (const [i] of coordinates.entries()) {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
} }
[ [
@ -137,12 +133,6 @@ describe('/search', () => {
assetLast = assets.at(-1) as AssetMediaResponseDto; assetLast = assets.at(-1) as AssetMediaResponseDto;
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
const mapMarkers = await getMapMarkers({}, { headers: asBearerAuth(admin.accessToken) });
const nonTrashed = mapMarkers.filter((mark) => mark.id !== assetSilver.id);
cities = [...new Set(nonTrashed.map((mark) => mark.city).filter((entry): entry is string => !!entry))].sort();
states = [...new Set(nonTrashed.map((mark) => mark.state).filter((entry): entry is string => !!entry))].sort();
countries = [...new Set(nonTrashed.map((mark) => mark.country).filter((entry): entry is string => !!entry))].sort();
}, 30_000); }, 30_000);
afterAll(async () => { afterAll(async () => {
@ -321,23 +311,120 @@ describe('/search', () => {
}, },
{ {
should: 'should search by city', should: 'should search by city',
deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }), deferred: () => ({
dto: {
city: 'Accra',
includeNull: true,
},
assets: [assetHeic],
}),
},
{
should: "should search city ('')",
deferred: () => ({
dto: {
city: '',
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search city (null)',
deferred: () => ({
dto: {
city: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
}, },
{ {
should: 'should search by state', should: 'should search by state',
deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }), deferred: () => ({
dto: {
state: 'New York',
includeNull: true,
},
assets: [assetDensity],
}),
},
{
should: "should search state ('')",
deferred: () => ({
dto: {
state: '',
isVisible: true,
withExif: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{
should: 'should search state (null)',
deferred: () => ({
dto: {
state: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
}, },
{ {
should: 'should search by country', should: 'should search by country',
deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }), deferred: () => ({
dto: {
country: 'France',
includeNull: true,
},
assets: [assetFalcon],
}),
},
{
should: "should search country ('')",
deferred: () => ({
dto: {
country: '',
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search country (null)',
deferred: () => ({
dto: {
country: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
}, },
{ {
should: 'should search by make', should: 'should search by make',
deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }), deferred: () => ({
dto: {
make: 'Canon',
includeNull: true,
},
assets: [assetFalcon, assetDenali],
}),
}, },
{ {
should: 'should search by model', should: 'should search by model',
deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }), deferred: () => ({
dto: {
model: 'Canon EOS 7D',
includeNull: true,
},
assets: [assetDenali],
}),
}, },
{ {
should: 'should allow searching the upload library (libraryId: null)', should: 'should allow searching the upload library (libraryId: null)',
@ -450,32 +537,79 @@ describe('/search', () => {
it('should get suggestions for country', async () => { it('should get suggestions for country', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=country') .get('/search/suggestions?type=country&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(countries); expect(body).toEqual([
'Cuba',
'France',
'Georgia',
'Germany',
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
null,
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for state', async () => { it('should get suggestions for state', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=state') .get('/search/suggestions?type=state&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(states.length); expect(body).toEqual([
expect(body).toEqual(expect.arrayContaining(states)); 'Andalusia',
'Berlin',
'Glarus',
'Greater Accra',
'Havana',
'Île-de-France',
'Marrakesh-Safi',
'Mississippi',
'New York',
'Shanghai',
'St.-Petersburg',
'Tbilisi',
'Tokyo',
'Virginia',
null,
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for city', async () => { it('should get suggestions for city', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=city') .get('/search/suggestions?type=city&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(cities); expect(body).toEqual([
'Accra',
'Berlin',
'Glarus',
'Havana',
'Marrakesh',
'Montalbán de Córdoba',
'New York City',
'Novena',
'Paris',
'Philadelphia',
'Saint Petersburg',
'Shanghai',
'Stanley',
'Tbilisi',
'Tokyo',
null,
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for camera make', async () => { it('should get suggestions for camera make', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=camera-make') .get('/search/suggestions?type=camera-make&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([ expect(body).toEqual([
'Apple', 'Apple',
@ -485,13 +619,14 @@ describe('/search', () => {
'PENTAX Corporation', 'PENTAX Corporation',
'samsung', 'samsung',
'SONY', 'SONY',
null,
]); ]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for camera model', async () => { it('should get suggestions for camera model', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=camera-model') .get('/search/suggestions?type=camera-model&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([ expect(body).toEqual([
'Canon EOS 7D', 'Canon EOS 7D',
@ -506,6 +641,7 @@ describe('/search', () => {
'SM-F711N', 'SM-F711N',
'SM-S906U', 'SM-S906U',
'SM-T970', 'SM-T970',
null,
]); ]);
expect(status).toBe(200); expect(status).toBe(200);
}); });

View File

@ -112,6 +112,13 @@ describe('/shared-links', () => {
expect(resp.header['content-type']).toContain('text/html'); expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`); expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
}); });
it('should have fqdn og:image meta tag for shared asset', async () => {
const resp = await request(shareUrl).get(`/${linkWithAssets.key}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(`<meta property="og:image" content="http://`);
});
}); });
describe('GET /shared-links', () => { describe('GET /shared-links', () => {

View File

@ -0,0 +1,211 @@
import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/stacks', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let asset: AssetMediaResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
asset = await utils.createAsset(user1.accessToken);
});
describe('POST /stacks', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/stacks')
.send({ assetIds: [asset.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require at least two assets', async () => {
const { status, body } = await request(app)
.post('/stacks')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.post('/stacks')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
it('should require access', async () => {
const user2Asset = await utils.createAsset(user2.accessToken);
const { status, body } = await request(app)
.post('/stacks')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset.id, user2Asset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should create a stack', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const { status, body } = await request(app)
.post('/stacks')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset1.id, asset2.id] });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
primaryAssetId: asset1.id,
assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })],
});
});
it('should merge an existing stack', async () => {
const [asset1, asset2, asset3] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const response1 = await request(app)
.post('/stacks')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset1.id, asset2.id] });
expect(response1.status).toBe(201);
const stacksBefore = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) });
const { status, body } = await request(app)
.post('/stacks')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset1.id, asset3.id] });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
primaryAssetId: asset1.id,
assets: expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
expect.objectContaining({ id: asset3.id }),
]),
});
const stacksAfter = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) });
expect(stacksAfter.length).toBe(stacksBefore.length);
});
// it('should require a valid parent id', async () => {
// const { status, body } = await request(app)
// .put('/assets')
// .set('Authorization', `Bearer ${user1.accessToken}`)
// .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID']));
// });
});
// it('should require access to the parent', async () => {
// const { status, body } = await request(app)
// .put('/assets')
// .set('Authorization', `Bearer ${user1.accessToken}`)
// .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.noPermission);
// });
// it('should add stack children', async () => {
// const { status } = await request(app)
// .put('/assets')
// .set('Authorization', `Bearer ${stackUser.accessToken}`)
// .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] });
// expect(status).toBe(204);
// const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
// expect(asset.stack).not.toBeUndefined();
// expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })]));
// });
// it('should remove stack children', async () => {
// const { status } = await request(app)
// .put('/assets')
// .set('Authorization', `Bearer ${stackUser.accessToken}`)
// .send({ removeParent: true, ids: [stackAssets[1].id] });
// expect(status).toBe(204);
// const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
// expect(asset.stack).not.toBeUndefined();
// expect(asset.stack).toEqual(
// expect.arrayContaining([
// expect.objectContaining({ id: stackAssets[2].id }),
// expect.objectContaining({ id: stackAssets[3].id }),
// ]),
// );
// });
// it('should remove all stack children', async () => {
// const { status } = await request(app)
// .put('/assets')
// .set('Authorization', `Bearer ${stackUser.accessToken}`)
// .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] });
// expect(status).toBe(204);
// const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
// expect(asset.stack).toBeUndefined();
// });
// it('should merge stack children', async () => {
// // create stack after previous test removed stack children
// await updateAssets(
// { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } },
// { headers: asBearerAuth(stackUser.accessToken) },
// );
// const { status } = await request(app)
// .put('/assets')
// .set('Authorization', `Bearer ${stackUser.accessToken}`)
// .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] });
// expect(status).toBe(204);
// const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) });
// expect(asset.stack).not.toBeUndefined();
// expect(asset.stack).toEqual(
// expect.arrayContaining([
// expect.objectContaining({ id: stackAssets[0].id }),
// expect.objectContaining({ id: stackAssets[1].id }),
// expect.objectContaining({ id: stackAssets[2].id }),
// ]),
// );
// });
});

View File

@ -0,0 +1,603 @@
import {
AssetMediaResponseDto,
LoginResponseDto,
Permission,
TagCreateDto,
TagResponseDto,
createTag,
getAllTags,
tagAssets,
upsertTags,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const create = (accessToken: string, dto: TagCreateDto) =>
createTag({ tagCreateDto: dto }, { headers: asBearerAuth(accessToken) });
const upsert = (accessToken: string, tags: string[]) =>
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) });
describe('/tags', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let userAsset: AssetMediaResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
userAsset = await utils.createAsset(user.accessToken);
});
beforeEach(async () => {
// tagging assets eventually triggers metadata extraction which can impact other tests
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.resetDatabase(['tags']);
});
describe('POST /tags', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/tags').send({ name: 'TagA' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.create'));
});
it('should work with tag.create', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]);
const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
expect(body).toEqual({
id: expect.any(String),
name: 'TagA',
value: 'TagA',
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(status).toBe(201);
});
it('should create a tag', async () => {
const { status, body } = await request(app)
.post('/tags')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'TagA' });
expect(body).toEqual({
id: expect.any(String),
name: 'TagA',
value: 'TagA',
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(status).toBe(201);
});
it('should allow multiple users to create tags with the same value', async () => {
await create(admin.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.post('/tags')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ name: 'TagA' });
expect(body).toEqual({
id: expect.any(String),
name: 'TagA',
value: 'TagA',
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(status).toBe(201);
});
it('should create a nested tag', async () => {
const parent = await create(admin.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.post('/tags')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'TagB', parentId: parent.id });
expect(body).toEqual({
id: expect.any(String),
parentId: parent.id,
name: 'TagB',
value: 'TagA/TagB',
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(status).toBe(201);
});
});
describe('GET /tags', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/tags');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).get('/tags').set('x-api-key', secret);
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.read'));
});
it('should start off empty', async () => {
const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([]);
expect(status).toEqual(200);
});
it('should return a list of tags', async () => {
const [tagA, tagB, tagC] = await Promise.all([
create(admin.accessToken, { name: 'TagA' }),
create(admin.accessToken, { name: 'TagB' }),
create(admin.accessToken, { name: 'TagC' }),
]);
const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(3);
expect(body).toEqual([tagA, tagB, tagC]);
expect(status).toEqual(200);
});
it('should return a nested tags', async () => {
await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']);
const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(4);
expect(status).toEqual(200);
const tags = body as TagResponseDto[];
const tagA = tags.find((tag) => tag.value === 'TagA') as TagResponseDto;
const tagB = tags.find((tag) => tag.value === 'TagA/TagB') as TagResponseDto;
const tagC = tags.find((tag) => tag.value === 'TagA/TagB/TagC') as TagResponseDto;
const tagD = tags.find((tag) => tag.value === 'TagD') as TagResponseDto;
expect(tagA).toEqual(expect.objectContaining({ name: 'TagA', value: 'TagA' }));
expect(tagB).toEqual(expect.objectContaining({ name: 'TagB', value: 'TagA/TagB', parentId: tagA.id }));
expect(tagC).toEqual(expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC', parentId: tagB.id }));
expect(tagD).toEqual(expect.objectContaining({ name: 'TagD', value: 'TagD' }));
});
});
describe('PUT /tags', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.create'));
});
it('should upsert tags', async () => {
const { status, body } = await request(app)
.put(`/tags`)
.send({ tags: ['TagA/TagB/TagC/TagD'] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]);
});
it('should upsert tags in parallel without conflicts', async () => {
const [[tag1], [tag2], [tag3], [tag4]] = await Promise.all([
upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']),
]);
const { id, parentId, createdAt } = tag1;
for (const tag of [tag1, tag2, tag3, tag4]) {
expect(tag).toMatchObject({
id,
parentId,
createdAt,
name: 'TagD',
value: 'TagA/TagB/TagC/TagD',
});
}
});
});
describe('PUT /tags/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.put('/tags/assets')
.set('x-api-key', secret)
.send({ assetIds: [], tagIds: [] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
});
it('should skip assets that are not owned by the user', async () => {
const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
create(user.accessToken, { name: 'TagA' }),
create(user.accessToken, { name: 'TagB' }),
create(user.accessToken, { name: 'TagC' }),
utils.createAsset(user.accessToken),
utils.createAsset(admin.accessToken),
]);
const { status, body } = await request(app)
.put(`/tags/assets`)
.send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 3 });
});
it('should skip tags that are not owned by the user', async () => {
const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
create(user.accessToken, { name: 'TagA' }),
create(user.accessToken, { name: 'TagB' }),
create(admin.accessToken, { name: 'TagC' }),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
const { status, body } = await request(app)
.put(`/tags/assets`)
.send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 4 });
});
it('should bulk tag assets', async () => {
const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
create(user.accessToken, { name: 'TagA' }),
create(user.accessToken, { name: 'TagB' }),
create(user.accessToken, { name: 'TagC' }),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
const { status, body } = await request(app)
.put(`/tags/assets`)
.send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 6 });
});
});
describe('GET /tags/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.get(`/tags/${tag.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.get(`/tags/${uuidDto.notFound}`)
.set('x-api-key', secret)
.send({ assetIds: [], tagIds: [] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.read'));
});
it('should require a valid uuid', async () => {
const { status, body } = await request(app)
.get(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should get tag details', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.get(`/tags/${tag.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
id: expect.any(String),
name: 'TagA',
value: 'TagA',
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
it('should get nested tag details', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
const tagC = await create(user.accessToken, { name: 'TagC', parentId: tagB.id });
const tagD = await create(user.accessToken, { name: 'TagD', parentId: tagC.id });
const { status, body } = await request(app)
.get(`/tags/${tagD.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
id: expect.any(String),
parentId: tagC.id,
name: 'TagD',
value: 'TagA/TagB/TagC/TagD',
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
});
describe('PUT /tags/:id', () => {
it('should require authentication', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const tag = await create(admin.accessToken, { name: 'tagA' });
const { status, body } = await request(app)
.put(`/tags/${tag.id}`)
.send({ color: '#000000' })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.put(`/tags/${tag.id}`)
.set('x-api-key', secret)
.send({ color: '#000000' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.update'));
});
it('should update a tag', async () => {
const tag = await create(user.accessToken, { name: 'tagA' });
const { status, body } = await request(app)
.put(`/tags/${tag.id}`)
.send({ color: '#000000' })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ color: `#000000` }));
});
it('should update a tag color without a # prefix', async () => {
const tag = await create(user.accessToken, { name: 'tagA' });
const { status, body } = await request(app)
.put(`/tags/${tag.id}`)
.send({ color: '000000' })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ color: `#000000` }));
});
});
describe('DELETE /tags/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.delete(`/tags/${tag.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret);
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.delete'));
});
it('should require a valid uuid', async () => {
const { status, body } = await request(app)
.delete(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should delete a tag', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status } = await request(app)
.delete(`/tags/${tag.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(204);
});
it('should delete a nested tag (root)', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
const { status } = await request(app)
.delete(`/tags/${tagA.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(204);
const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) });
expect(tags.length).toBe(0);
});
it('should delete a nested tag (leaf)', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
const { status } = await request(app)
.delete(`/tags/${tagB.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(204);
const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) });
expect(tags.length).toBe(1);
expect(tags[0]).toEqual(tagA);
});
});
describe('PUT /tags/:id/assets', () => {
it('should require authentication', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.put(`/tags/${tagA.id}/assets`)
.send({ ids: [userAsset.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.put(`/tags/${tag.id}/assets`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [userAsset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.put(`/tags/${tag.id}/assets`)
.set('x-api-key', secret)
.send({ ids: [userAsset.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
});
it('should be able to tag own asset', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.put(`/tags/${tagA.id}/assets`)
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ ids: [userAsset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
});
it("should not be able to add assets to another user's tag", async () => {
const tagA = await create(admin.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.put(`/tags/${tagA.id}/assets`)
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ ids: [userAsset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access'));
});
it('should add duplicate assets only once', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.put(`/tags/${tagA.id}/assets`)
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ ids: [userAsset.id, userAsset.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: userAsset.id, success: true }),
expect.objectContaining({ id: userAsset.id, success: false, error: 'duplicate' }),
]);
});
});
describe('DELETE /tags/:id/assets', () => {
it('should require authentication', async () => {
const tagA = await create(admin.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.delete(`/tags/${tagA}/assets`)
.send({ ids: [userAsset.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(
{ id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
{ headers: asBearerAuth(user.accessToken) },
);
const { status, body } = await request(app)
.delete(`/tags/${tagA.id}/assets`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [userAsset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.delete(`/tags/${tag.id}/assets`)
.set('x-api-key', secret)
.send({ ids: [userAsset.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
});
it('should be able to remove own asset from own tag', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(
{ id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
{ headers: asBearerAuth(user.accessToken) },
);
const { status, body } = await request(app)
.delete(`/tags/${tagA.id}/assets`)
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ ids: [userAsset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
});
it('should remove duplicate assets only once', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(
{ id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
{ headers: asBearerAuth(user.accessToken) },
);
const { status, body } = await request(app)
.delete(`/tags/${tagA.id}/assets`)
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ ids: [userAsset.id, userAsset.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: userAsset.id, success: true }),
expect.objectContaining({ id: userAsset.id, success: false, error: 'not_found' }),
]);
});
});
});

View File

@ -42,6 +42,23 @@ describe('/trash', () => {
const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) });
expect(after.total).toBe(0); expect(after.total).toBe(0);
}); });
it('should empty the trash with archived assets', async () => {
const { id: assetId } = await utils.createAsset(admin.accessToken);
await utils.archiveAssets(admin.accessToken, [assetId]);
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, isArchived: true }));
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);
});
}); });
describe('POST /trash/restore', () => { describe('POST /trash/restore', () => {

View File

@ -1,11 +1,11 @@
import { import {
LoginResponseDto, LoginResponseDto,
createStack,
deleteUserAdmin, deleteUserAdmin,
getMyUser, getMyUser,
getUserAdmin, getUserAdmin,
getUserPreferencesAdmin, getUserPreferencesAdmin,
login, login,
updateAssets,
} from '@immich/sdk'; } from '@immich/sdk';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
@ -321,8 +321,8 @@ describe('/admin/users', () => {
utils.createAsset(user.accessToken), utils.createAsset(user.accessToken),
]); ]);
await updateAssets( await createStack(
{ assetBulkUpdateDto: { stackParentId: asset1.id, ids: [asset2.id] } }, { stackCreateDto: { assetIds: [asset1.id, asset2.id] } },
{ headers: asBearerAuth(user.accessToken) }, { headers: asBearerAuth(user.accessToken) },
); );

View File

@ -236,6 +236,32 @@ describe('/users', () => {
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } });
}); });
it('should require a boolean for download include embedded videos', async () => {
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ download: { includeEmbeddedVideos: 1_234_567.89 } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value']));
});
it('should update download include embedded videos', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } });
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ download: { includeEmbeddedVideos: true } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
});
}); });
describe('GET /users/:id', () => { describe('GET /users/:id', () => {

View File

@ -1,3 +1,4 @@
import { Permission } from '@immich/sdk';
import { stat } from 'node:fs/promises'; import { stat } from 'node:fs/promises';
import { app, immichCli, utils } from 'src/utils'; import { app, immichCli, utils } from 'src/utils';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
@ -29,10 +30,10 @@ describe(`immich login`, () => {
it('should login and save auth.yml with 600', async () => { it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup(); const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken); const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]); const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([ expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283/api', 'Logging in to http://127.0.0.1:2285/api',
'Logged in as admin@immich.cloud', 'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml', 'Wrote auth info to /tmp/immich/auth.yml',
]); ]);
@ -46,11 +47,11 @@ describe(`immich login`, () => {
it('should login without /api in the url', async () => { it('should login without /api in the url', async () => {
const admin = await utils.adminSetup(); const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken); const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]); const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
expect(stdout.split('\n')).toEqual([ expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283', 'Logging in to http://127.0.0.1:2285',
'Discovered API at http://127.0.0.1:2283/api', 'Discovered API at http://127.0.0.1:2285/api',
'Logged in as admin@immich.cloud', 'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml', 'Wrote auth info to /tmp/immich/auth.yml',
]); ]);

View File

@ -12,7 +12,7 @@ describe(`immich server-info`, () => {
const { stderr, stdout, exitCode } = await immichCli(['server-info']); const { stderr, stdout, exitCode } = await immichCli(['server-info']);
expect(stdout.split('\n')).toEqual([ expect(stdout.split('\n')).toEqual([
expect.stringContaining('Server Info (via admin@immich.cloud'), expect.stringContaining('Server Info (via admin@immich.cloud'),
' Url: http://127.0.0.1:2283/api', ' Url: http://127.0.0.1:2285/api',
expect.stringContaining('Version:'), expect.stringContaining('Version:'),
' Formats:', ' Formats:',
expect.stringContaining('Images:'), expect.stringContaining('Images:'),

View File

@ -13,6 +13,12 @@ export const errorDto = {
message: expect.any(String), message: expect.any(String),
correlationId: expect.any(String), correlationId: expect.any(String),
}, },
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: { wrongPassword: {
error: 'Bad Request', error: 'Bad Request',
statusCode: 400, statusCode: 400,

View File

@ -86,14 +86,14 @@ const setup = async () => {
{ {
client_id: OAuthClient.DEFAULT, client_id: OAuthClient.DEFAULT,
client_secret: OAuthClient.DEFAULT, client_secret: OAuthClient.DEFAULT,
redirect_uris: ['http://127.0.0.1:2283/auth/login'], redirect_uris: ['http://127.0.0.1:2285/auth/login'],
grant_types: ['authorization_code'], grant_types: ['authorization_code'],
response_types: ['code'], response_types: ['code'],
}, },
{ {
client_id: OAuthClient.RS256_TOKENS, client_id: OAuthClient.RS256_TOKENS,
client_secret: OAuthClient.RS256_TOKENS, client_secret: OAuthClient.RS256_TOKENS,
redirect_uris: ['http://127.0.0.1:2283/auth/login'], redirect_uris: ['http://127.0.0.1:2285/auth/login'],
grant_types: ['authorization_code'], grant_types: ['authorization_code'],
id_token_signed_response_alg: 'RS256', id_token_signed_response_alg: 'RS256',
jwks: { keys: [await exportJWK(publicKey)] }, jwks: { keys: [await exportJWK(publicKey)] },
@ -101,7 +101,7 @@ const setup = async () => {
{ {
client_id: OAuthClient.RS256_PROFILE, client_id: OAuthClient.RS256_PROFILE,
client_secret: OAuthClient.RS256_PROFILE, client_secret: OAuthClient.RS256_PROFILE,
redirect_uris: ['http://127.0.0.1:2283/auth/login'], redirect_uris: ['http://127.0.0.1:2285/auth/login'],
grant_types: ['authorization_code'], grant_types: ['authorization_code'],
userinfo_signed_response_alg: 'RS256', userinfo_signed_response_alg: 'RS256',
jwks: { keys: [await exportJWK(publicKey)] }, jwks: { keys: [await exportJWK(publicKey)] },

View File

@ -7,6 +7,7 @@ import {
CreateAlbumDto, CreateAlbumDto,
CreateLibraryDto, CreateLibraryDto,
MetadataSearchDto, MetadataSearchDto,
Permission,
PersonCreateDto, PersonCreateDto,
SharedLinkCreateDto, SharedLinkCreateDto,
UserAdminCreateDto, UserAdminCreateDto,
@ -29,6 +30,7 @@ import {
signUpAdmin, signUpAdmin,
updateAdminOnboarding, updateAdminOnboarding,
updateAlbumUser, updateAlbumUser,
updateAssets,
updateConfig, updateConfig,
validate, validate,
} from '@immich/sdk'; } from '@immich/sdk';
@ -52,8 +54,8 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
type AdminSetupOptions = { onboarding?: boolean }; type AdminSetupOptions = { onboarding?: boolean };
type FileData = { bytes?: Buffer; filename: string }; type FileData = { bytes?: Buffer; filename: string };
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5433/immich'; const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
export const baseUrl = 'http://127.0.0.1:2283'; export const baseUrl = 'http://127.0.0.1:2285';
export const shareUrl = `${baseUrl}/share`; export const shareUrl = `${baseUrl}/share`;
export const app = `${baseUrl}/api`; export const app = `${baseUrl}/api`;
// TODO move test assets into e2e/assets // TODO move test assets into e2e/assets
@ -147,6 +149,7 @@ export const utils = {
'sessions', 'sessions',
'users', 'users',
'system_metadata', 'system_metadata',
'tags',
]; ];
const sql: string[] = []; const sql: string[] = [];
@ -279,8 +282,8 @@ export const utils = {
}); });
}, },
createApiKey: (accessToken: string) => { createApiKey: (accessToken: string, permissions: Permission[]) => {
return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) }); return createApiKey({ apiKeyCreateDto: { name: 'e2e', permissions } }, { headers: asBearerAuth(accessToken) });
}, },
createAlbum: (accessToken: string, dto: CreateAlbumDto) => createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
@ -387,6 +390,9 @@ export const utils = {
return searchMetadata({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) }); return searchMetadata({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
}, },
archiveAssets: (accessToken: string, ids: string[]) =>
updateAssets({ assetBulkUpdateDto: { ids, isArchived: true } }, { headers: asBearerAuth(accessToken) }),
deleteAssets: (accessToken: string, ids: string[]) => deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
@ -424,12 +430,12 @@ export const utils = {
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }), createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string) => setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
await context.addCookies([ await context.addCookies([
{ {
name: 'immich_access_token', name: 'immich_access_token',
value: accessToken, value: accessToken,
domain: '127.0.0.1', domain,
path: '/', path: '/',
expires: 1_742_402_728, expires: 1_742_402_728,
httpOnly: true, httpOnly: true,
@ -439,7 +445,7 @@ export const utils = {
{ {
name: 'immich_auth_type', name: 'immich_auth_type',
value: 'password', value: 'password',
domain: '127.0.0.1', domain,
path: '/', path: '/',
expires: 1_742_402_728, expires: 1_742_402_728,
httpOnly: true, httpOnly: true,
@ -449,7 +455,7 @@ export const utils = {
{ {
name: 'immich_is_authenticated', name: 'immich_is_authenticated',
value: 'true', value: 'true',
domain: '127.0.0.1', domain,
path: '/', path: '/',
expires: 1_742_402_728, expires: 1_742_402_728,
httpOnly: false, httpOnly: false,
@ -492,7 +498,7 @@ export const utils = {
}, },
cliLogin: async (accessToken: string) => { cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken); const key = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, `${key.secret}`]); await immichCli(['login', app, `${key.secret}`]);
return key.secret; return key.secret;
}, },

View File

@ -0,0 +1,25 @@
import { LoginResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe('Album', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test(`doesn't delete album after canceling add assets`, async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/albums');
await page.getByRole('button', { name: 'Create album' }).click();
await page.getByRole('button', { name: 'Select photos' }).click();
await page.getByRole('button', { name: 'Close' }).click();
await page.reload();
await page.getByRole('button', { name: 'Select photos' }).waitFor();
});
});

View File

@ -1,16 +1,23 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
test.describe('Detail Panel', () => { test.describe('Detail Panel', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetMediaResponseDto; let asset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => { test.beforeAll(async () => {
utils.initSdk(); utils.initSdk();
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken); asset = await utils.createAsset(admin.accessToken);
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
}); });
test('can be opened for shared links', async ({ page }) => { test('can be opened for shared links', async ({ page }) => {
@ -57,4 +64,23 @@ test.describe('Detail Panel', () => {
await expect(textarea).toBeVisible(); await expect(textarea).toBeVisible();
await expect(textarea).not.toBeDisabled(); await expect(textarea).not.toBeDisabled();
}); });
test('description changes are visible after reopening', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'Info' }).click();
const textarea = page.getByRole('textbox', { name: 'Add a description' });
await textarea.fill('new description');
await expect(textarea).toHaveValue('new description');
await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).not.toBeVisible();
await page.getByRole('button', { name: 'Info' }).click();
await expect(textarea).toBeVisible();
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await expect(textarea).toHaveValue('new description');
});
}); });

View File

@ -10,6 +10,9 @@ test.describe('Asset Viewer Navbar', () => {
utils.initSdk(); utils.initSdk();
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
});
test.beforeEach(async () => {
asset = await utils.createAsset(admin.accessToken); asset = await utils.createAsset(admin.accessToken);
}); });
@ -49,4 +52,14 @@ test.describe('Asset Viewer Navbar', () => {
} }
}); });
}); });
test.describe('actions', () => {
test('favorite asset with shortcut', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.keyboard.press('f');
await expect(page.locator('#notification-list').getByTestId('message')).toHaveText('Added to favorites');
});
});
}); });

View File

@ -0,0 +1,56 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, type Page, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe('Slideshow', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
});
const openSlideshow = async (page: Page) => {
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'More' }).click();
await page.getByRole('menuitem', { name: 'Slideshow' }).click();
};
test('open slideshow', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
});
test('exit slideshow with button', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
await exitButton.click();
await expect(exitButton).not.toBeVisible();
});
test('exit slideshow with shortcut', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
await expect(exitButton).toBeVisible();
await page.keyboard.press('Escape');
await expect(exitButton).not.toBeVisible();
});
test('favorite shortcut is disabled', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
await page.keyboard.press('f');
await expect(page.locator('#notification-list')).not.toBeVisible();
});
});

View File

@ -13,7 +13,7 @@ test.describe('Registration', () => {
test('admin registration', async ({ page }) => { test('admin registration', async ({ page }) => {
// welcome // welcome
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: 'Getting Started' }).click(); await page.getByRole('link', { name: 'Getting Started' }).click();
// register // register
await expect(page).toHaveTitle(/Admin Registration/); await expect(page).toHaveTitle(/Admin Registration/);
@ -33,6 +33,7 @@ test.describe('Registration', () => {
// onboarding // onboarding
await expect(page).toHaveURL('/auth/onboarding'); await expect(page).toHaveURL('/auth/onboarding');
await page.getByRole('button', { name: 'Theme' }).click(); await page.getByRole('button', { name: 'Theme' }).click();
await page.getByRole('button', { name: 'Privacy' }).click();
await page.getByRole('button', { name: 'Storage Template' }).click(); await page.getByRole('button', { name: 'Storage Template' }).click();
await page.getByRole('button', { name: 'Done' }).click(); await page.getByRole('button', { name: 'Done' }).click();

View File

@ -25,7 +25,7 @@ test.describe('Photo Viewer', () => {
test('initially shows a loading spinner', async ({ page }) => { test('initially shows a loading spinner', async ({ page }) => {
await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => { await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => {
// slow down the request for thumbnail, so spiner has chance to show up // slow down the request for thumbnail, so spinner has chance to show up
await new Promise((f) => setTimeout(f, 2000)); await new Promise((f) => setTimeout(f, 2000));
await route.continue(); await route.continue();
}); });
@ -33,14 +33,14 @@ test.describe('Photo Viewer', () => {
await page.waitForLoadState('load'); await page.waitForLoadState('load');
// this is the spinner // this is the spinner
await page.waitForSelector('svg[role=status]'); await page.waitForSelector('svg[role=status]');
await expect(page.getByRole('status')).toBeVisible(); await expect(page.getByTestId('loading-spinner')).toBeVisible();
}); });
test('loads high resolution photo when zoomed', async ({ page }) => { test('loads high resolution photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox(); const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy; expect(box).toBeTruthy();
const { x, y, width, height } = box!; const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2); await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);

View File

@ -44,7 +44,7 @@ test.describe('Shared Links', () => {
test('download from a shared link', async ({ page }) => { test('download from a shared link', async ({ page }) => {
await page.goto(`/share/${sharedLink.key}`); await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator('.group > div').first().hover(); await page.locator('.group').first().hover();
await page.waitForSelector('#asset-group-by-date svg'); await page.waitForSelector('#asset-group-by-date svg');
await page.getByRole('checkbox').click(); await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();

View File

@ -0,0 +1,25 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe('Websocket', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('connects using ipv4', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('http://127.0.0.1:2285/');
await expect(page.locator('#sidebar')).toContainText('Server Online');
});
test('connects using ipv6', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken, '[::1]');
await page.goto('http://[::1]:2285/');
await expect(page.locator('#sidebar')).toContainText('Server Online');
});
});

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