Compare commits

..

2 Commits

Author SHA1 Message Date
Jason Rasmussen
6a4f48204d chore: pr feedback 2025-02-14 11:36:01 -05:00
Jason Rasmussen
9cd0871178 feat(web): rotate image 2025-02-14 11:17:51 -05:00
120 changed files with 2440 additions and 1832 deletions

View File

@@ -29,11 +29,9 @@ jobs:
filters: | filters: |
mobile: mobile:
- 'mobile/**' - 'mobile/**'
workflow:
- '.github/workflows/build-mobile.yml'
- name: Check if we should force jobs to run - name: Check if we should force jobs to run
id: should_force id: should_force
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" 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

73
.github/workflows/docker-cleanup.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
# This workflow runs on certain conditions to check for and potentially
# delete container images from the GHCR which no longer have an associated
# code branch.
# Requires a PAT with the correct scope set in the secrets.
#
# This workflow will not trigger runs on forked repos.
name: Docker Cleanup
on:
pull_request:
types:
- "closed"
push:
paths:
- ".github/workflows/docker-cleanup.yml"
concurrency:
group: registry-tags-cleanup
cancel-in-progress: false
jobs:
cleanup-images:
name: Cleanup Stale Images Tags for ${{ matrix.primary-name }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- primary-name: "immich-server"
- primary-name: "immich-machine-learning"
env:
# Requires a personal access token with the OAuth scope delete:packages
TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }}
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
is_org: "true"
do_delete: "true"
package_name: "${{ matrix.primary-name }}"
scheme: "pull_request"
repo_name: "immich"
match_regex: '^pr-(\d+)$|^(\d+)$'
cleanup-untagged-images:
name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }}
runs-on: ubuntu-24.04
needs:
- cleanup-images
strategy:
fail-fast: false
matrix:
include:
- primary-name: "immich-server"
- primary-name: "immich-machine-learning"
- primary-name: "immich-build-cache"
env:
# Requires a personal access token with the OAuth scope delete:packages
TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }}
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.9.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
do_delete: "true"
is_org: "true"
package_name: "${{ matrix.primary-name }}"

View File

@@ -36,12 +36,10 @@ jobs:
- 'i18n/**' - 'i18n/**'
machine-learning: machine-learning:
- 'machine-learning/**' - 'machine-learning/**'
workflow:
- '.github/workflows/docker.yml'
- name: Check if we should force jobs to run - name: Check if we should force jobs to run
id: should_force id: should_force
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" run: echo "should_force=${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
retag_ml: retag_ml:
name: Re-Tag ML name: Re-Tag ML
@@ -63,10 +61,8 @@ jobs:
REGISTRY_NAME="ghcr.io" REGISTRY_NAME="ghcr.io"
REPOSITORY=${{ github.repository_owner }}/immich-machine-learning REPOSITORY=${{ github.repository_owner }}/immich-machine-learning
TAG_OLD=main${{ matrix.suffix }} TAG_OLD=main${{ matrix.suffix }}
TAG_PR=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
TAG_COMMIT=commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_PR $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_COMMIT $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
retag_server: retag_server:
name: Re-Tag Server name: Re-Tag Server
@@ -88,100 +84,106 @@ jobs:
REGISTRY_NAME="ghcr.io" REGISTRY_NAME="ghcr.io"
REPOSITORY=${{ github.repository_owner }}/immich-server REPOSITORY=${{ github.repository_owner }}/immich-server
TAG_OLD=main${{ matrix.suffix }} TAG_OLD=main${{ matrix.suffix }}
TAG_PR=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
TAG_COMMIT=commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_PR $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_COMMIT $REGISTRY_NAME/$REPOSITORY:$TAG_OLD
build_and_push_ml: build_and_push_ml:
name: Build and Push ML name: Build and Push ML
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }} if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
runs-on: ${{ matrix.runner }} runs-on: ubuntu-latest
env: env:
image: immich-machine-learning image: immich-machine-learning
context: machine-learning context: machine-learning
file: machine-learning/Dockerfile file: machine-learning/Dockerfile
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
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:
- platform: linux/amd64 - platforms: linux/amd64,linux/arm64
runner: ubuntu-latest
device: cpu device: cpu
- platform: linux/arm64 - platforms: linux/amd64
runner: ubuntu-24.04-arm
device: cpu
- platform: linux/amd64
runner: ubuntu-latest
device: cuda device: cuda
suffix: -cuda suffix: -cuda
- platform: linux/amd64 - platforms: linux/amd64
runner: ubuntu-latest
device: openvino device: openvino
suffix: -openvino suffix: -openvino
- platform: linux/arm64 - platforms: linux/arm64
runner: ubuntu-24.04-arm
device: armnn device: armnn
suffix: -armnn suffix: -armnn
steps: steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.4.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.9.0 uses: docker/setup-buildx-action@v3.9.0
- 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 - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache key suffix - name: Generate docker image tags
run: | id: metadata
if [[ "${{ github.event_name }}" == "pull_request" ]]; then uses: docker/metadata-action@v5
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV with:
else flavor: |
echo "CACHE_KEY_SUFFIX=$(echo ${{ github.ref_name }} | sed 's/[^a-zA-Z0-9]/-/g')" >> $GITHUB_ENV # Disable latest tag
fi 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: Generate cache target - name: Determine build cache output
id: cache-target id: cache-target
run: | run: |
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Essentially just ignore the cache output (forks 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,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }},mode=max,compression=zstd" >> $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
id: build
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.13.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
platforms: ${{ matrix.platforms }} platforms: ${{ matrix.platforms }}
labels: ${{ steps.metadata.outputs.labels }} # 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 }}
cache-from: | tags: ${{ steps.metadata.outputs.tags }}
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }} labels: ${{ steps.metadata.outputs.labels }}
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-main
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
build-args: | build-args: |
DEVICE=${{ matrix.device }} DEVICE=${{ matrix.device }}
BUILD_ID=${{ github.run_id }} BUILD_ID=${{ github.run_id }}
@@ -189,90 +191,6 @@ jobs:
BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }} BUILD_SOURCE_COMMIT=${{ github.sha }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: ml-digests-${{ matrix.device }}-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge_ml:
name: Merge & Push ML
runs-on: ubuntu-latest
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' && !github.event.pull_request.head.repo.fork }}
env:
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
DOCKER_REPO: altran1502/immich-machine-learning
strategy:
matrix:
include:
- device: cpu
- device: cuda
suffix: -cuda
- device: openvino
suffix: -openvino
- device: armnn
suffix: -armnn
needs:
- build_and_push_ml
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: ml-digests-${{ matrix.device }}-*
merge-multiple: true
- name: Login to Docker Hub
if: ${{ github.event_name == 'release' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@v5
with:
flavor: |
# Disable latest tag
latest=false
images: |
name=${{ env.GHCR_REPO }}
name=${{ env.DOCKER_REPO }},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 long commit sha hash
type=sha,format=long,prefix=commit-,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: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
build_and_push_server: build_and_push_server:
name: Build and Push Server name: Build and Push Server
@@ -284,6 +202,7 @@ jobs:
context: . context: .
file: server/Dockerfile file: server/Dockerfile
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server
DOCKER_REPO: altran1502/immich-server
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -304,32 +223,22 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache key suffix
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
else
echo "CACHE_KEY_SUFFIX=$(echo ${{ github.ref_name }} | sed 's/[^a-zA-Z0-9]/-/g')" >> $GITHUB_ENV
fi
- name: Generate cache target
id: cache-target
run: |
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
# Essentially just ignore the cache output (forks 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,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }},mode=max,compression=zstd" >> $GITHUB_OUTPUT
fi
- name: Build and push image - name: Build and push image
id: build id: build
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.13.0
@@ -337,12 +246,10 @@ jobs:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
# Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}
cache-to: ${{ steps.cache-target.outputs.cache-to }} outputs: type=image,"name=${{ env.GHCR_REPO }},${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: |
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ env.CACHE_KEY_SUFFIX }}
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-main
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
build-args: | build-args: |
DEVICE=cpu DEVICE=cpu
BUILD_ID=${{ github.run_id }} BUILD_ID=${{ github.run_id }}
@@ -359,7 +266,7 @@ jobs:
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: server-digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -378,11 +285,10 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: server-digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: Login to Docker Hub - name: Login to Docker Hub
if: ${{ github.event_name == 'release' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -413,8 +319,6 @@ jobs:
type=ref,event=branch,suffix=${{ matrix.suffix }} type=ref,event=branch,suffix=${{ matrix.suffix }}
# Tag with pr-number # Tag with pr-number
type=ref,event=pr,suffix=${{ matrix.suffix }} type=ref,event=pr,suffix=${{ matrix.suffix }}
# Tag with long commit sha hash
type=sha,format=long,prefix=commit-,suffix=${{ matrix.suffix }}
# Tag with git tag on release # Tag with git tag on release
type=ref,event=tag,suffix=${{ matrix.suffix }} type=ref,event=tag,suffix=${{ matrix.suffix }}
type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }} type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }}
@@ -423,7 +327,8 @@ jobs:
working-directory: ${{ runner.temp }}/digests working-directory: ${{ runner.temp }}/digests
run: | run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
success-check-server: success-check-server:
name: Docker Build & Push Server Success name: Docker Build & Push Server Success
@@ -440,7 +345,7 @@ jobs:
success-check-ml: success-check-ml:
name: Docker Build & Push ML Success name: Docker Build & Push ML Success
needs: [merge_ml, retag_ml] needs: [build_and_push_ml, retag_ml]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
steps: steps:

View File

@@ -15,7 +15,7 @@ jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -25,11 +25,9 @@ jobs:
filters: | filters: |
docs: docs:
- 'docs/**' - 'docs/**'
workflow:
- '.github/workflows/docs-build.yml'
- name: Check if we should force jobs to run - name: Check if we should force jobs to run
id: should_force id: should_force
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT" run: echo "should_force=${{ github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT"
build: build:
name: Docs Build name: Docs Build

View File

@@ -23,11 +23,9 @@ jobs:
filters: | filters: |
mobile: mobile:
- 'mobile/**' - 'mobile/**'
workflow:
- '.github/workflows/static_analysis.yml'
- name: Check if we should force jobs to run - name: Check if we should force jobs to run
id: should_force id: should_force
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" 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

View File

@@ -43,12 +43,10 @@ jobs:
- 'mobile/**' - 'mobile/**'
machine-learning: machine-learning:
- 'machine-learning/**' - 'machine-learning/**'
workflow:
- '.github/workflows/test.yml'
- name: Check if we should force jobs to run - name: Check if we should force jobs to run
id: should_force id: should_force
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" run: echo "should_force=${{ github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
server-unit-tests: server-unit-tests:
name: Test & Lint Server name: Test & Lint Server

View File

@@ -1 +1 @@
22.14.0 22.13.1

135
cli/package-lock.json generated
View File

@@ -24,7 +24,7 @@
"@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": "^22.13.2", "@types/node": "^22.13.1",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
@@ -59,7 +59,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.2", "@types/node": "^22.13.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -881,9 +881,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.20.0", "version": "9.19.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz",
"integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1482,9 +1482,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.4", "version": "22.13.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
"integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1498,17 +1498,17 @@
"dev": true "dev": true
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz",
"integrity": "sha512-aFcXEJJCI4gUdXgoo/j9udUYIHgF23MFkg09LFz2dzEmU0+1Plk4rQWv/IYKvPHAtlkkGoB3m5e6oUp+JPsNaQ==", "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/scope-manager": "8.23.0",
"@typescript-eslint/type-utils": "8.24.0", "@typescript-eslint/type-utils": "8.23.0",
"@typescript-eslint/utils": "8.24.0", "@typescript-eslint/utils": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0", "@typescript-eslint/visitor-keys": "8.23.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -1528,16 +1528,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.23.0.tgz",
"integrity": "sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==", "integrity": "sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/scope-manager": "8.23.0",
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/typescript-estree": "8.24.0", "@typescript-eslint/typescript-estree": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0", "@typescript-eslint/visitor-keys": "8.23.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@@ -1553,14 +1553,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz",
"integrity": "sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==", "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0" "@typescript-eslint/visitor-keys": "8.23.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1571,14 +1571,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz",
"integrity": "sha512-8fitJudrnY8aq0F1wMiPM1UUgiXQRJ5i8tFjq9kGfRajU+dbPyOuHbl0qRopLEidy0MwqgTHDt6CnSeXanNIwA==", "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.24.0", "@typescript-eslint/typescript-estree": "8.23.0",
"@typescript-eslint/utils": "8.24.0", "@typescript-eslint/utils": "8.23.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.0.1" "ts-api-utils": "^2.0.1"
}, },
@@ -1595,9 +1595,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz",
"integrity": "sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==", "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1609,14 +1609,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz",
"integrity": "sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==", "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0", "@typescript-eslint/visitor-keys": "8.23.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@@ -1636,16 +1636,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz",
"integrity": "sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==", "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/scope-manager": "8.23.0",
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/typescript-estree": "8.24.0" "@typescript-eslint/typescript-estree": "8.23.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1660,13 +1660,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz",
"integrity": "sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==", "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
@@ -2334,18 +2334,18 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.20.1", "version": "9.19.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz",
"integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.0", "@eslint/config-array": "^0.19.0",
"@eslint/core": "^0.11.0", "@eslint/core": "^0.10.0",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "9.20.0", "@eslint/js": "9.19.0",
"@eslint/plugin-kit": "^0.2.5", "@eslint/plugin-kit": "^0.2.5",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@@ -2500,19 +2500,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/@eslint/core": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz",
"integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint/node_modules/brace-expansion": { "node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -2841,9 +2828,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "15.15.0", "version": "15.14.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3582,9 +3569,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.5.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View File

@@ -20,7 +20,7 @@
"@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": "^22.13.2", "@types/node": "^22.13.1",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
@@ -67,6 +67,6 @@
"lodash-es": "^4.17.21" "lodash-es": "^4.17.21"
}, },
"volta": { "volta": {
"node": "22.14.0" "node": "22.13.1"
} }
} }

View File

@@ -1,13 +1,4 @@
# # See:
# WARNING: To install Immich, follow our guide: https://immich.app/docs/install/docker-compose
#
# Make sure to use the docker-compose.yml of the current release:
#
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
#
# The compose file on main may not be compatible with the latest release.
# For development see:
# - https://immich.app/docs/developer/setup # - https://immich.app/docs/developer/setup
# - https://immich.app/docs/developer/troubleshooting # - https://immich.app/docs/developer/troubleshooting
@@ -116,7 +107,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 image: redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1

View File

@@ -1,12 +1,3 @@
#
# WARNING: To install Immich, follow our guide: https://immich.app/docs/install/docker-compose
#
# Make sure to use the docker-compose.yml of the current release:
#
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
#
# The compose file on main may not be compatible with the latest release.
name: immich-prod name: immich-prod
services: services:
@@ -56,7 +47,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 image: redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -100,7 +91,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:5888c188cf09e3f7eebc97369c3b2ce713e844cdbd88ccf36f5047c958aea120 image: prom/prometheus@sha256:6559acbd5d770b15bb3c954629ce190ac3cbbdb2b7f1c30f0385c4e05104e218
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus

View File

@@ -1,11 +1,10 @@
# #
# WARNING: To install Immich, follow our guide: https://immich.app/docs/install/docker-compose # WARNING: Make sure to use the docker-compose.yml of the current release:
#
# Make sure to use the docker-compose.yml of the current release:
# #
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml # https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
# #
# The compose file on main may not be compatible with the latest release. # The compose file on main may not be compatible with the latest release.
#
name: immich name: immich
@@ -49,7 +48,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 image: docker.io/redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@@ -1 +1 @@
22.14.0 22.13.1

View File

@@ -70,4 +70,4 @@ When installing a new version of pgvecto.rs, you will need to manually update th
If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO <immichdbusername>;`. If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO <immichdbusername>;`.
[vectors-install]: https://docs.vectorchord.ai/getting-started/installation.html [vectors-install]: https://docs.pgvecto.rs/getting-started/installation.html

View File

@@ -1,76 +0,0 @@
---
sidebar_position: 85
---
# Synology [Community]
:::note
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
:::
Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager.
## Step 1 - Download the required files
Create a directory of your choice (e.g. `./immich-app`) to house Immich. In general, it's a best practice to have all Docker-based applications running under the `./docker` directory, so in this case, your directory structure will look like `./docker/immich-app`.
Now create a `./postgres` and `./library` directory as sub-directories of the `./docker/immich-app`.
When you're all done, you should have the following:
- `./docker/immich-app/postgres`
- `./docker/immich-app/library`
Download [`docker-compose.yml`](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) and [`example.env`](https://github.com/immich-app/immich/releases/latest/download/example.env) to your computer. Upload the files to the `./docker/immich-app` directory.
## Step 2 - Populate the .env file with custom values
Follow [Step 2 in Docker Compose](./docker-compose#step-2---populate-the-env-file-with-custom-values) for instructions on customizing the `.env` file, and then return back to this guide to continue.
## Step 3 - Create a new project in Container Manager
Open Container Manager, and select the "**Project**" action on the left navigation bar and then click "**Create**".
![Create Project](../../static/img/synology-container-manager-create-project.png)
In the settings of your new project, set "**Project name**" to a name you'll remember, such as _immich-app_. When setting the "**Path**", select the `./docker/immich-app` directory you created earlier. Doing so will prompt a message to use the existing `docker-compose.yml` already present in the directory for your project. Click "**OK**" to continue.
![Set Path](../../static/img/synology-container-manager-set-path.png)
The following screen will give you the option to further customize your `docker-compose.yml` file, giving you a warning regarding the `start_interval` property. Under the `healthcheck` heading, remove the `start_interval: 30s` completely and click "**Next**".
![start interval](../../static/img/synology-container-manager-customize-docker-compose.png)
Skip the section asking to set-up a portal for Web Station, and then complete the wizard which will build and start the containers for your project.
Once your containers are successfully running, navigate to the "**Container**" section of Container Manager, right-click on the "**immich-server**" container, and choose the "**Details**".
Scroll to the bottom of the "**Details**" section, and find the `IP Address` of the container, located in the `Network` section. Take note of the container's IP address as you will need it for **Step 4**.
![Container Details](../../static/img/synology-container-manager-container-details.png)
## Step 4 - Configure Firewall Settings
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS.
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
![Firewall rules](../../static/img/synology-firewall-rules.png)
Click "**Edit Rules**" and add the following firewall rules:
- Add a "**Source IP**" rule for the IP address of your container that you obtained in Step 3 above
- Add a "**Ports**" rule for the port specified in the `docker-compose.yml`, which should be `2283`
## Next Steps
Read the [Post Installation](/docs/install/post-install.mdx) steps or setup optional features below.
### Setting up optional features
- [External Libraries](/docs/features/libraries.md): Adding your existing photo library to Immich
- [Hardware Transcoding](/docs/features/hardware-transcoding.md): Speeding up video transcoding
- [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md): Speeding up various machine learning tasks in Immich

12
docs/package-lock.json generated
View File

@@ -14061,9 +14061,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.2", "version": "8.5.1",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
"integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -15725,9 +15725,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.5.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View File

@@ -55,6 +55,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "22.14.0" "node": "22.13.1"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1 +1 @@
22.14.0 22.13.1

View File

@@ -34,7 +34,7 @@ services:
- 2285:2285 - 2285:2285
redis: redis:
image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 image: redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae
database: database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0

174
e2e/package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@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": "^22.13.2", "@types/node": "^22.13.1",
"@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",
@@ -64,7 +64,7 @@
"@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": "^22.13.2", "@types/node": "^22.13.1",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
@@ -99,7 +99,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.2", "@types/node": "^22.13.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -876,9 +876,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.20.0", "version": "9.19.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz",
"integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1714,9 +1714,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.4", "version": "22.13.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
"integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1878,17 +1878,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz",
"integrity": "sha512-aFcXEJJCI4gUdXgoo/j9udUYIHgF23MFkg09LFz2dzEmU0+1Plk4rQWv/IYKvPHAtlkkGoB3m5e6oUp+JPsNaQ==", "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/scope-manager": "8.23.0",
"@typescript-eslint/type-utils": "8.24.0", "@typescript-eslint/type-utils": "8.23.0",
"@typescript-eslint/utils": "8.24.0", "@typescript-eslint/utils": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0", "@typescript-eslint/visitor-keys": "8.23.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -1908,16 +1908,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.23.0.tgz",
"integrity": "sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==", "integrity": "sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/scope-manager": "8.23.0",
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/typescript-estree": "8.24.0", "@typescript-eslint/typescript-estree": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0", "@typescript-eslint/visitor-keys": "8.23.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@@ -1933,14 +1933,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz",
"integrity": "sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==", "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0" "@typescript-eslint/visitor-keys": "8.23.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1951,14 +1951,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz",
"integrity": "sha512-8fitJudrnY8aq0F1wMiPM1UUgiXQRJ5i8tFjq9kGfRajU+dbPyOuHbl0qRopLEidy0MwqgTHDt6CnSeXanNIwA==", "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.24.0", "@typescript-eslint/typescript-estree": "8.23.0",
"@typescript-eslint/utils": "8.24.0", "@typescript-eslint/utils": "8.23.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.0.1" "ts-api-utils": "^2.0.1"
}, },
@@ -1975,9 +1975,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz",
"integrity": "sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==", "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1989,14 +1989,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz",
"integrity": "sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==", "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/visitor-keys": "8.24.0", "@typescript-eslint/visitor-keys": "8.23.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@@ -2042,16 +2042,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz",
"integrity": "sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==", "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/scope-manager": "8.23.0",
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"@typescript-eslint/typescript-estree": "8.24.0" "@typescript-eslint/typescript-estree": "8.23.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2066,13 +2066,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz",
"integrity": "sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==", "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.0", "@typescript-eslint/types": "8.23.0",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
@@ -3153,18 +3153,18 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.20.1", "version": "9.19.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz",
"integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.0", "@eslint/config-array": "^0.19.0",
"@eslint/core": "^0.11.0", "@eslint/core": "^0.10.0",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "9.20.0", "@eslint/js": "9.19.0",
"@eslint/plugin-kit": "^0.2.5", "@eslint/plugin-kit": "^0.2.5",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@@ -3319,19 +3319,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/@eslint/core": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz",
"integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": { "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
@@ -3819,9 +3806,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "15.15.0", "version": "15.14.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4413,11 +4400,10 @@
} }
}, },
"node_modules/koa": { "node_modules/koa": {
"version": "2.15.4", "version": "2.15.3",
"resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz",
"integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"accepts": "^1.3.5", "accepts": "^1.3.5",
"cache-content-type": "^1.0.0", "cache-content-type": "^1.0.0",
@@ -4934,9 +4920,9 @@
"dev": true "dev": true
}, },
"node_modules/oidc-provider": { "node_modules/oidc-provider": {
"version": "8.7.0", "version": "8.6.1",
"resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.7.0.tgz", "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.6.1.tgz",
"integrity": "sha512-H0AE07n7d5zBHwP8bDb1Yg+NokP+BybisUPB2kGG/lI0aPvLL/JcEhh3vJsz3UbThoz2p+6a0xGTBA8a3yDUGg==", "integrity": "sha512-wJ+nhwkCjRtQiwJKACjjV8FAIn7QXGDc1UOAE5WW0i8fsqN1GgXi42S/ccOxEx/JV3tyVLEwIipAvJNsJ/3djA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4947,7 +4933,7 @@
"got": "^13.0.0", "got": "^13.0.0",
"jose": "^5.9.6", "jose": "^5.9.6",
"jsesc": "^3.1.0", "jsesc": "^3.1.0",
"koa": "^2.15.4", "koa": "^2.15.3",
"nanoid": "^5.0.9", "nanoid": "^5.0.9",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"oidc-token-hash": "^5.0.3", "oidc-token-hash": "^5.0.3",
@@ -5207,15 +5193,15 @@
} }
}, },
"node_modules/pg": { "node_modules/pg": {
"version": "8.13.3", "version": "8.13.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz",
"integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pg-connection-string": "^2.7.0", "pg-connection-string": "^2.7.0",
"pg-pool": "^3.7.1", "pg-pool": "^3.7.0",
"pg-protocol": "^1.7.1", "pg-protocol": "^1.7.0",
"pg-types": "^2.1.0", "pg-types": "^2.1.0",
"pgpass": "1.x" "pgpass": "1.x"
}, },
@@ -5267,9 +5253,9 @@
} }
}, },
"node_modules/pg-pool": { "node_modules/pg-pool": {
"version": "3.7.1", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.1.tgz", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz",
"integrity": "sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==", "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@@ -5277,9 +5263,9 @@
} }
}, },
"node_modules/pg-protocol": { "node_modules/pg-protocol": {
"version": "1.7.1", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.1.tgz", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz",
"integrity": "sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==", "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -5481,9 +5467,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.5.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View File

@@ -25,7 +25,7 @@
"@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": "^22.13.2", "@types/node": "^22.13.1",
"@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",
@@ -53,6 +53,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.14.0" "node": "22.13.1"
} }
} }

View File

@@ -1,4 +1,4 @@
import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk';
import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs'; import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures'; import { userDto, uuidDto } from 'src/fixtures';
@@ -8,6 +8,8 @@ import request from 'supertest';
import { utimes } from 'utimes'; import { utimes } from 'utimes';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
describe('/libraries', () => { describe('/libraries', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user: LoginResponseDto; let user: LoginResponseDto;
@@ -296,7 +298,6 @@ describe('/libraries', () => {
expect(status).toBe(204); expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets } = await utils.searchAssets(admin.accessToken, { const { assets } = await utils.searchAssets(admin.accessToken, {
@@ -312,7 +313,15 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/directoryA`], importPaths: [`${testAssetDirInternal}/temp/directoryA`],
}); });
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
const { assets } = await utils.searchAssets(admin.accessToken, { const { assets } = await utils.searchAssets(admin.accessToken, {
originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
@@ -332,7 +341,13 @@ describe('/libraries', () => {
exclusionPatterns: ['**/directoryA'], exclusionPatterns: ['**/directoryA'],
}); });
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -346,7 +361,13 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
}); });
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -365,7 +386,13 @@ describe('/libraries', () => {
utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`);
utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`);
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -387,7 +414,13 @@ describe('/libraries', () => {
utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`);
utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`);
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -439,7 +472,13 @@ describe('/libraries', () => {
utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`);
utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`);
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -463,12 +502,23 @@ describe('/libraries', () => {
utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets } = await utils.searchAssets(admin.accessToken, { const { assets } = await utils.searchAssets(admin.accessToken, {
libraryId: library.id, libraryId: library.id,
@@ -499,12 +549,21 @@ describe('/libraries', () => {
utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets } = await utils.searchAssets(admin.accessToken, { const { assets } = await utils.searchAssets(admin.accessToken, {
libraryId: library.id, libraryId: library.id,
@@ -534,14 +593,21 @@ describe('/libraries', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1); expect(assets.count).toBe(1);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
@@ -559,7 +625,8 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/offline`], importPaths: [`${testAssetDirInternal}/temp/offline`],
}); });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1); expect(assets.count).toBe(1);
@@ -570,7 +637,13 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/another-path/`], importPaths: [`${testAssetDirInternal}/temp/another-path/`],
}); });
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`);
@@ -590,7 +663,8 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { const { assets } = await utils.searchAssets(admin.accessToken, {
libraryId: library.id, libraryId: library.id,
@@ -600,7 +674,8 @@ describe('/libraries', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.isTrashed).toBe(true); expect(trashedAsset.isTrashed).toBe(true);
@@ -622,12 +697,19 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assetsBefore.count).toBeGreaterThan(1); expect(assetsBefore.count).toBeGreaterThan(1);
await utils.scan(admin.accessToken, library.id); const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -644,7 +726,11 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -667,7 +753,10 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -691,7 +780,10 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -715,13 +807,19 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -744,12 +842,18 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -772,12 +876,18 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -801,13 +911,19 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -831,12 +947,18 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -860,12 +982,18 @@ describe('/libraries', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -888,13 +1016,22 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/offline`], importPaths: [`${testAssetDirInternal}/temp/offline`],
}); });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
await utils.scan(admin.accessToken, library.id); {
const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
}
await utils.waitForQueueFinish(admin.accessToken, 'library');
const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(offlineAsset.isTrashed).toBe(true); expect(offlineAsset.isTrashed).toBe(true);
@@ -908,7 +1045,15 @@ describe('/libraries', () => {
utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id); {
const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
}
await utils.waitForQueueFinish(admin.accessToken, 'library');
const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
@@ -930,13 +1075,22 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/offline`], importPaths: [`${testAssetDirInternal}/temp/offline`],
}); });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
await utils.scan(admin.accessToken, library.id); {
const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
}
await utils.waitForQueueFinish(admin.accessToken, 'library');
{ {
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
@@ -957,7 +1111,15 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/another-path`], importPaths: [`${testAssetDirInternal}/temp/another-path`],
}); });
await utils.scan(admin.accessToken, library.id); {
const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
}
await utils.waitForQueueFinish(admin.accessToken, 'library');
const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
@@ -981,13 +1143,22 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp/offline`], importPaths: [`${testAssetDirInternal}/temp/offline`],
}); });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`);
await utils.scan(admin.accessToken, library.id); {
const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
}
await utils.waitForQueueFinish(admin.accessToken, 'library');
{ {
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true });
@@ -1004,7 +1175,15 @@ describe('/libraries', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await utils.scan(admin.accessToken, library.id); {
const { status } = await request(app)
.post(`/libraries/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(204);
}
await utils.waitForQueueFinish(admin.accessToken, 'library');
const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
@@ -1124,7 +1303,8 @@ describe('/libraries', () => {
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
await utils.scan(admin.accessToken, library.id); await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/libraries/${library.id}`) .delete(`/libraries/${library.id}`)

View File

@@ -204,12 +204,6 @@ describe('/shared-links', () => {
); );
}); });
it('should increment the view count', async () => {
const request1 = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
const request2 = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
expect(request2.body.viewCount).toBe(request1.body.viewCount + 1);
});
it('should return unauthorized for incorrect shared link', async () => { it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/shared-links/me') .get('/shared-links/me')

View File

@@ -30,7 +30,6 @@ import {
getAssetInfo, getAssetInfo,
getConfigDefaults, getConfigDefaults,
login, login,
scanLibrary,
searchAssets, searchAssets,
sendJobCommand, sendJobCommand,
setBaseUrl, setBaseUrl,
@@ -554,14 +553,6 @@ export const utils = {
await immichCli(['login', app, `${key.secret}`]); await immichCli(['login', app, `${key.secret}`]);
return key.secret; return key.secret;
}, },
scan: async (accessToken: string, id: string) => {
await scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
await utils.waitForQueueFinish(accessToken, 'library');
await utils.waitForQueueFinish(accessToken, 'sidecar');
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
},
}; };
utils.initSdk(); utils.initSdk();

View File

@@ -602,6 +602,8 @@
"enable": "Enable", "enable": "Enable",
"enabled": "Enabled", "enabled": "Enabled",
"end_date": "End date", "end_date": "End date",
"rotate_left": "Rotate left",
"rotate_right": "Rotate right",
"error": "Error", "error": "Error",
"error_loading_image": "Error loading image", "error_loading_image": "Error loading image",
"error_title": "Error - Something went wrong", "error_title": "Error - Something went wrong",
@@ -644,6 +646,7 @@
"quota_higher_than_disk_size": "You set a quota higher than the disk size", "quota_higher_than_disk_size": "You set a quota higher than the disk size",
"repair_unable_to_check_items": "Unable to check {count, select, one {item} other {items}}", "repair_unable_to_check_items": "Unable to check {count, select, one {item} other {items}}",
"unable_to_add_album_users": "Unable to add users to album", "unable_to_add_album_users": "Unable to add users to album",
"unable_to_rotate_image": "Unable to rotate image",
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link", "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment", "unable_to_add_comment": "Unable to add comment",
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern", "unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
@@ -816,7 +819,6 @@
"invite_people": "Invite People", "invite_people": "Invite People",
"invite_to_album": "Invite to album", "invite_to_album": "Invite to album",
"items_count": "{count, plural, one {# item} other {# items}}", "items_count": "{count, plural, one {# item} other {# items}}",
"views_count": "{count, plural, one {# view} other {# views}}",
"jobs": "Jobs", "jobs": "Jobs",
"keep": "Keep", "keep": "Keep",
"keep_all": "Keep All", "keep_all": "Keep All",

View File

@@ -10,14 +10,14 @@
"activity_changed": "L'attività è {enabled, select, true {abilitata} other {disabilitata}}", "activity_changed": "L'attività è {enabled, select, true {abilitata} other {disabilitata}}",
"add": "Aggiungi", "add": "Aggiungi",
"add_a_description": "Aggiungi una descrizione", "add_a_description": "Aggiungi una descrizione",
"add_a_location": "Aggiungi una posizione", "add_a_location": "Aggiungi un luogo",
"add_a_name": "Aggiungi un nome", "add_a_name": "Aggiungi un nome",
"add_a_title": "Aggiungi un titolo", "add_a_title": "Aggiungi un titolo",
"add_exclusion_pattern": "Aggiungi un pattern di esclusione", "add_exclusion_pattern": "Aggiungi un pattern di esclusione",
"add_import_path": "Aggiungi un percorso di importazione", "add_import_path": "Aggiungi un percorso di importazione",
"add_location": "Aggiungi posizione", "add_location": "Aggiungi posizione",
"add_more_users": "Aggiungi altri utenti", "add_more_users": "Aggiungi altri utenti",
"add_partner": "Aggiungi partner", "add_partner": "Aggiungi un partner",
"add_path": "Aggiungi percorso", "add_path": "Aggiungi percorso",
"add_photos": "Aggiungi foto", "add_photos": "Aggiungi foto",
"add_to": "Aggiungi a...", "add_to": "Aggiungi a...",
@@ -374,11 +374,11 @@
"album_name": "Nome Album", "album_name": "Nome Album",
"album_options": "Impostazioni Album", "album_options": "Impostazioni Album",
"album_remove_user": "Rimuovi l'utente?", "album_remove_user": "Rimuovi l'utente?",
"album_remove_user_confirmation": "Sicuro di voler rimuovere l'utente {user}?", "album_remove_user_confirmation": "Sicuro di voler cancellare l'utente {user}?",
"album_share_no_users": "Sembra che tu abbia condiviso questo album con tutti gli utenti oppure non hai nessun utente con cui condividere.", "album_share_no_users": "Sembra che tu abbia condiviso questo album con tutti gli utenti oppure non hai nessun utente con cui condividere.",
"album_updated": "Album aggiornato", "album_updated": "Album aggiornato",
"album_updated_setting_description": "Ricevi una notifica email quando un album condiviso ha nuovi media", "album_updated_setting_description": "Ricevi una notifica email quando un album condiviso ha nuovi asset",
"album_user_left": "{album} abbandonato", "album_user_left": "Abbandona {album}",
"album_user_removed": "Utente {user} rimosso", "album_user_removed": "Utente {user} rimosso",
"album_with_link_access": "Permetti a chiunque possieda il link di visualizzare le foto e le persone dell'album.", "album_with_link_access": "Permetti a chiunque possieda il link di visualizzare le foto e le persone dell'album.",
"albums": "Album", "albums": "Album",
@@ -391,10 +391,10 @@
"allow_edits": "Permetti Modifiche", "allow_edits": "Permetti Modifiche",
"allow_public_user_to_download": "Permetti agli utenti pubblici di scaricare", "allow_public_user_to_download": "Permetti agli utenti pubblici di scaricare",
"allow_public_user_to_upload": "Permetti agli utenti pubblici di caricare", "allow_public_user_to_upload": "Permetti agli utenti pubblici di caricare",
"anti_clockwise": "Senso anti-orario", "anti_clockwise": "Senso Anti-Orario",
"api_key": "Chiave API", "api_key": "Chiave API",
"api_key_description": "Il valore verrà mostrato solo una volta. Assicurati di copiarlo prima di chiudere la finestra.", "api_key_description": "Il campo verrà mostrato solo una volta. Abbi cura di copiarlo prima di chiudere la finestra.",
"api_key_empty": "Il nome della chiave API non può essere vuoto", "api_key_empty": "Il Nome dell'API Key non può essere vuoto",
"api_keys": "Chiavi API", "api_keys": "Chiavi API",
"app_settings": "Impostazioni Applicazione", "app_settings": "Impostazioni Applicazione",
"appears_in": "Compare in", "appears_in": "Compare in",
@@ -407,14 +407,14 @@
"are_you_sure_to_do_this": "Sei sicuro di voler procedere?", "are_you_sure_to_do_this": "Sei sicuro di voler procedere?",
"asset_added_to_album": "Aggiunto all'album", "asset_added_to_album": "Aggiunto all'album",
"asset_adding_to_album": "In aggiunta all'album...", "asset_adding_to_album": "In aggiunta all'album...",
"asset_description_updated": "La descrizione del media è stata aggiornata", "asset_description_updated": "La descrizione del media non è stata aggiornata",
"asset_filename_is_offline": "Il media {filename} è offline", "asset_filename_is_offline": "Il media {filename} è offline",
"asset_has_unassigned_faces": "Il media ha dei volti non categorizzati", "asset_has_unassigned_faces": "Il media ha dei volti non categorizzati",
"asset_hashing": "Hashing in corso ...", "asset_hashing": "Hashing in corso ...",
"asset_offline": "Risorsa Offline", "asset_offline": "Risorsa Offline",
"asset_offline_description": "Questo media non è stato trovato nel disco. Contatta il tuo amministratore di Immich per assistenza.", "asset_offline_description": "Questo media non è stato trovato nel disco. Contatta il tuo amministratore di Immich per assistenza.",
"asset_skipped": "Saltato", "asset_skipped": "Saltato",
"asset_skipped_in_trash": "Nel cestino", "asset_skipped_in_trash": "In cestino",
"asset_uploaded": "Caricato", "asset_uploaded": "Caricato",
"asset_uploading": "Caricamento...", "asset_uploading": "Caricamento...",
"assets": "Risorse", "assets": "Risorse",
@@ -434,7 +434,7 @@
"back_close_deselect": "Indietro, chiudi o deseleziona", "back_close_deselect": "Indietro, chiudi o deseleziona",
"backward": "Indietro", "backward": "Indietro",
"birthdate_saved": "Data di nascita salvata con successo", "birthdate_saved": "Data di nascita salvata con successo",
"birthdate_set_description": "La data di nascita è usata per calcolare l'età di questa persona al momento dello scatto della foto.", "birthdate_set_description": "La data di nascita è usata per calcolare l'età di questa persona nel momento dello scatto della foto.",
"blurred_background": "Sfondo sfocato", "blurred_background": "Sfondo sfocato",
"bugs_and_feature_requests": "Bug & Richieste di nuove funzionalità", "bugs_and_feature_requests": "Bug & Richieste di nuove funzionalità",
"build": "Compilazione", "build": "Compilazione",
@@ -442,7 +442,7 @@
"bulk_delete_duplicates_confirmation": "Sei sicuro di voler cancellare {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione manterrà l'asset più pesante di ogni gruppo e cancellerà permanentemente tutti gli altri duplicati. Non puoi annullare questa operazione!", "bulk_delete_duplicates_confirmation": "Sei sicuro di voler cancellare {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione manterrà l'asset più pesante di ogni gruppo e cancellerà permanentemente tutti gli altri duplicati. Non puoi annullare questa operazione!",
"bulk_keep_duplicates_confirmation": "Sei sicuro di voler tenere {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione risolverà tutti i gruppi duplicati senza cancellare nulla.", "bulk_keep_duplicates_confirmation": "Sei sicuro di voler tenere {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione risolverà tutti i gruppi duplicati senza cancellare nulla.",
"bulk_trash_duplicates_confirmation": "Sei davvero sicuro di voler cancellare {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione manterrà l'asset più pesante di ogni gruppo e cancellerà permanentemente tutti gli altri duplicati.", "bulk_trash_duplicates_confirmation": "Sei davvero sicuro di voler cancellare {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione manterrà l'asset più pesante di ogni gruppo e cancellerà permanentemente tutti gli altri duplicati.",
"buy": "Acquista Immich", "buy": "Acquistare Immich",
"camera": "Fotocamera", "camera": "Fotocamera",
"camera_brand": "Marca fotocamera", "camera_brand": "Marca fotocamera",
"camera_model": "Modello fotocamera", "camera_model": "Modello fotocamera",

View File

@@ -96,8 +96,8 @@ download:
locale_code: ro-RO locale_code: ro-RO
- file: mobile/assets/i18n/id-ID.json - file: mobile/assets/i18n/id-ID.json
locale_code: id-ID locale_code: id-ID
- file: mobile/assets/i18n/gl-ES.json - file: mobile/assets/i18n/gl.json
locale_code: gl-ES locale_code: gl
- file: mobile/assets/i18n/ga.json - file: mobile/assets/i18n/ga.json
locale_code: ga locale_code: ga
- file: mobile/assets/i18n/tr-TR.json - file: mobile/assets/i18n/tr-TR.json

View File

@@ -77,7 +77,7 @@ custom_lint:
- test/**.dart - test/**.dart
# refactor the remaining providers # refactor the remaining providers
- lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart
- lib/providers/{asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart
- import_rule_openapi: - import_rule_openapi:
message: openapi must only be used through ApiRepositories message: openapi must only be used through ApiRepositories
@@ -110,81 +110,51 @@ custom_lint:
- test/**.dart - test/**.dart
dart_code_metrics: dart_code_metrics:
extends: metrics:
- recommended cyclomatic-complexity: 20
number-of-parameters: 4
maximum-nesting-level: 5
rules: rules:
# Common # Common
- arguments-ordering: - avoid-accessing-collections-by-constant-index
last:
- child
- children
- avoid-accessing-other-classes-private-members - avoid-accessing-other-classes-private-members
- avoid-assigning-to-static-field - avoid-cascade-after-if-null
- avoid-assignments-as-conditions
- avoid-async-call-in-sync-function
- avoid-collapsible-if - avoid-collapsible-if
- avoid-collection-equality-checks - avoid-collection-methods-with-unrelated-types
- avoid-complex-loop-conditions - avoid-double-slash-imports
- avoid-declaring-call-method - avoid-duplicate-cascades
- avoid-extensions-on-records - avoid-duplicate-patterns
- avoid-function-type-in-records - avoid-generics-shadowing
- avoid-future-ignore
- avoid-global-state - avoid-global-state
- avoid-inverted-boolean-checks
- avoid-late-final-reassignment
- avoid-local-functions
- avoid-negated-conditions
- avoid-nested-streams-and-futures
- avoid-referencing-subclasses
- avoid-unnecessary-continue
- avoid-unnecessary-nullable-return-type: false
- binary-expression-operand-order
- move-variable-outside-iteration
- pattern-fields-ordering
- prefer-abstract-final-static-class
- prefer-commenting-future-delayed
- prefer-early-return
- prefer-first
- prefer-immediate-return
- prefer-last
- prefer-simpler-boolean-expressions
- prefer-switch-expression
- prefer-type-over-var
- use-existing-destructuring
- use-existing-variable
# Flutter # Flutter
- add-copy-with:
file-name-pattern: '.model.dart'
- always-remove-listener
- avoid-border-all - avoid-border-all
- avoid-complex-arithmetic-expressions - avoid-empty-setstate
- avoid-expanded-as-spacer - avoid-expanded-as-spacer
- avoid-if-with-many-branches - avoid-incomplete-copy-with
- avoid-inherited-widget-in-initstate - avoid-inherited-widget-in-initstate
- avoid-late-context - avoid-late-context
- avoid-recursive-widget-calls
- avoid-returning-widgets - avoid-returning-widgets
- avoid-shrink-wrap-in-lists - avoid-shrink-wrap-in-lists
- avoid-single-child-column-or-row - avoid-single-child-column-or-row
- avoid-state-constructors
- avoid-stateless-widget-initialized-fields - avoid-stateless-widget-initialized-fields
- avoid-unnecessary-overrides-in-state
- avoid-unnecessary-stateful-widgets
- avoid-wrapping-in-padding - avoid-wrapping-in-padding
- prefer-align-over-container - dispose-fields
- prefer-const-border-radius - prefer-const-border-radius
- prefer-correct-callback-field-name: false
- prefer-correct-edge-insets-constructor - prefer-correct-edge-insets-constructor
- prefer-dedicated-media-query-methods
- prefer-define-hero-tag - prefer-define-hero-tag
- prefer-extracting-callbacks - prefer-extracting-callbacks
- prefer-for-loop-in-children - prefer-single-widget-per-file:
- prefer-match-file-name: false ignore-private-widgets: true
- prefer-sliver-prefix - prefer-sliver-prefix
- prefer-spacing
- prefer-text-rich - prefer-text-rich
- prefer-transform-over-container
- prefer-using-list-view - prefer-using-list-view
- prefer-widget-private-members: - proper-super-calls
ignore-static: true - use-setstate-synchronously
- use-closest-build-context
# riverpod
- avoid-calling-notifier-members-inside-build
- avoid-notifier-constructors
- avoid-ref-read-inside-build
- avoid-ref-watch-outside-build
- avoid-unnecessary-consumer-widgets
- dispose-provided-instances
- use-ref-read-synchronously

View File

@@ -1,5 +1,5 @@
import 'package:analyzer/error/error.dart' show ErrorSeverity;
import 'package:analyzer/error/listener.dart'; import 'package:analyzer/error/listener.dart';
import 'package:analyzer/error/error.dart' show ErrorSeverity;
import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart';
// ignore: depend_on_referenced_packages // ignore: depend_on_referenced_packages
import 'package:glob/glob.dart'; import 'package:glob/glob.dart';
@@ -65,8 +65,7 @@ class ImportRule extends DartLintRule {
) { ) {
if (_rootOffset == -1) { if (_rootOffset == -1) {
const project = "/immich/mobile/"; const project = "/immich/mobile/";
_rootOffset = _rootOffset = resolver.path.indexOf(project) + project.length;
resolver.path.toLowerCase().indexOf(project) + project.length;
} }
final path = resolver.path.substring(_rootOffset); final path = resolver.path.substring(_rootOffset);

View File

@@ -5,7 +5,7 @@ environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
dependencies: dependencies:
analyzer: ^6.0.0 analyzer: ^7.0.0
analyzer_plugin: ^0.11.3 analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.6.4 custom_lint_builder: ^0.6.4
glob: ^2.1.2 glob: ^2.1.2

View File

@@ -3,7 +3,6 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:immich_mobile/interfaces/database.interface.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
abstract interface class IAlbumRepository implements IDatabaseRepository { abstract interface class IAlbumRepository implements IDatabaseRepository {
Future<Album> create(Album album); Future<Album> create(Album album);
@@ -43,16 +42,6 @@ abstract interface class IAlbumRepository implements IDatabaseRepository {
Future<Album> recalculateMetadata(Album album); Future<Album> recalculateMetadata(Album album);
Future<List<Album>> search(String searchTerm, QuickFilterMode filterMode); Future<List<Album>> search(String searchTerm, QuickFilterMode filterMode);
Stream<List<Album>> watchRemoteAlbums();
Stream<List<Album>> watchLocalAlbums();
Stream<Album?> watchAlbum(int id);
Stream<RenderList> getRenderListStream(Album album);
Future<void> clearTable();
} }
enum AlbumSort { remoteId, localId } enum AlbumSort { remoteId, localId }

View File

@@ -41,7 +41,7 @@ abstract interface class IAssetRepository implements IDatabaseRepository {
Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state}); Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state});
Future<void> deleteByIds(List<int> ids); Future<void> deleteById(List<int> ids);
Future<List<Asset>> getMatches({ Future<List<Asset>> getMatches({
required List<Asset> assets, required List<Asset> assets,
@@ -57,12 +57,6 @@ abstract interface class IAssetRepository implements IDatabaseRepository {
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets); Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets);
Future<List<String>> getAllDuplicatedAssetIds(); Future<List<String>> getAllDuplicatedAssetIds();
Future<List<Asset>> getStackAssets(String stackId);
Future<void> clearTable();
Stream<Asset?> watchAsset(int id, {bool fireImmediately = false});
} }
enum AssetSort { checksum, ownerIdChecksum } enum AssetSort { checksum, ownerIdChecksum }

View File

@@ -11,6 +11,4 @@ abstract interface class IETagRepository implements IDatabaseRepository {
Future<void> upsertAll(List<ETag> etags); Future<void> upsertAll(List<ETag> etags);
Future<void> deleteByIds(List<String> ids); Future<void> deleteByIds(List<String> ids);
Future<void> clearTable();
} }

View File

@@ -9,6 +9,4 @@ abstract interface class IExifInfoRepository implements IDatabaseRepository {
Future<List<ExifInfo>> updateAll(List<ExifInfo> exifInfos); Future<List<ExifInfo>> updateAll(List<ExifInfo> exifInfos);
Future<void> delete(int id); Future<void> delete(int id);
Future<void> clearTable();
} }

View File

@@ -18,8 +18,6 @@ abstract interface class IUserRepository implements IDatabaseRepository {
Future<void> deleteById(List<int> ids); Future<void> deleteById(List<int> ids);
Future<User> me(); Future<User> me();
Future<void> clearTable();
} }
enum UserSort { id } enum UserSort { id }

View File

@@ -8,40 +8,43 @@ import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
final isRefreshingRemoteAlbumProvider = StateProvider<bool>((ref) => false); final isRefreshingRemoteAlbumProvider = StateProvider<bool>((ref) => false);
class AlbumNotifier extends StateNotifier<List<Album>> { class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this.albumService, this.ref) : super([]) { AlbumNotifier(this._albumService, this.db, this.ref) : super([]) {
albumService.getAllRemoteAlbums().then((value) { final query = db.albums.filter().remoteIdIsNotNull();
query.findAll().then((value) {
if (mounted) { if (mounted) {
state = value; state = value;
} }
}); });
_streamSub = query.watch().listen((data) => state = data);
_streamSub =
albumService.watchRemoteAlbums().listen((data) => state = data);
} }
final AlbumService albumService; final AlbumService _albumService;
final Isar db;
final Ref ref; final Ref ref;
late final StreamSubscription<List<Album>> _streamSub; late final StreamSubscription<List<Album>> _streamSub;
Future<void> refreshRemoteAlbums() async { Future<void> refreshRemoteAlbums() async {
ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true; ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true;
await albumService.refreshRemoteAlbums(); await _albumService.refreshRemoteAlbums();
ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false; ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false;
} }
Future<void> refreshDeviceAlbums() => albumService.refreshDeviceAlbums(); Future<void> refreshDeviceAlbums() => _albumService.refreshDeviceAlbums();
Future<bool> deleteAlbum(Album album) => albumService.deleteAlbum(album); Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
Future<Album?> createAlbum( Future<Album?> createAlbum(
String albumTitle, String albumTitle,
Set<Asset> assets, Set<Asset> assets,
) => ) =>
albumService.createAlbum(albumTitle, assets, []); _albumService.createAlbum(albumTitle, assets, []);
Future<Album?> getAlbumByName( Future<Album?> getAlbumByName(
String albumName, { String albumName, {
@@ -49,7 +52,7 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
bool? shared, bool? shared,
bool? owner, bool? owner,
}) => }) =>
albumService.getAlbumByName( _albumService.getAlbumByName(
albumName, albumName,
remote: remote, remote: remote,
shared: shared, shared: shared,
@@ -71,7 +74,7 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
} }
Future<bool> leaveAlbum(Album album) async { Future<bool> leaveAlbum(Album album) async {
var res = await albumService.leaveAlbum(album); var res = await _albumService.leaveAlbum(album);
if (res) { if (res) {
await deleteAlbum(album); await deleteAlbum(album);
@@ -82,15 +85,15 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
} }
void searchAlbums(String searchTerm, QuickFilterMode filterMode) async { void searchAlbums(String searchTerm, QuickFilterMode filterMode) async {
state = await albumService.search(searchTerm, filterMode); state = await _albumService.search(searchTerm, filterMode);
} }
Future<void> addUsers(Album album, List<String> userIds) async { Future<void> addUsers(Album album, List<String> userIds) async {
await albumService.addUsers(album, userIds); await _albumService.addUsers(album, userIds);
} }
Future<bool> removeUser(Album album, User user) async { Future<bool> removeUser(Album album, User user) async {
final isRemoved = await albumService.removeUser(album, user); final isRemoved = await _albumService.removeUser(album, user);
if (isRemoved && album.sharedUsers.isEmpty) { if (isRemoved && album.sharedUsers.isEmpty) {
state = state.where((element) => element.id != album.id).toList(); state = state.where((element) => element.id != album.id).toList();
@@ -100,25 +103,25 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
} }
Future<void> addAssets(Album album, Iterable<Asset> assets) async { Future<void> addAssets(Album album, Iterable<Asset> assets) async {
await albumService.addAssets(album, assets); await _albumService.addAssets(album, assets);
} }
Future<bool> removeAsset(Album album, Iterable<Asset> assets) async { Future<bool> removeAsset(Album album, Iterable<Asset> assets) async {
return await albumService.removeAsset(album, assets); return await _albumService.removeAsset(album, assets);
} }
Future<bool> setActivitystatus( Future<bool> setActivitystatus(
Album album, Album album,
bool enabled, bool enabled,
) { ) {
return albumService.setActivityStatus(album, enabled); return _albumService.setActivityStatus(album, enabled);
} }
Future<Album?> toggleSortOrder(Album album) { Future<Album?> toggleSortOrder(Album album) {
final order = final order =
album.sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc; album.sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
return albumService.updateSortOrder(album, order); return _albumService.updateSortOrder(album, order);
} }
@override @override
@@ -132,49 +135,57 @@ final albumProvider =
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) { StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier( return AlbumNotifier(
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(dbProvider),
ref, ref,
); );
}); });
final albumWatcher = final albumWatcher =
StreamProvider.autoDispose.family<Album, int>((ref, id) async* { StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
final albumService = ref.watch(albumServiceProvider); final db = ref.watch(dbProvider);
final a = await db.albums.get(albumId);
final album = await albumService.getAlbumById(id); if (a != null) yield a;
if (album != null) { await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
yield album; if (a != null) yield a;
}
await for (final album in albumService.watchAlbum(id)) {
if (album != null) {
yield album;
}
} }
}); });
final albumRenderlistProvider = final albumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, id) { StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(albumWatcher(id)).value; final album = ref.watch(albumWatcher(albumId)).value;
if (album != null) { if (album != null) {
return ref.watch(albumServiceProvider).getRenderListGenerator(album); final query = album.assets.filter().isTrashedEqualTo(false);
if (album.sortOrder == SortOrder.asc) {
return renderListGeneratorWithGroupBy(
query.sortByFileCreatedAt(),
GroupAssetsBy.none,
);
} else if (album.sortOrder == SortOrder.desc) {
return renderListGeneratorWithGroupBy(
query.sortByFileCreatedAtDesc(),
GroupAssetsBy.none,
);
}
} }
return const Stream.empty(); return const Stream.empty();
}); });
class LocalAlbumsNotifier extends StateNotifier<List<Album>> { class LocalAlbumsNotifier extends StateNotifier<List<Album>> {
LocalAlbumsNotifier(this.albumService) : super([]) { LocalAlbumsNotifier(this.db) : super([]) {
albumService.getAllLocalAlbums().then((value) { final query = db.albums.where().remoteIdIsNull();
query.findAll().then((value) {
if (mounted) { if (mounted) {
state = value; state = value;
} }
}); });
_streamSub = albumService.watchLocalAlbums().listen((data) => state = data); _streamSub = query.watch().listen((data) => state = data);
} }
final AlbumService albumService; final Isar db;
late final StreamSubscription<List<Album>> _streamSub; late final StreamSubscription<List<Album>> _streamSub;
@override @override
@@ -186,5 +197,5 @@ class LocalAlbumsNotifier extends StateNotifier<List<Album>> {
final localAlbumsProvider = final localAlbumsProvider =
StateNotifierProvider.autoDispose<LocalAlbumsNotifier, List<Album>>((ref) { StateNotifierProvider.autoDispose<LocalAlbumsNotifier, List<Album>>((ref) {
return LocalAlbumsNotifier(ref.watch(albumServiceProvider)); return LocalAlbumsNotifier(ref.watch(dbProvider));
}); });

View File

@@ -2,40 +2,28 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/services/etag.service.dart';
import 'package:immich_mobile/services/exif.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/services/user.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart'; import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(etagServiceProvider),
ref.watch(exifServiceProvider),
ref,
);
});
class AssetNotifier extends StateNotifier<bool> { class AssetNotifier extends StateNotifier<bool> {
final AssetService _assetService; final AssetService _assetService;
final AlbumService _albumService; final AlbumService _albumService;
final UserService _userService; final UserService _userService;
final SyncService _syncService; final SyncService _syncService;
final ETagService _etagService; final Isar _db;
final ExifService _exifService;
final StateNotifierProviderRef _ref; final StateNotifierProviderRef _ref;
final log = Logger('AssetNotifier'); final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false; bool _getAllAssetInProgress = false;
@@ -46,8 +34,7 @@ class AssetNotifier extends StateNotifier<bool> {
this._albumService, this._albumService,
this._userService, this._userService,
this._syncService, this._syncService,
this._etagService, this._db,
this._exifService,
this._ref, this._ref,
) : super(false); ) : super(false);
@@ -61,7 +48,7 @@ class AssetNotifier extends StateNotifier<bool> {
_getAllAssetInProgress = true; _getAllAssetInProgress = true;
state = true; state = true;
if (clear) { if (clear) {
await clearAllAssets(); await clearAssetsAndAlbums(_db);
log.info("Manual refresh requested, cleared assets and albums from db"); log.info("Manual refresh requested, cleared assets and albums from db");
} }
final bool changedUsers = await _userService.refreshUsers(); final bool changedUsers = await _userService.refreshUsers();
@@ -81,15 +68,8 @@ class AssetNotifier extends StateNotifier<bool> {
} }
} }
Future<void> clearAllAssets() async { Future<void> clearAllAsset() {
await Store.delete(StoreKey.assetETag); return clearAssetsAndAlbums(_db);
await Future.wait([
_assetService.clearTable(),
_exifService.clearTable(),
_albumService.clearTable(),
_userService.clearTable(),
_etagService.clearTable(),
]);
} }
Future<void> onNewAssetUploaded(Asset newAsset) async { Future<void> onNewAssetUploaded(Asset newAsset) async {
@@ -98,43 +78,102 @@ class AssetNotifier extends StateNotifier<bool> {
await _syncService.syncNewAssetToDb(newAsset); await _syncService.syncNewAssetToDb(newAsset);
} }
Future<bool> deleteLocalAssets(List<Asset> assets) async { Future<bool> deleteLocalOnlyAssets(
_deleteInProgress = true;
state = true;
try {
await _assetService.deleteLocalAssets(assets);
return true;
} catch (error) {
log.severe("Failed to delete local assets", error);
return false;
} finally {
_deleteInProgress = false;
state = false;
}
}
/// Delete remote asset only
///
/// Default behavior is trashing the asset
Future<bool> deleteRemoteAssets(
Iterable<Asset> deleteAssets, { Iterable<Asset> deleteAssets, {
bool shouldDeletePermanently = false, bool onlyBackedUp = false,
}) async { }) async {
_deleteInProgress = true; _deleteInProgress = true;
state = true; state = true;
try { try {
await _assetService.deleteRemoteAssets( // Filter the assets based on the backed-up status
deleteAssets, final assets = onlyBackedUp
shouldDeletePermanently: shouldDeletePermanently, ? deleteAssets.where((e) => e.storage == AssetState.merged)
); : deleteAssets;
return true;
} catch (error) { if (assets.isEmpty) {
log.severe("Failed to delete remote assets", error); return false; // No assets to delete
return false; }
// Proceed with local deletion of the filtered assets
final localDeleted = await _deleteLocalAssets(assets);
if (localDeleted.isNotEmpty) {
final localOnlyIds = assets
.where((e) => e.storage == AssetState.local)
.map((e) => e.id)
.toList();
// Update merged assets to remote-only
final mergedAssets =
assets.where((e) => e.storage == AssetState.merged).map((e) {
e.localId = null;
return e;
}).toList();
// Update the local database
await _db.writeTxn(() async {
if (mergedAssets.isNotEmpty) {
await _db.assets
.putAll(mergedAssets); // Use the filtered merged assets
}
await _db.exifInfos.deleteAll(localOnlyIds);
await _db.assets.deleteAll(localOnlyIds);
});
return true;
}
} finally { } finally {
_deleteInProgress = false; _deleteInProgress = false;
state = false; state = false;
} }
return false;
}
Future<bool> deleteRemoteOnlyAssets(
Iterable<Asset> deleteAssets, {
bool force = false,
}) async {
_deleteInProgress = true;
state = true;
try {
final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force);
if (remoteDeleted.isNotEmpty) {
final assetsToUpdate = force
/// If force, only update merged only assets and remove remote assets
? remoteDeleted
.where((e) => e.storage == AssetState.merged)
.map((e) {
e.remoteId = null;
return e;
})
// If not force, trash everything
: remoteDeleted.where((e) => e.isRemote).map((e) {
e.isTrashed = true;
return e;
});
await _db.writeTxn(() async {
if (assetsToUpdate.isNotEmpty) {
await _db.assets.putAll(assetsToUpdate.toList());
}
if (force) {
final remoteOnly = remoteDeleted
.where((e) => e.storage == AssetState.remote)
.map((e) => e.id)
.toList();
await _db.exifInfos.deleteAll(remoteOnly);
await _db.assets.deleteAll(remoteOnly);
}
});
return true;
}
} finally {
_deleteInProgress = false;
state = false;
}
return false;
} }
Future<bool> deleteAssets( Future<bool> deleteAssets(
@@ -144,18 +183,111 @@ class AssetNotifier extends StateNotifier<bool> {
_deleteInProgress = true; _deleteInProgress = true;
state = true; state = true;
try { try {
await _assetService.deleteAssets( final hasLocal = deleteAssets.any((a) => a.storage != AssetState.remote);
deleteAssets, final localDeleted = await _deleteLocalAssets(deleteAssets);
shouldDeletePermanently: force, final remoteDeleted = (hasLocal && localDeleted.isNotEmpty) || !hasLocal
); ? await _deleteRemoteAssets(deleteAssets, force)
return true; : [];
} catch (error) { if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
log.severe("Failed to delete assets", error); final dbIds = <int>[];
return false; final dbUpdates = <Asset>[];
// Local assets are removed
if (localDeleted.isNotEmpty) {
// Permanently remove local only assets from isar
dbIds.addAll(
deleteAssets
.where((a) => a.storage == AssetState.local)
.map((e) => e.id),
);
if (remoteDeleted.any((e) => e.isLocal)) {
// Force delete: Add all local assets including merged assets
if (force) {
dbIds.addAll(remoteDeleted.map((e) => e.id));
// Soft delete: Remove local Id from asset and trash it
} else {
dbUpdates.addAll(
remoteDeleted.map((e) {
e.localId = null;
e.isTrashed = true;
return e;
}),
);
}
}
}
// Handle remote deletion
if (remoteDeleted.isNotEmpty) {
if (force) {
// Remove remote only assets
dbIds.addAll(
deleteAssets
.where((a) => a.storage == AssetState.remote)
.map((e) => e.id),
);
// Local assets are not removed and there are merged assets
final hasLocal = remoteDeleted.any((e) => e.isLocal);
if (localDeleted.isEmpty && hasLocal) {
// Remove remote Id from local assets
dbUpdates.addAll(
remoteDeleted.map((e) {
e.remoteId = null;
// Remove from trashed if remote asset is removed
e.isTrashed = false;
return e;
}),
);
}
} else {
dbUpdates.addAll(
remoteDeleted.map((e) {
e.isTrashed = true;
return e;
}),
);
}
}
await _db.writeTxn(() async {
await _db.assets.putAll(dbUpdates);
await _db.exifInfos.deleteAll(dbIds);
await _db.assets.deleteAll(dbIds);
});
return true;
}
} finally { } finally {
_deleteInProgress = false; _deleteInProgress = false;
state = false; state = false;
} }
return false;
}
Future<List<String>> _deleteLocalAssets(
Iterable<Asset> assetsToDelete,
) async {
final List<String> local =
assetsToDelete.where((a) => a.isLocal).map((a) => a.localId!).toList();
// Delete asset from device
if (local.isNotEmpty) {
try {
return await _ref.read(assetMediaRepositoryProvider).deleteAll(local);
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
}
return [];
}
Future<List<Asset>> _deleteRemoteAssets(
Iterable<Asset> assetsToDelete,
bool? force,
) async {
final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
final isSuccess = await _assetService.deleteAssets(remote, force: force);
return isSuccess ? remote.toList() : [];
} }
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) { Future<void> toggleFavorite(List<Asset> assets, [bool? status]) {
@@ -169,40 +301,41 @@ class AssetNotifier extends StateNotifier<bool> {
} }
} }
final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
ref,
);
});
final assetDetailProvider = final assetDetailProvider =
StreamProvider.autoDispose.family<Asset, Asset>((ref, asset) async* { StreamProvider.autoDispose.family<Asset, Asset>((ref, asset) async* {
final assetService = ref.watch(assetServiceProvider); yield await ref.watch(assetServiceProvider).loadExif(asset);
yield await assetService.loadExif(asset); final db = ref.watch(dbProvider);
await for (final a in db.assets.watchObject(asset.id)) {
await for (final asset in assetService.watchAsset(asset.id)) { if (a != null) {
if (asset != null) { yield await ref.watch(assetServiceProvider).loadExif(a);
yield await ref.watch(assetServiceProvider).loadExif(asset);
} }
} }
}); });
final assetWatcher = final assetWatcher =
StreamProvider.autoDispose.family<Asset?, Asset>((ref, asset) { StreamProvider.autoDispose.family<Asset?, Asset>((ref, asset) {
final assetService = ref.watch(assetServiceProvider); final db = ref.watch(dbProvider);
return assetService.watchAsset(asset.id, fireImmediately: true); return db.assets.watchObject(asset.id, fireImmediately: true);
}); });
final assetsProvider = StreamProvider.family<RenderList, int?>( final assetsProvider = StreamProvider.family<RenderList, int?>(
(ref, userId) { (ref, userId) {
if (userId == null) return const Stream.empty(); if (userId == null) return const Stream.empty();
ref.watch(localeProvider); ref.watch(localeProvider);
final query = _commonFilterAndSort(
final query = ref _assets(ref).where().ownerIdEqualToAnyChecksum(userId),
.watch(dbProvider) );
.assets
.where()
.ownerIdEqualToAnyChecksum(userId)
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
return renderListGenerator(query, ref); return renderListGenerator(query, ref);
}, },
dependencies: [localeProvider], dependencies: [localeProvider],
@@ -212,17 +345,11 @@ final multiUserAssetsProvider = StreamProvider.family<RenderList, List<int>>(
(ref, userIds) { (ref, userIds) {
if (userIds.isEmpty) return const Stream.empty(); if (userIds.isEmpty) return const Stream.empty();
ref.watch(localeProvider); ref.watch(localeProvider);
final query = ref final query = _commonFilterAndSort(
.watch(dbProvider) _assets(ref)
.assets .where()
.where() .anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)),
.anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)) );
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
return renderListGenerator(query, ref); return renderListGenerator(query, ref);
}, },
dependencies: [localeProvider], dependencies: [localeProvider],
@@ -244,3 +371,17 @@ QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
.stackPrimaryAssetIdIsNull() .stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
} }
IsarCollection<Asset> _assets(StreamProviderRef<RenderList> ref) =>
ref.watch(dbProvider).assets;
QueryBuilder<Asset, Asset, QAfterSortBy> _commonFilterAndSort(
QueryBuilder<Asset, Asset, QAfterWhereClause> query,
) {
return query
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
}

View File

@@ -1,15 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'asset_stack.provider.g.dart'; part 'asset_stack.provider.g.dart';
class AssetStackNotifier extends StateNotifier<List<Asset>> { class AssetStackNotifier extends StateNotifier<List<Asset>> {
final AssetService assetService;
final String _stackId; final String _stackId;
final Ref _ref;
AssetStackNotifier(this.assetService, this._stackId) : super([]) { AssetStackNotifier(this._stackId, this._ref) : super([]) {
_fetchStack(_stackId); _fetchStack(_stackId);
} }
@@ -18,7 +19,7 @@ class AssetStackNotifier extends StateNotifier<List<Asset>> {
return; return;
} }
final stack = await assetService.getStackAssets(stackId); final stack = await _ref.read(assetStackProvider(stackId).future);
if (stack.isNotEmpty) { if (stack.isNotEmpty) {
state = stack; state = stack;
} }
@@ -34,10 +35,24 @@ class AssetStackNotifier extends StateNotifier<List<Asset>> {
final assetStackStateProvider = StateNotifierProvider.autoDispose final assetStackStateProvider = StateNotifierProvider.autoDispose
.family<AssetStackNotifier, List<Asset>, String>( .family<AssetStackNotifier, List<Asset>, String>(
(ref, stackId) => (ref, stackId) => AssetStackNotifier(stackId, ref),
AssetStackNotifier(ref.watch(assetServiceProvider), stackId),
); );
final assetStackProvider =
FutureProvider.autoDispose.family<List<Asset>, String>((ref, stackId) {
return ref
.watch(dbProvider)
.assets
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackIdEqualTo(stackId)
// orders primary asset first as its ID is null
.sortByStackPrimaryAssetId()
.thenByFileCreatedAtDesc()
.findAll();
});
@riverpod @riverpod
int assetStackIndex(AssetStackIndexRef ref, Asset asset) { int assetStackIndex(AssetStackIndexRef ref, Asset asset) {
return -1; return -1;

View File

@@ -57,7 +57,7 @@ class TrashNotifier extends StateNotifier<bool> {
final isRemoved = await _ref final isRemoved = await _ref
.read(assetProvider.notifier) .read(assetProvider.notifier)
.deleteRemoteAssets(assetList, shouldDeletePermanently: true); .deleteRemoteOnlyAssets(assetList, force: true);
if (isRemoved) { if (isRemoved) {
final idsToRemove = final idsToRemove =

View File

@@ -1,5 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@@ -8,7 +7,6 @@ import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
final albumRepositoryProvider = final albumRepositoryProvider =
@@ -154,44 +152,4 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
return await query.findAll(); return await query.findAll();
} }
@override
Future<void> clearTable() async {
await txn(() async {
await db.albums.clear();
});
}
@override
Stream<List<Album>> watchRemoteAlbums() {
return db.albums.where().remoteIdIsNotNull().watch();
}
@override
Stream<List<Album>> watchLocalAlbums() {
return db.albums.where().localIdIsNotNull().watch();
}
@override
Stream<Album?> watchAlbum(int id) {
return db.albums.watchObject(id, fireImmediately: true);
}
@override
Stream<RenderList> getRenderListStream(Album album) async* {
final query = album.assets.filter().isTrashedEqualTo(false);
final withSortedOption = switch (album.sortOrder) {
SortOrder.asc => query.sortByFileCreatedAt(),
SortOrder.desc => query.sortByFileCreatedAtDesc(),
};
yield await RenderList.fromQuery(
withSortedOption,
GroupAssetsBy.none,
);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(withSortedOption, GroupAssetsBy.none);
}
}
} }

View File

@@ -57,7 +57,7 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
} }
@override @override
Future<void> deleteByIds(List<int> ids) => txn(() async { Future<void> deleteById(List<int> ids) => txn(() async {
await db.assets.deleteAll(ids); await db.assets.deleteAll(ids);
await db.exifInfos.deleteAll(ids); await db.exifInfos.deleteAll(ids);
}); });
@@ -197,31 +197,6 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
@override @override
Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state}) => Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state}) =>
txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll());
@override
Future<List<Asset>> getStackAssets(String stackId) {
return db.assets
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackIdEqualTo(stackId)
// orders primary asset first as its ID is null
.sortByStackPrimaryAssetId()
.thenByFileCreatedAtDesc()
.findAll();
}
@override
Future<void> clearTable() async {
await txn(() async {
await db.assets.clear();
});
}
@override
Stream<Asset?> watchAsset(int id, {bool fireImmediately = false}) {
return db.assets.watchObject(id, fireImmediately: fireImmediately);
}
} }
Future<List<Asset>> _getMatchesImpl( Future<List<Asset>> _getMatchesImpl(

View File

@@ -26,11 +26,4 @@ class ETagRepository extends DatabaseRepository implements IETagRepository {
@override @override
Future<ETag?> getById(String id) => db.eTags.getById(id); Future<ETag?> getById(String id) => db.eTags.getById(id);
@override
Future<void> clearTable() async {
await txn(() async {
await db.eTags.clear();
});
}
} }

View File

@@ -28,9 +28,4 @@ class ExifInfoRepository extends DatabaseRepository
await txn(() => db.exifInfos.putAll(exifInfos)); await txn(() => db.exifInfos.putAll(exifInfos));
return exifInfos; return exifInfos;
} }
@override
Future<void> clearTable() {
return txn(() => db.exifInfos.clear());
}
} }

View File

@@ -57,11 +57,4 @@ class UserRepository extends DatabaseRepository implements IUserRepository {
.or() .or()
.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.findAll(); .findAll();
@override
Future<void> clearTable() async {
await txn(() async {
await db.users.clear();
});
}
} }

View File

@@ -1060,7 +1060,6 @@ class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
required Asset asset, required Asset asset,
required Widget image, required Widget image,
bool showControls = true, bool showControls = true,
int playbackDelayFactor = 1,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
NativeVideoViewerRoute.name, NativeVideoViewerRoute.name,
@@ -1069,7 +1068,6 @@ class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
asset: asset, asset: asset,
image: image, image: image,
showControls: showControls, showControls: showControls,
playbackDelayFactor: playbackDelayFactor,
), ),
initialChildren: children, initialChildren: children,
); );
@@ -1085,7 +1083,6 @@ class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
asset: args.asset, asset: args.asset,
image: args.image, image: args.image,
showControls: args.showControls, showControls: args.showControls,
playbackDelayFactor: args.playbackDelayFactor,
); );
}, },
); );
@@ -1097,7 +1094,6 @@ class NativeVideoViewerRouteArgs {
required this.asset, required this.asset,
required this.image, required this.image,
this.showControls = true, this.showControls = true,
this.playbackDelayFactor = 1,
}); });
final Key? key; final Key? key;
@@ -1108,11 +1104,9 @@ class NativeVideoViewerRouteArgs {
final bool showControls; final bool showControls;
final int playbackDelayFactor;
@override @override
String toString() { String toString() {
return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls, playbackDelayFactor: $playbackDelayFactor}'; return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}';
} }
} }

View File

@@ -26,7 +26,6 @@ import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/services/user.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
final albumServiceProvider = Provider( final albumServiceProvider = Provider(
@@ -310,7 +309,7 @@ class AlbumService {
final List<int> idsToRemove = final List<int> idsToRemove =
_syncService.sharedAssetsToRemove(foreignAssets, existing); _syncService.sharedAssetsToRemove(foreignAssets, existing);
if (idsToRemove.isNotEmpty) { if (idsToRemove.isNotEmpty) {
await _assetRepository.deleteByIds(idsToRemove); await _assetRepository.deleteById(idsToRemove);
} }
} else { } else {
await _albumRepository.delete(album.id); await _albumRepository.delete(album.id);
@@ -443,35 +442,10 @@ class AlbumService {
} }
} }
Future<List<Album>> getAllRemoteAlbums() async { Future<List<Album>> getAll() async {
return _albumRepository.getAll(remote: true); return _albumRepository.getAll(remote: true);
} }
Future<List<Album>> getAllLocalAlbums() async {
return _albumRepository.getAll(remote: false);
}
Stream<List<Album>> watchRemoteAlbums() {
return _albumRepository.watchRemoteAlbums();
}
Stream<List<Album>> watchLocalAlbums() {
return _albumRepository.watchLocalAlbums();
}
/// Get album by Isar ID
Future<Album?> getAlbumById(int id) {
return _albumRepository.get(id);
}
Stream<Album?> watchAlbum(int id) {
return _albumRepository.watchAlbum(id);
}
Stream<RenderList> getRenderListGenerator(Album album) {
return _albumRepository.getRenderListStream(album);
}
Future<List<Album>> search( Future<List<Album>> search(
String searchTerm, String searchTerm,
QuickFilterMode filterMode, QuickFilterMode filterMode,
@@ -491,8 +465,4 @@ class AlbumService {
} }
return null; return null;
} }
Future<void> clearTable() async {
await _albumRepository.clearTable();
}
} }

View File

@@ -23,6 +23,7 @@ class ApiService implements Authentication {
late MapApi mapApi; late MapApi mapApi;
late PartnersApi partnersApi; late PartnersApi partnersApi;
late PeopleApi peopleApi; late PeopleApi peopleApi;
late AuditApi auditApi;
late SharedLinksApi sharedLinksApi; late SharedLinksApi sharedLinksApi;
late SyncApi syncApi; late SyncApi syncApi;
late SystemConfigApi systemConfigApi; late SystemConfigApi systemConfigApi;
@@ -55,6 +56,7 @@ class ApiService implements Authentication {
mapApi = MapApi(_apiClient); mapApi = MapApi(_apiClient);
partnersApi = PartnersApi(_apiClient); partnersApi = PartnersApi(_apiClient);
peopleApi = PeopleApi(_apiClient); peopleApi = PeopleApi(_apiClient);
auditApi = AuditApi(_apiClient);
sharedLinksApi = SharedLinksApi(_apiClient); sharedLinksApi = SharedLinksApi(_apiClient);
syncApi = SyncApi(_apiClient); syncApi = SyncApi(_apiClient);
systemConfigApi = SystemConfigApi(_apiClient); systemConfigApi = SystemConfigApi(_apiClient);

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart';
@@ -18,7 +17,6 @@ import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart';
@@ -45,7 +43,6 @@ final assetServiceProvider = Provider(
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(backupServiceProvider), ref.watch(backupServiceProvider),
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(assetMediaRepositoryProvider),
), ),
); );
@@ -61,7 +58,6 @@ class AssetService {
final UserService _userService; final UserService _userService;
final BackupService _backupService; final BackupService _backupService;
final AlbumService _albumService; final AlbumService _albumService;
final IAssetMediaRepository _assetMediaRepository;
final log = Logger('AssetService'); final log = Logger('AssetService');
AssetService( AssetService(
@@ -76,7 +72,6 @@ class AssetService {
this._userService, this._userService,
this._backupService, this._backupService,
this._albumService, this._albumService,
this._assetMediaRepository,
); );
/// Checks the server for updated assets and updates the local database if /// Checks the server for updated assets and updates the local database if
@@ -163,6 +158,30 @@ class AssetService {
} }
} }
Future<bool> deleteAssets(
Iterable<Asset> deleteAssets, {
bool? force = false,
}) async {
try {
final List<String> payload = [];
for (final asset in deleteAssets) {
payload.add(asset.remoteId!);
}
await _apiService.assetsApi.deleteAssets(
AssetBulkDeleteDto(
ids: payload,
force: force,
),
);
return true;
} catch (error, stack) {
log.severe("Error while deleting assets", error, stack);
}
return false;
}
/// Loads the exif information from the database. If there is none, loads /// Loads the exif information from the database. If there is none, loads
/// the exif info from the server (remote assets only) /// the exif info from the server (remote assets only)
Future<Asset> loadExif(Asset a) async { Future<Asset> loadExif(Asset a) async {
@@ -409,109 +428,4 @@ class AssetService {
return 1.0; return 1.0;
} }
Future<List<Asset>> getStackAssets(String stackId) {
return _assetRepository.getStackAssets(stackId);
}
Future<void> clearTable() {
return _assetRepository.clearTable();
}
/// Delete assets from local file system and unreference from the database
Future<void> deleteLocalAssets(Iterable<Asset> assets) async {
// Delete files from local gallery
final candidates = assets.where((asset) => asset.isLocal);
final deletedIds = await _assetMediaRepository
.deleteAll(candidates.map((asset) => asset.localId!).toList());
// Modify local database by removing the reference to the local assets
if (deletedIds.isNotEmpty) {
// Delete records from local database
final isarIds = assets
.where((asset) => asset.storage == AssetState.local)
.map((asset) => asset.id)
.toList();
await _assetRepository.deleteByIds(isarIds);
// Modify Merged asset to be remote only
final updatedAssets = assets
.where((asset) => asset.storage == AssetState.merged)
.map((asset) {
asset.localId = null;
return asset;
}).toList();
await _assetRepository.updateAll(updatedAssets);
}
}
/// Delete assets from the server and unreference from the database
Future<void> deleteRemoteAssets(
Iterable<Asset> assets, {
bool shouldDeletePermanently = false,
}) async {
final candidates = assets.where((a) => a.isRemote);
if (candidates.isEmpty) {
return;
}
await _apiService.assetsApi.deleteAssets(
AssetBulkDeleteDto(
ids: candidates.map((a) => a.remoteId!).toList(),
force: shouldDeletePermanently,
),
);
/// Update asset info bassed on the deletion type.
final payload = shouldDeletePermanently
? assets
.where((asset) => asset.storage == AssetState.merged)
.map((asset) {
asset.remoteId = null;
return asset;
})
: assets.where((asset) => asset.isRemote).map((asset) {
asset.isTrashed = true;
return asset;
});
await _assetRepository.transaction(() async {
await _assetRepository.updateAll(payload.toList());
if (shouldDeletePermanently) {
final remoteAssetIds = assets
.where((asset) => asset.storage == AssetState.remote)
.map((asset) => asset.id)
.toList();
await _assetRepository.deleteByIds(remoteAssetIds);
}
});
}
/// Delete assets on both local file system and the server.
/// Unreference from the database.
Future<void> deleteAssets(
Iterable<Asset> assets, {
bool shouldDeletePermanently = false,
}) async {
final hasLocal = assets.any((asset) => asset.isLocal);
final hasRemote = assets.any((asset) => asset.isRemote);
if (hasLocal) {
await deleteLocalAssets(assets);
}
if (hasRemote) {
await deleteRemoteAssets(
assets,
shouldDeletePermanently: shouldDeletePermanently,
);
}
}
Stream<Asset?> watchAsset(int id, {bool fireImmediately = false}) {
return _assetRepository.watchAsset(id, fireImmediately: fireImmediately);
}
} }

View File

@@ -1,16 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
final etagServiceProvider =
Provider((ref) => ETagService(ref.watch(etagRepositoryProvider)));
class ETagService {
final IETagRepository _eTagRepository;
ETagService(this._eTagRepository);
Future<void> clearTable() {
return _eTagRepository.clearTable();
}
}

View File

@@ -1,16 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
import 'package:immich_mobile/repositories/exif_info.repository.dart';
final exifServiceProvider =
Provider((ref) => ExifService(ref.watch(exifInfoRepositoryProvider)));
class ExifService {
final IExifInfoRepository _exifInfoRepository;
ExifService(this._exifInfoRepository);
Future<void> clearTable() {
return _exifInfoRepository.clearTable();
}
}

View File

@@ -286,7 +286,7 @@ class SyncService {
} }
final idsToDelete = toRemove.map((e) => e.id).toList(); final idsToDelete = toRemove.map((e) => e.id).toList();
try { try {
await _assetRepository.deleteByIds(idsToDelete); await _assetRepository.deleteById(idsToDelete);
await upsertAssetsWithExif(toAdd + toUpdate); await upsertAssetsWithExif(toAdd + toUpdate);
} catch (e) { } catch (e) {
_log.severe("Failed to sync remote assets to db", e); _log.severe("Failed to sync remote assets to db", e);
@@ -334,7 +334,7 @@ class SyncService {
if (toDelete.isNotEmpty) { if (toDelete.isNotEmpty) {
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing); final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
if (idsToRemove.isNotEmpty) { if (idsToRemove.isNotEmpty) {
await _assetRepository.deleteByIds(idsToRemove); await _assetRepository.deleteById(idsToRemove);
} }
} else { } else {
assert(toDelete.isEmpty); assert(toDelete.isEmpty);
@@ -531,7 +531,7 @@ class SyncService {
); );
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
await _assetRepository.transaction(() async { await _assetRepository.transaction(() async {
await _assetRepository.deleteByIds(toDelete); await _assetRepository.deleteById(toDelete);
await _assetRepository.updateAll(toUpdate); await _assetRepository.updateAll(toUpdate);
}); });
_log.info( _log.info(
@@ -826,7 +826,7 @@ class SyncService {
final (toDelete, toUpdate) = final (toDelete, toUpdate) =
_handleAssetRemoval(assets, [], remote: false); _handleAssetRemoval(assets, [], remote: false);
await _assetRepository.transaction(() async { await _assetRepository.transaction(() async {
await _assetRepository.deleteByIds(toDelete); await _assetRepository.deleteById(toDelete);
await _assetRepository.updateAll(toUpdate); await _assetRepository.updateAll(toUpdate);
await _albumRepository.deleteAllLocal(); await _albumRepository.deleteAllLocal();
}); });

View File

@@ -103,8 +103,4 @@ class UserService {
if (users == null) return false; if (users == null) return false;
return _syncService.syncUsersFromServer(users); return _syncService.syncUsersFromServer(users);
} }
Future<void> clearTable() {
return _userRepository.clearTable();
}
} }

18
mobile/lib/utils/db.dart Normal file
View File

@@ -0,0 +1,18 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:isar/isar.dart';
Future<void> clearAssetsAndAlbums(Isar db) async {
await Store.delete(StoreKey.assetETag);
await db.writeTxn(() async {
await db.assets.clear();
await db.exifInfos.clear();
await db.albums.clear();
await db.eTags.clear();
await db.users.clear();
});
}

View File

@@ -1,11 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
const int targetVersion = 8; const int targetVersion = 8;
@@ -18,13 +14,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
} }
Future<void> _migrateTo(Isar db, int version) async { Future<void> _migrateTo(Isar db, int version) async {
await Store.delete(StoreKey.assetETag); await clearAssetsAndAlbums(db);
await db.writeTxn(() async {
await db.assets.clear();
await db.exifInfos.clear();
await db.albums.clear();
await db.eTags.clear();
await db.users.clear();
});
await Store.put(StoreKey.version, version); await Store.put(StoreKey.version, version);
} }

View File

@@ -13,13 +13,6 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'sharedLinks', SharedLinksResponse().toJson()); addDefault(value, 'sharedLinks', SharedLinksResponse().toJson());
} }
break; break;
case 'SharedLinkResponseDto':
if (value is Map) {
addDefault(value, 'viewCount', 0);
}
break;
case 'ServerConfigDto': case 'ServerConfigDto':
if (value is Map) { if (value is Map) {
addDefault( addDefault(
@@ -33,14 +26,11 @@ dynamic upgradeDto(dynamic value, String targetType) {
'https://tiles.immich.cloud/v1/style/dark.json', 'https://tiles.immich.cloud/v1/style/dark.json',
); );
} }
break;
case 'UserResponseDto': case 'UserResponseDto':
if (value is Map) { if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
} }
break; break;
case 'UserAdminResponseDto': case 'UserAdminResponseDto':
if (value is Map) { if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());

View File

@@ -200,26 +200,24 @@ class MultiselectGrid extends HookConsumerWidget {
} }
} }
void onDeleteLocal(bool isMergedAsset) async { void onDeleteLocal(bool onlyBackedUp) async {
processing.value = true; processing.value = true;
try { try {
final localAssets = selection.value.where((a) => a.isLocal).toList(); // Select only the local assets from the selection
final localIds = selection.value.where((a) => a.isLocal).toList();
final toDelete = isMergedAsset
? localAssets.where((e) => e.storage == AssetState.merged)
: localAssets;
if (toDelete.isEmpty) {
return;
}
// Delete only the backed-up assets if 'onlyBackedUp' is true
final isDeleted = await ref final isDeleted = await ref
.read(assetProvider.notifier) .read(assetProvider.notifier)
.deleteLocalAssets(toDelete.toList()); .deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp);
if (isDeleted) { if (isDeleted) {
final deletedCount = // Show a toast with the correct number of deleted assets
localAssets.where((e) => !isMergedAsset || e.isRemote).length; final deletedCount = localIds
.where(
(e) => !onlyBackedUp || e.isRemote,
) // Only count backed-up assets
.length;
ImmichToast.show( ImmichToast.show(
context: context, context: context,
@@ -228,6 +226,7 @@ class MultiselectGrid extends HookConsumerWidget {
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
); );
// Reset the selection
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
} }
} finally { } finally {
@@ -235,7 +234,7 @@ class MultiselectGrid extends HookConsumerWidget {
} }
} }
void onDeleteRemote([bool shouldDeletePermanently = false]) async { void onDeleteRemote([bool force = false]) async {
processing.value = true; processing.value = true;
try { try {
final toDelete = ownedRemoteSelection( final toDelete = ownedRemoteSelection(
@@ -243,15 +242,13 @@ class MultiselectGrid extends HookConsumerWidget {
ownerErrorMessage: 'home_page_delete_err_partner'.tr(), ownerErrorMessage: 'home_page_delete_err_partner'.tr(),
).toList(); ).toList();
final isDeleted = final isDeleted = await ref
await ref.read(assetProvider.notifier).deleteRemoteAssets( .read(assetProvider.notifier)
toDelete, .deleteRemoteOnlyAssets(toDelete, force: force);
shouldDeletePermanently: shouldDeletePermanently,
);
if (isDeleted) { if (isDeleted) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: shouldDeletePermanently msg: force
? 'assets_deleted_permanently_from_server' ? 'assets_deleted_permanently_from_server'
.tr(args: ["${toDelete.length}"]) .tr(args: ["${toDelete.length}"])
: 'assets_trashed_from_server'.tr(args: ["${toDelete.length}"]), : 'assets_trashed_from_server'.tr(args: ["${toDelete.length}"]),

View File

@@ -134,7 +134,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
ref.read(manualUploadProvider.notifier).cancelBackup(); ref.read(manualUploadProvider.notifier).cancelBackup();
ref.read(backupProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup();
ref.read(assetProvider.notifier).clearAllAssets(); ref.read(assetProvider.notifier).clearAllAsset();
ref.read(websocketProvider.notifier).disconnect(); ref.read(websocketProvider.notifier).disconnect();
context.replaceRoute(const LoginRoute()); context.replaceRoute(const LoginRoute());
}, },

View File

@@ -85,7 +85,7 @@ class ChangePasswordForm extends HookConsumerWidget {
ref.read(backupProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup();
await ref await ref
.read(assetProvider.notifier) .read(assetProvider.notifier)
.clearAllAssets(); .clearAllAsset();
ref.read(websocketProvider.notifier).disconnect(); ref.read(websocketProvider.notifier).disconnect();
AutoRouter.of(context).back(); AutoRouter.of(context).back();

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -149,7 +150,7 @@ class LoginForm extends HookConsumerWidget {
useEffect( useEffect(
() { () {
final serverUrl = getServerUrl(); final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null) { if (serverUrl != null) {
serverEndpointController.text = serverUrl; serverEndpointController.text = serverUrl;
} }

View File

@@ -109,6 +109,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
@@ -292,6 +293,7 @@ Class | Method | HTTP request | Description
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md) - [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md) - [AudioCodec](doc//AudioCodec.md)
- [AuditDeletesResponseDto](doc//AuditDeletesResponseDto.md)
- [AvatarResponse](doc//AvatarResponse.md) - [AvatarResponse](doc//AvatarResponse.md)
- [AvatarUpdate](doc//AvatarUpdate.md) - [AvatarUpdate](doc//AvatarUpdate.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
@@ -315,6 +317,7 @@ Class | Method | HTTP request | Description
- [DuplicateResponseDto](doc//DuplicateResponseDto.md) - [DuplicateResponseDto](doc//DuplicateResponseDto.md)
- [EmailNotificationsResponse](doc//EmailNotificationsResponse.md) - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md)
- [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md) - [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md)
- [EntityType](doc//EntityType.md)
- [ExifResponseDto](doc//ExifResponseDto.md) - [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md) - [FaceDto](doc//FaceDto.md)
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)

View File

@@ -34,6 +34,7 @@ part 'api/api_keys_api.dart';
part 'api/activities_api.dart'; part 'api/activities_api.dart';
part 'api/albums_api.dart'; part 'api/albums_api.dart';
part 'api/assets_api.dart'; part 'api/assets_api.dart';
part 'api/audit_api.dart';
part 'api/authentication_api.dart'; part 'api/authentication_api.dart';
part 'api/deprecated_api.dart'; part 'api/deprecated_api.dart';
part 'api/download_api.dart'; part 'api/download_api.dart';
@@ -105,6 +106,7 @@ part 'model/asset_stack_response_dto.dart';
part 'model/asset_stats_response_dto.dart'; part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart'; part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart'; part 'model/audio_codec.dart';
part 'model/audit_deletes_response_dto.dart';
part 'model/avatar_response.dart'; part 'model/avatar_response.dart';
part 'model/avatar_update.dart'; part 'model/avatar_update.dart';
part 'model/bulk_id_response_dto.dart'; part 'model/bulk_id_response_dto.dart';
@@ -128,6 +130,7 @@ part 'model/duplicate_detection_config.dart';
part 'model/duplicate_response_dto.dart'; part 'model/duplicate_response_dto.dart';
part 'model/email_notifications_response.dart'; part 'model/email_notifications_response.dart';
part 'model/email_notifications_update.dart'; part 'model/email_notifications_update.dart';
part 'model/entity_type.dart';
part 'model/exif_response_dto.dart'; part 'model/exif_response_dto.dart';
part 'model/face_dto.dart'; part 'model/face_dto.dart';
part 'model/facial_recognition_config.dart'; part 'model/facial_recognition_config.dart';

79
mobile/openapi/lib/api/audit_api.dart generated Normal file
View File

@@ -0,0 +1,79 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AuditApi {
AuditApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /audit/deletes' operation and returns the [Response].
/// Parameters:
///
/// * [DateTime] after (required):
///
/// * [EntityType] entityType (required):
///
/// * [String] userId:
Future<Response> getAuditDeletesWithHttpInfo(DateTime after, EntityType entityType, { String? userId, }) async {
// ignore: prefer_const_declarations
final path = r'/audit/deletes';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'after', after));
queryParams.addAll(_queryParams('', 'entityType', entityType));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [DateTime] after (required):
///
/// * [EntityType] entityType (required):
///
/// * [String] userId:
Future<AuditDeletesResponseDto?> getAuditDeletes(DateTime after, EntityType entityType, { String? userId, }) async {
final response = await getAuditDeletesWithHttpInfo(after, entityType, userId: userId, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuditDeletesResponseDto',) as AuditDeletesResponseDto;
}
return null;
}
}

View File

@@ -266,6 +266,8 @@ class ApiClient {
return AssetTypeEnumTypeTransformer().decode(value); return AssetTypeEnumTypeTransformer().decode(value);
case 'AudioCodec': case 'AudioCodec':
return AudioCodecTypeTransformer().decode(value); return AudioCodecTypeTransformer().decode(value);
case 'AuditDeletesResponseDto':
return AuditDeletesResponseDto.fromJson(value);
case 'AvatarResponse': case 'AvatarResponse':
return AvatarResponse.fromJson(value); return AvatarResponse.fromJson(value);
case 'AvatarUpdate': case 'AvatarUpdate':
@@ -312,6 +314,8 @@ class ApiClient {
return EmailNotificationsResponse.fromJson(value); return EmailNotificationsResponse.fromJson(value);
case 'EmailNotificationsUpdate': case 'EmailNotificationsUpdate':
return EmailNotificationsUpdate.fromJson(value); return EmailNotificationsUpdate.fromJson(value);
case 'EntityType':
return EntityTypeTypeTransformer().decode(value);
case 'ExifResponseDto': case 'ExifResponseDto':
return ExifResponseDto.fromJson(value); return ExifResponseDto.fromJson(value);
case 'FaceDto': case 'FaceDto':

View File

@@ -82,6 +82,9 @@ String parameterToString(dynamic value) {
if (value is Colorspace) { if (value is Colorspace) {
return ColorspaceTypeTransformer().encode(value).toString(); return ColorspaceTypeTransformer().encode(value).toString();
} }
if (value is EntityType) {
return EntityTypeTypeTransformer().encode(value).toString();
}
if (value is ImageFormat) { if (value is ImageFormat) {
return ImageFormatTypeTransformer().encode(value).toString(); return ImageFormatTypeTransformer().encode(value).toString();
} }

View File

@@ -20,6 +20,7 @@ class AssetBulkUpdateDto {
this.isFavorite, this.isFavorite,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.orientation,
this.rating, this.rating,
}); });
@@ -67,6 +68,8 @@ class AssetBulkUpdateDto {
/// ///
num? longitude; num? longitude;
AssetBulkUpdateDtoOrientationEnum? orientation;
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
/// ///
@@ -86,6 +89,7 @@ class AssetBulkUpdateDto {
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.latitude == latitude && other.latitude == latitude &&
other.longitude == longitude && other.longitude == longitude &&
other.orientation == orientation &&
other.rating == rating; other.rating == rating;
@override @override
@@ -98,10 +102,11 @@ class AssetBulkUpdateDto {
(isFavorite == null ? 0 : isFavorite!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode) +
(rating == null ? 0 : rating!.hashCode); (rating == null ? 0 : rating!.hashCode);
@override @override
String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, rating=$rating]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -136,6 +141,11 @@ class AssetBulkUpdateDto {
} else { } else {
// json[r'longitude'] = null; // json[r'longitude'] = null;
} }
if (this.orientation != null) {
json[r'orientation'] = this.orientation;
} else {
// json[r'orientation'] = null;
}
if (this.rating != null) { if (this.rating != null) {
json[r'rating'] = this.rating; json[r'rating'] = this.rating;
} else { } else {
@@ -162,6 +172,7 @@ class AssetBulkUpdateDto {
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
orientation: AssetBulkUpdateDtoOrientationEnum.fromJson(json[r'orientation']),
rating: num.parse('${json[r'rating']}'), rating: num.parse('${json[r'rating']}'),
); );
} }
@@ -214,3 +225,95 @@ class AssetBulkUpdateDto {
}; };
} }
class AssetBulkUpdateDtoOrientationEnum {
/// Instantiate a new enum with the provided [value].
const AssetBulkUpdateDtoOrientationEnum._(this.value);
/// The underlying value of this enum member.
final int value;
@override
String toString() => value.toString();
int toJson() => value;
static const number1 = AssetBulkUpdateDtoOrientationEnum._(1);
static const number2 = AssetBulkUpdateDtoOrientationEnum._(2);
static const number3 = AssetBulkUpdateDtoOrientationEnum._(3);
static const number4 = AssetBulkUpdateDtoOrientationEnum._(4);
static const number5 = AssetBulkUpdateDtoOrientationEnum._(5);
static const number6 = AssetBulkUpdateDtoOrientationEnum._(6);
static const number7 = AssetBulkUpdateDtoOrientationEnum._(7);
static const number8 = AssetBulkUpdateDtoOrientationEnum._(8);
/// List of all possible values in this [enum][AssetBulkUpdateDtoOrientationEnum].
static const values = <AssetBulkUpdateDtoOrientationEnum>[
number1,
number2,
number3,
number4,
number5,
number6,
number7,
number8,
];
static AssetBulkUpdateDtoOrientationEnum? fromJson(dynamic value) => AssetBulkUpdateDtoOrientationEnumTypeTransformer().decode(value);
static List<AssetBulkUpdateDtoOrientationEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetBulkUpdateDtoOrientationEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetBulkUpdateDtoOrientationEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AssetBulkUpdateDtoOrientationEnum] to int,
/// and [decode] dynamic data back to [AssetBulkUpdateDtoOrientationEnum].
class AssetBulkUpdateDtoOrientationEnumTypeTransformer {
factory AssetBulkUpdateDtoOrientationEnumTypeTransformer() => _instance ??= const AssetBulkUpdateDtoOrientationEnumTypeTransformer._();
const AssetBulkUpdateDtoOrientationEnumTypeTransformer._();
int encode(AssetBulkUpdateDtoOrientationEnum data) => data.value;
/// Decodes a [dynamic value][data] to a AssetBulkUpdateDtoOrientationEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetBulkUpdateDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case 1: return AssetBulkUpdateDtoOrientationEnum.number1;
case 2: return AssetBulkUpdateDtoOrientationEnum.number2;
case 3: return AssetBulkUpdateDtoOrientationEnum.number3;
case 4: return AssetBulkUpdateDtoOrientationEnum.number4;
case 5: return AssetBulkUpdateDtoOrientationEnum.number5;
case 6: return AssetBulkUpdateDtoOrientationEnum.number6;
case 7: return AssetBulkUpdateDtoOrientationEnum.number7;
case 8: return AssetBulkUpdateDtoOrientationEnum.number8;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetBulkUpdateDtoOrientationEnumTypeTransformer] instance.
static AssetBulkUpdateDtoOrientationEnumTypeTransformer? _instance;
}

View File

@@ -0,0 +1,109 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AuditDeletesResponseDto {
/// Returns a new [AuditDeletesResponseDto] instance.
AuditDeletesResponseDto({
this.ids = const [],
required this.needsFullSync,
});
List<String> ids;
bool needsFullSync;
@override
bool operator ==(Object other) => identical(this, other) || other is AuditDeletesResponseDto &&
_deepEquality.equals(other.ids, ids) &&
other.needsFullSync == needsFullSync;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(needsFullSync.hashCode);
@override
String toString() => 'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
json[r'needsFullSync'] = this.needsFullSync;
return json;
}
/// Returns a new [AuditDeletesResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AuditDeletesResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AuditDeletesResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AuditDeletesResponseDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
needsFullSync: mapValueOfType<bool>(json, r'needsFullSync')!,
);
}
return null;
}
static List<AuditDeletesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AuditDeletesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AuditDeletesResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AuditDeletesResponseDto> mapFromJson(dynamic json) {
final map = <String, AuditDeletesResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AuditDeletesResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AuditDeletesResponseDto-objects as value to a dart map
static Map<String, List<AuditDeletesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AuditDeletesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AuditDeletesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'ids',
'needsFullSync',
};
}

View File

@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EntityType {
/// Instantiate a new enum with the provided [value].
const EntityType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const ASSET = EntityType._(r'ASSET');
static const ALBUM = EntityType._(r'ALBUM');
/// List of all possible values in this [enum][EntityType].
static const values = <EntityType>[
ASSET,
ALBUM,
];
static EntityType? fromJson(dynamic value) => EntityTypeTypeTransformer().decode(value);
static List<EntityType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EntityType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EntityType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [EntityType] to String,
/// and [decode] dynamic data back to [EntityType].
class EntityTypeTypeTransformer {
factory EntityTypeTypeTransformer() => _instance ??= const EntityTypeTypeTransformer._();
const EntityTypeTypeTransformer._();
String encode(EntityType data) => data.value;
/// Decodes a [dynamic value][data] to a EntityType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
EntityType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'ASSET': return EntityType.ASSET;
case r'ALBUM': return EntityType.ALBUM;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [EntityTypeTypeTransformer] instance.
static EntityTypeTypeTransformer? _instance;
}

View File

@@ -27,7 +27,6 @@ class SharedLinkResponseDto {
this.token, this.token,
required this.type, required this.type,
required this.userId, required this.userId,
required this.viewCount,
}); });
/// ///
@@ -64,8 +63,6 @@ class SharedLinkResponseDto {
String userId; String userId;
num viewCount;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SharedLinkResponseDto && bool operator ==(Object other) => identical(this, other) || other is SharedLinkResponseDto &&
other.album == album && other.album == album &&
@@ -81,8 +78,7 @@ class SharedLinkResponseDto {
other.showMetadata == showMetadata && other.showMetadata == showMetadata &&
other.token == token && other.token == token &&
other.type == type && other.type == type &&
other.userId == userId && other.userId == userId;
other.viewCount == viewCount;
@override @override
int get hashCode => int get hashCode =>
@@ -100,11 +96,10 @@ class SharedLinkResponseDto {
(showMetadata.hashCode) + (showMetadata.hashCode) +
(token == null ? 0 : token!.hashCode) + (token == null ? 0 : token!.hashCode) +
(type.hashCode) + (type.hashCode) +
(userId.hashCode) + (userId.hashCode);
(viewCount.hashCode);
@override @override
String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, password=$password, showMetadata=$showMetadata, token=$token, type=$type, userId=$userId, viewCount=$viewCount]'; String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, password=$password, showMetadata=$showMetadata, token=$token, type=$type, userId=$userId]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -142,7 +137,6 @@ class SharedLinkResponseDto {
} }
json[r'type'] = this.type; json[r'type'] = this.type;
json[r'userId'] = this.userId; json[r'userId'] = this.userId;
json[r'viewCount'] = this.viewCount;
return json; return json;
} }
@@ -169,7 +163,6 @@ class SharedLinkResponseDto {
token: mapValueOfType<String>(json, r'token'), token: mapValueOfType<String>(json, r'token'),
type: SharedLinkType.fromJson(json[r'type'])!, type: SharedLinkType.fromJson(json[r'type'])!,
userId: mapValueOfType<String>(json, r'userId')!, userId: mapValueOfType<String>(json, r'userId')!,
viewCount: num.parse('${json[r'viewCount']}'),
); );
} }
return null; return null;
@@ -229,7 +222,6 @@ class SharedLinkResponseDto {
'showMetadata', 'showMetadata',
'type', 'type',
'userId', 'userId',
'viewCount',
}; };
} }

View File

@@ -20,6 +20,7 @@ class UpdateAssetDto {
this.latitude, this.latitude,
this.livePhotoVideoId, this.livePhotoVideoId,
this.longitude, this.longitude,
this.orientation,
this.rating, this.rating,
}); });
@@ -73,6 +74,8 @@ class UpdateAssetDto {
/// ///
num? longitude; num? longitude;
UpdateAssetDtoOrientationEnum? orientation;
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
/// ///
@@ -92,6 +95,7 @@ class UpdateAssetDto {
other.latitude == latitude && other.latitude == latitude &&
other.livePhotoVideoId == livePhotoVideoId && other.livePhotoVideoId == livePhotoVideoId &&
other.longitude == longitude && other.longitude == longitude &&
other.orientation == orientation &&
other.rating == rating; other.rating == rating;
@override @override
@@ -104,10 +108,11 @@ class UpdateAssetDto {
(latitude == null ? 0 : latitude!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode) +
(rating == null ? 0 : rating!.hashCode); (rating == null ? 0 : rating!.hashCode);
@override @override
String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]'; String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, orientation=$orientation, rating=$rating]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -146,6 +151,11 @@ class UpdateAssetDto {
} else { } else {
// json[r'longitude'] = null; // json[r'longitude'] = null;
} }
if (this.orientation != null) {
json[r'orientation'] = this.orientation;
} else {
// json[r'orientation'] = null;
}
if (this.rating != null) { if (this.rating != null) {
json[r'rating'] = this.rating; json[r'rating'] = this.rating;
} else { } else {
@@ -170,6 +180,7 @@ class UpdateAssetDto {
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'), livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
orientation: UpdateAssetDtoOrientationEnum.fromJson(json[r'orientation']),
rating: num.parse('${json[r'rating']}'), rating: num.parse('${json[r'rating']}'),
); );
} }
@@ -221,3 +232,95 @@ class UpdateAssetDto {
}; };
} }
class UpdateAssetDtoOrientationEnum {
/// Instantiate a new enum with the provided [value].
const UpdateAssetDtoOrientationEnum._(this.value);
/// The underlying value of this enum member.
final int value;
@override
String toString() => value.toString();
int toJson() => value;
static const number1 = UpdateAssetDtoOrientationEnum._(1);
static const number2 = UpdateAssetDtoOrientationEnum._(2);
static const number3 = UpdateAssetDtoOrientationEnum._(3);
static const number4 = UpdateAssetDtoOrientationEnum._(4);
static const number5 = UpdateAssetDtoOrientationEnum._(5);
static const number6 = UpdateAssetDtoOrientationEnum._(6);
static const number7 = UpdateAssetDtoOrientationEnum._(7);
static const number8 = UpdateAssetDtoOrientationEnum._(8);
/// List of all possible values in this [enum][UpdateAssetDtoOrientationEnum].
static const values = <UpdateAssetDtoOrientationEnum>[
number1,
number2,
number3,
number4,
number5,
number6,
number7,
number8,
];
static UpdateAssetDtoOrientationEnum? fromJson(dynamic value) => UpdateAssetDtoOrientationEnumTypeTransformer().decode(value);
static List<UpdateAssetDtoOrientationEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateAssetDtoOrientationEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateAssetDtoOrientationEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [UpdateAssetDtoOrientationEnum] to int,
/// and [decode] dynamic data back to [UpdateAssetDtoOrientationEnum].
class UpdateAssetDtoOrientationEnumTypeTransformer {
factory UpdateAssetDtoOrientationEnumTypeTransformer() => _instance ??= const UpdateAssetDtoOrientationEnumTypeTransformer._();
const UpdateAssetDtoOrientationEnumTypeTransformer._();
int encode(UpdateAssetDtoOrientationEnum data) => data.value;
/// Decodes a [dynamic value][data] to a UpdateAssetDtoOrientationEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
UpdateAssetDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case 1: return UpdateAssetDtoOrientationEnum.number1;
case 2: return UpdateAssetDtoOrientationEnum.number2;
case 3: return UpdateAssetDtoOrientationEnum.number3;
case 4: return UpdateAssetDtoOrientationEnum.number4;
case 5: return UpdateAssetDtoOrientationEnum.number5;
case 6: return UpdateAssetDtoOrientationEnum.number6;
case 7: return UpdateAssetDtoOrientationEnum.number7;
case 8: return UpdateAssetDtoOrientationEnum.number8;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [UpdateAssetDtoOrientationEnumTypeTransformer] instance.
static UpdateAssetDtoOrientationEnumTypeTransformer? _instance;
}

View File

@@ -105,7 +105,7 @@ void main() {
when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) when(() => assetRepository.getAllByOwnerIdChecksum(any(), any()))
.thenAnswer((_) async => [initialAssets[3], null, null]); .thenAnswer((_) async => [initialAssets[3], null, null]);
when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []);
when(() => assetRepository.deleteByIds(any())).thenAnswer((_) async {}); when(() => assetRepository.deleteById(any())).thenAnswer((_) async {});
when(() => exifInfoRepository.updateAll(any())) when(() => exifInfoRepository.updateAll(any()))
.thenAnswer((_) async => []); .thenAnswer((_) async => []);
when(() => assetRepository.transaction<void>(any())).thenAnswer( when(() => assetRepository.transaction<void>(any())).thenAnswer(

View File

@@ -2079,6 +2079,65 @@
] ]
} }
}, },
"/audit/deletes": {
"get": {
"operationId": "getAuditDeletes",
"parameters": [
{
"name": "after",
"required": true,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "entityType",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/EntityType"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuditDeletesResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Audit"
]
}
},
"/auth/admin-sign-up": { "/auth/admin-sign-up": {
"post": { "post": {
"operationId": "signUpAdmin", "operationId": "signUpAdmin",
@@ -7904,6 +7963,21 @@
"longitude": { "longitude": {
"type": "number" "type": "number"
}, },
"orientation": {
"enum": [
1,
2,
3,
4,
5,
6,
7,
8
],
"maximum": 8,
"minimum": 1,
"type": "integer"
},
"rating": { "rating": {
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
@@ -8584,6 +8658,24 @@
], ],
"type": "string" "type": "string"
}, },
"AuditDeletesResponseDto": {
"properties": {
"ids": {
"items": {
"type": "string"
},
"type": "array"
},
"needsFullSync": {
"type": "boolean"
}
},
"required": [
"ids",
"needsFullSync"
],
"type": "object"
},
"AvatarResponse": { "AvatarResponse": {
"properties": { "properties": {
"color": { "color": {
@@ -8998,6 +9090,13 @@
}, },
"type": "object" "type": "object"
}, },
"EntityType": {
"enum": [
"ASSET",
"ALBUM"
],
"type": "string"
},
"ExifResponseDto": { "ExifResponseDto": {
"properties": { "properties": {
"city": { "city": {
@@ -11415,9 +11514,6 @@
}, },
"userId": { "userId": {
"type": "string" "type": "string"
},
"viewCount": {
"type": "number"
} }
}, },
"required": [ "required": [
@@ -11432,8 +11528,7 @@
"password", "password",
"showMetadata", "showMetadata",
"type", "type",
"userId", "userId"
"viewCount"
], ],
"type": "object" "type": "object"
}, },
@@ -12800,6 +12895,21 @@
"longitude": { "longitude": {
"type": "number" "type": "number"
}, },
"orientation": {
"enum": [
1,
2,
3,
4,
5,
6,
7,
8
],
"maximum": 8,
"minimum": 1,
"type": "integer"
},
"rating": { "rating": {
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,

View File

@@ -1 +1 @@
22.14.0 22.13.1

View File

@@ -12,20 +12,19 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.2", "@types/node": "^22.13.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
"node_modules/@oazapfts/runtime": { "node_modules/@oazapfts/runtime": {
"version": "1.0.4", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.3.tgz",
"integrity": "sha512-7t6C2shug/6tZhQgkCa532oTYBLEnbASV/i1SG1rH2GB4h3aQQujYciYSPT92hvN4IwTe8S2hPkN/6iiOyTlCg==", "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
"license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.4", "version": "22.13.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
"integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.2", "@types/node": "^22.13.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk" "directory": "open-api/typescript-sdk"
}, },
"volta": { "volta": {
"node": "22.14.0" "node": "22.13.1"
} }
} }

View File

@@ -391,6 +391,7 @@ export type AssetBulkUpdateDto = {
isFavorite?: boolean; isFavorite?: boolean;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
orientation?: Orientation;
rating?: number; rating?: number;
}; };
export type AssetBulkUploadCheckItem = { export type AssetBulkUploadCheckItem = {
@@ -439,6 +440,7 @@ export type UpdateAssetDto = {
latitude?: number; latitude?: number;
livePhotoVideoId?: string | null; livePhotoVideoId?: string | null;
longitude?: number; longitude?: number;
orientation?: Orientation;
rating?: number; rating?: number;
}; };
export type AssetMediaReplaceDto = { export type AssetMediaReplaceDto = {
@@ -449,6 +451,10 @@ export type AssetMediaReplaceDto = {
fileCreatedAt: string; fileCreatedAt: string;
fileModifiedAt: string; fileModifiedAt: string;
}; };
export type AuditDeletesResponseDto = {
ids: string[];
needsFullSync: boolean;
};
export type SignUpDto = { export type SignUpDto = {
email: string; email: string;
name: string; name: string;
@@ -1060,7 +1066,6 @@ export type SharedLinkResponseDto = {
token?: string | null; token?: string | null;
"type": SharedLinkType; "type": SharedLinkType;
userId: string; userId: string;
viewCount: number;
}; };
export type SharedLinkCreateDto = { export type SharedLinkCreateDto = {
albumId?: string; albumId?: string;
@@ -1910,6 +1915,22 @@ export function playAssetVideo({ id, key }: {
...opts ...opts
})); }));
} }
export function getAuditDeletes({ after, entityType, userId }: {
after: string;
entityType: EntityType;
userId?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AuditDeletesResponseDto;
}>(`/audit/deletes${QS.query(QS.explode({
after,
entityType,
userId
}))}`, {
...opts
}));
}
export function signUpAdmin({ signUpDto }: { export function signUpAdmin({ signUpDto }: {
signUpDto: SignUpDto; signUpDto: SignUpDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@@ -3462,6 +3483,16 @@ export enum AssetMediaStatus {
Replaced = "replaced", Replaced = "replaced",
Duplicate = "duplicate" Duplicate = "duplicate"
} }
export enum Orientation {
$1 = 1,
$2 = 2,
$3 = 3,
$4 = 4,
$5 = 5,
$6 = 6,
$7 = 7,
$8 = 8
}
export enum Action { export enum Action {
Accept = "accept", Accept = "accept",
Reject = "reject" Reject = "reject"
@@ -3480,6 +3511,10 @@ export enum AssetMediaSize {
Preview = "preview", Preview = "preview",
Thumbnail = "thumbnail" Thumbnail = "thumbnail"
} }
export enum EntityType {
Asset = "ASSET",
Album = "ALBUM"
}
export enum ManualJobName { export enum ManualJobName {
PersonCleanup = "person-cleanup", PersonCleanup = "person-cleanup",
TagCleanup = "tag-cleanup", TagCleanup = "tag-cleanup",

View File

@@ -1 +1 @@
22.14.0 22.13.1

View File

@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:20250218@sha256:04df131dafca34538685453e4a00387ffe14288edff43cc68cf44feb76c8f4c0 AS dev FROM ghcr.io/immich-app/base-server-dev:20250211@sha256:6ae577a6518e1ccca973db16955f4d79b01cac3ae122759ccb1c17bf6c330ba9 AS dev
RUN apt-get install --no-install-recommends -yqq tini RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -42,7 +42,7 @@ RUN npm run build
# prod build # prod build
FROM ghcr.io/immich-app/base-server-prod:20250218@sha256:c5980c358dc94b62cac2086edaa1fd1ae855340a0d8e386fd92bc527d8aaadaa FROM ghcr.io/immich-app/base-server-prod:20250211@sha256:879cfe3d2afd4b7bdb211f694f99b6c0679a8bbd3e96964ad6c878f8471d63ea
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \

3
server/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Immich server project
This project uses the [NestJS](https://nestjs.com/) web framework. Please refer to [the NestJS docs](https://docs.nestjs.com/) for information on getting started as a contributor to this project.

894
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,7 @@
"@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/context-async-hooks": "^1.24.0",
"@opentelemetry/exporter-prometheus": "^0.57.0", "@opentelemetry/exporter-prometheus": "^0.57.0",
"@opentelemetry/sdk-node": "^0.57.0", "@opentelemetry/sdk-node": "^0.57.0",
"@react-email/components": "^0.0.33", "@react-email/components": "^0.0.32",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0", "archiver": "^7.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",
@@ -112,7 +112,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.13.2", "@types/node": "^22.13.1",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
@@ -145,6 +145,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.14.0" "node": "22.13.1"
} }
} }

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuditDeletesDto, AuditDeletesResponseDto } from 'src/dtos/audit.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AuditService } from 'src/services/audit.service';
@ApiTags('Audit')
@Controller('audit')
export class AuditController {
constructor(private service: AuditService) {}
@Get('deletes')
@Authenticated()
getAuditDeletes(@Auth() auth: AuthDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
return this.service.getDeletes(auth, dto);
}
}

View File

@@ -4,6 +4,7 @@ import { APIKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller'; import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller'; import { AssetController } from 'src/controllers/asset.controller';
import { AuditController } from 'src/controllers/audit.controller';
import { AuthController } from 'src/controllers/auth.controller'; import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller'; import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller';
@@ -39,6 +40,7 @@ export const controllers = [
AppController, AppController,
AssetController, AssetController,
AssetMediaController, AssetMediaController,
AuditController,
AuthController, AuthController,
DownloadController, DownloadController,
DuplicateController, DuplicateController,

1
server/src/db.d.ts vendored
View File

@@ -312,7 +312,6 @@ export interface SharedLinks {
showExif: Generated<boolean>; showExif: Generated<boolean>;
type: string; type: string;
userId: string; userId: string;
viewCount: Generated<number>;
} }
export interface SmartSearch { export interface SmartSearch {

View File

@@ -14,7 +14,7 @@ import {
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType } from 'src/enum'; import { AssetType, ExifOrientation } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository'; import { AssetStats } from 'src/repositories/asset.repository';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
@@ -54,6 +54,12 @@ export class UpdateAssetBase {
@Max(5) @Max(5)
@Min(-1) @Min(-1)
rating?: number; rating?: number;
@Optional()
@Min(1)
@Max(8)
@ApiProperty({ type: 'integer' })
orientation?: ExifOrientation;
} }
export class AssetBulkUpdateDto extends UpdateAssetBase { export class AssetBulkUpdateDto extends UpdateAssetBase {

View File

@@ -94,7 +94,6 @@ export class SharedLinkResponseDto {
type!: SharedLinkType; type!: SharedLinkType;
createdAt!: Date; createdAt!: Date;
expiresAt!: Date | null; expiresAt!: Date | null;
viewCount!: number;
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
album?: AlbumResponseDto; album?: AlbumResponseDto;
allowUpload!: boolean; allowUpload!: boolean;
@@ -103,7 +102,7 @@ export class SharedLinkResponseDto {
showMetadata!: boolean; showMetadata!: boolean;
} }
const map = (sharedLink: SharedLinkEntity, { stripMetadata }: { stripMetadata: boolean }) => { export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
@@ -116,16 +115,35 @@ const map = (sharedLink: SharedLinkEntity, { stripMetadata }: { stripMetadata: b
userId: sharedLink.userId, userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'), key: sharedLink.key.toString('base64url'),
type: sharedLink.type, type: sharedLink.type,
viewCount: sharedLink.viewCount,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset)),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif, showMetadata: sharedLink.showExif,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
}; };
}; }
export const mapSharedLink = (sharedLink: SharedLinkEntity) => map(sharedLink, { stripMetadata: false }); export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
export const mapSharedLinkWithoutMetadata = (sharedLink: SharedLinkEntity) => map(sharedLink, { stripMetadata: true }); const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'),
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[],
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
};
}

View File

@@ -50,7 +50,7 @@ export const ImmichLayout = ({ children, preview }: ImmichLayoutProps) => (
<Section className="flex justify-center mb-12"> <Section className="flex justify-center mb-12">
<Img <Img
src="https://immich.app/img/immich-logo-inline-light.png" src="https://immich.app/img/immich-logo-inline-light.png"
className="h-12 antialiased rounded-none w-full" className="h-12 antialiased rounded-none"
alt="Immich" alt="Immich"
/> />
</Section> </Section>

View File

@@ -62,7 +62,4 @@ export class SharedLinkEntity {
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
albumId!: string | null; albumId!: string | null;
@Column({ default: 0 })
viewCount!: number;
} }

View File

@@ -1,14 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddViewCount1739916305466 implements MigrationInterface {
name = 'AddViewCount1739916305466'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" ADD "viewCount" integer NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "viewCount"`);
}
}

View File

@@ -169,6 +169,7 @@ from
and "asset_faces"."personId" = $1 and "asset_faces"."personId" = $1
and "assets"."isArchived" = $2 and "assets"."isArchived" = $2
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."livePhotoVideoId" is null
-- PersonRepository.getNumberOfPeople -- PersonRepository.getNumberOfPeople
select select

View File

@@ -194,12 +194,3 @@ where
"shared_links"."type" = $2 "shared_links"."type" = $2
or "albums"."id" is not null or "albums"."id" is not null
) )
-- SharedLinkRepository.incrementViewCount
update "shared_links"
set
"viewCount" = viewCount + 1
where
"shared_links"."id" = $1
returning
*

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz'; import geotz from 'geo-tz';
import { LogLevel } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
interface ExifDuration { interface ExifDuration {
@@ -101,6 +102,9 @@ export class MetadataRepository {
} }
async writeTags(path: string, tags: Partial<Tags>): Promise<void> { async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) {
this.logger.verbose(`Writing tags ${JSON.stringify(tags)} to ${path}`);
}
try { try {
await this.exiftool.write(path, tags); await this.exiftool.write(path, tags);
} catch (error) { } catch (error) {

View File

@@ -314,7 +314,8 @@ export class PersonRepository {
.onRef('assets.id', '=', 'asset_faces.assetId') .onRef('assets.id', '=', 'asset_faces.assetId')
.on('asset_faces.personId', '=', personId) .on('asset_faces.personId', '=', personId)
.on('assets.isArchived', '=', false) .on('assets.isArchived', '=', false)
.on('assets.deletedAt', 'is', null), .on('assets.deletedAt', 'is', null)
.on('assets.livePhotoVideoId', 'is', null),
) )
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count')) .select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -216,15 +216,6 @@ export class SharedLinkRepository {
await this.db.deleteFrom('shared_links').where('shared_links.id', '=', entity.id).execute(); await this.db.deleteFrom('shared_links').where('shared_links.id', '=', entity.id).execute();
} }
@GenerateSql({ params: [DummyValue.UUID] })
async incrementViewCount(id: string) {
await this.db
.updateTable('shared_links')
.set('viewCount', sql<number>`"viewCount" + 1`)
.where('shared_links.id', '=', id)
.execute();
}
private getSharedLinks(id: string) { private getSharedLinks(id: string) {
return this.db return this.db
.selectFrom('shared_links') .selectFrom('shared_links')

View File

@@ -100,7 +100,7 @@ export class AssetService extends BaseService {
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, orientation, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository }; const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: AssetEntity | null = null; let previousMotion: AssetEntity | null = null;
@@ -113,7 +113,7 @@ export class AssetService extends BaseService {
} }
} }
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating, orientation });
const asset = await this.assetRepository.update({ id, ...rest }); const asset = await this.assetRepository.update({ id, ...rest });
@@ -129,11 +129,12 @@ export class AssetService extends BaseService {
} }
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> { async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; const { ids, dateTimeOriginal, latitude, longitude, orientation, ...options } = dto;
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids });
// TODO rewrite this to support batching
for (const id of ids) { for (const id of ids) {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude, orientation });
} }
if ( if (
@@ -284,11 +285,14 @@ export class AssetService extends BaseService {
} }
private async updateMetadata(dto: ISidecarWriteJob) { private async updateMetadata(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const { id, description, dateTimeOriginal, latitude, longitude, rating, orientation } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating, orientation }, _.isUndefined);
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.assetRepository.upsertExif({ assetId: id, ...writes });
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });
if (orientation !== undefined) {
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id, notify: true } });
}
} }
} }
} }

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