Merge branch 'main' into improve_focus

This commit is contained in:
Min Idzelis 2025-04-22 22:39:22 -04:00 committed by GitHub
commit dccf33af2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
216 changed files with 3680 additions and 1664 deletions

View File

@ -7,6 +7,15 @@ on:
ref: ref:
required: false required: false
type: string type: string
secrets:
KEY_JKS:
required: true
ALIAS:
required: true
ANDROID_KEY_PASSWORD:
required: true
ANDROID_STORE_PASSWORD:
required: true
pull_request: pull_request:
push: push:
branches: [main] branches: [main]
@ -15,14 +24,21 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
outputs: outputs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
@ -38,22 +54,17 @@ jobs:
build-sign-android: build-sign-android:
name: Build and sign Android name: Build and sign Android
needs: pre-job needs: pre-job
permissions:
contents: read
# Skip when PR from a fork # Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }} if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
runs-on: macos-14 runs-on: macos-14
steps: steps:
- name: Determine ref
id: get-ref
run: |
input_ref="${{ inputs.ref }}"
github_ref="${{ github.sha }}"
ref="${input_ref:-$github_ref}"
echo "ref=$ref" >> $GITHUB_OUTPUT
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
ref: ${{ steps.get-ref.outputs.ref }} ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
with: with:

View File

@ -8,31 +8,38 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
cleanup: cleanup:
name: Cleanup name: Cleanup
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Cleanup - name: Cleanup
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REF: ${{ github.ref }}
run: | run: |
gh extension install actions/gh-actions-cache gh extension install actions/gh-actions-cache
REPO=${{ github.repository }} REPO=${{ github.repository }}
BRANCH=${{ github.ref }}
echo "Fetching list of cache keys" echo "Fetching list of cache keys"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 ) cacheKeysForPR=$(gh actions-cache list -R $REPO -B ${REF} -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys. ## Setting this to not fail the workflow while deleting cache keys.
set +e set +e
echo "Deleting caches..." echo "Deleting caches..."
for cacheKey in $cacheKeysForPR for cacheKey in $cacheKeysForPR
do do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm gh actions-cache delete $cacheKey -R "$REPO" -B "${REF}" --confirm
done done
echo "Done" echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -16,19 +16,23 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: permissions: {}
packages: write
jobs: jobs:
publish: publish:
name: CLI Publish name: CLI Publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./cli working-directory: ./cli
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
@ -48,11 +52,16 @@ jobs:
docker: docker:
name: Docker name: Docker
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: publish needs: publish
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0

View File

@ -24,6 +24,8 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
@ -43,6 +45,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL

View File

@ -12,18 +12,21 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: permissions: {}
packages: write
jobs: jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
outputs: outputs:
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
@ -45,6 +48,9 @@ jobs:
retag_ml: retag_ml:
name: Re-Tag ML name: Re-Tag ML
needs: pre-job needs: pre-job
permissions:
contents: read
packages: write
if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }} if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
@ -58,18 +64,22 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag image - name: Re-tag image
env:
REGISTRY_NAME: 'ghcr.io'
REPOSITORY: ${{ github.repository_owner }}/immich-machine-learning
TAG_OLD: main${{ matrix.suffix }}
TAG_PR: ${{ 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 }}
run: | run: |
REGISTRY_NAME="ghcr.io" docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
REPOSITORY=${{ github.repository_owner }}/immich-machine-learning docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
TAG_OLD=main${{ matrix.suffix }}
TAG_PR=${{ 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_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
needs: pre-job needs: pre-job
permissions:
contents: read
packages: write
if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }} if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
@ -83,18 +93,22 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag image - name: Re-tag image
env:
REGISTRY_NAME: 'ghcr.io'
REPOSITORY: ${{ github.repository_owner }}/immich-server
TAG_OLD: main${{ matrix.suffix }}
TAG_PR: ${{ 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 }}
run: | run: |
REGISTRY_NAME="ghcr.io" docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
REPOSITORY=${{ github.repository_owner }}/immich-server docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
TAG_OLD=main${{ matrix.suffix }}
TAG_PR=${{ 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_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
permissions:
contents: read
packages: write
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }} if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
env: env:
@ -148,6 +162,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@ -161,11 +177,14 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache key suffix - name: Generate cache key suffix
env:
REF: ${{ github.ref_name }}
run: | run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
else else
echo "CACHE_KEY_SUFFIX=$(echo ${{ github.ref_name }} | sed 's/[^a-zA-Z0-9]/-/g')" >> $GITHUB_ENV SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
fi fi
- name: Generate cache target - name: Generate cache target
@ -175,7 +194,7 @@ jobs:
# Essentially just ignore the cache output (forks can't write to registry cache) # 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 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,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${{ matrix.device }}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
fi fi
- name: Generate docker image tags - name: Generate docker image tags
@ -221,6 +240,10 @@ jobs:
merge_ml: merge_ml:
name: Merge & Push ML name: Merge & Push ML
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
actions: read
packages: write
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' && !github.event.pull_request.head.repo.fork }} if: ${{ needs.pre-job.outputs.should_run_ml == 'true' && !github.event.pull_request.head.repo.fork }}
env: env:
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
@ -308,15 +331,16 @@ jobs:
fi fi
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
SOURCE_ARGS=$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
echo "docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS"
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
build_and_push_server: build_and_push_server:
name: Build and Push Server name: Build and Push Server
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
env: env:
@ -340,6 +364,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
@ -353,11 +379,14 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache key suffix - name: Generate cache key suffix
env:
REF: ${{ github.ref_name }}
run: | run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
else else
echo "CACHE_KEY_SUFFIX=$(echo ${{ github.ref_name }} | sed 's/[^a-zA-Z0-9]/-/g')" >> $GITHUB_ENV SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
fi fi
- name: Generate cache target - name: Generate cache target
@ -367,7 +396,7 @@ jobs:
# Essentially just ignore the cache output (forks can't write to registry cache) # 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 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 }}-${{ env.CACHE_KEY_SUFFIX }},mode=max,compression=zstd" >> $GITHUB_OUTPUT echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
fi fi
- name: Generate docker image tags - name: Generate docker image tags
@ -413,6 +442,10 @@ jobs:
merge_server: merge_server:
name: Merge & Push Server name: Merge & Push Server
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
actions: read
packages: write
if: ${{ needs.pre-job.outputs.should_run_server == 'true' && !github.event.pull_request.head.repo.fork }} if: ${{ needs.pre-job.outputs.should_run_server == 'true' && !github.event.pull_request.head.repo.fork }}
env: env:
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server
@ -486,15 +519,14 @@ jobs:
fi fi
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
SOURCE_ARGS=$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
echo "docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS"
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
success-check-server: success-check-server:
name: Docker Build & Push Server Success name: Docker Build & Push Server Success
needs: [merge_server, retag_server] needs: [merge_server, retag_server]
permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
steps: steps:
@ -508,6 +540,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: [merge_ml, retag_ml]
permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
steps: steps:

View File

@ -10,14 +10,20 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
@ -33,6 +39,8 @@ jobs:
build: build:
name: Docs Build name: Docs Build
needs: pre-job needs: pre-job
permissions:
contents: read
if: ${{ needs.pre-job.outputs.should_run == 'true' }} if: ${{ needs.pre-job.outputs.should_run == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
@ -42,6 +50,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View File

@ -9,6 +9,9 @@ jobs:
checks: checks:
name: Docs Deploy Checks name: Docs Deploy Checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
actions: read
pull-requests: read
outputs: outputs:
parameters: ${{ steps.parameters.outputs.result }} parameters: ${{ steps.parameters.outputs.result }}
artifact: ${{ steps.get-artifact.outputs.result }} artifact: ${{ steps.get-artifact.outputs.result }}
@ -36,6 +39,8 @@ jobs:
- name: Determine deploy parameters - name: Determine deploy parameters
id: parameters id: parameters
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with: with:
script: | script: |
const eventType = context.payload.workflow_run.event; const eventType = context.payload.workflow_run.event;
@ -57,7 +62,8 @@ jobs:
} else if (eventType == "pull_request") { } else if (eventType == "pull_request") {
let pull_number = context.payload.workflow_run.pull_requests[0]?.number; let pull_number = context.payload.workflow_run.pull_requests[0]?.number;
if(!pull_number) { if(!pull_number) {
const response = await github.rest.search.issuesAndPullRequests({q: 'repo:${{ github.repository }} is:pr sha:${{ github.event.workflow_run.head_sha }}',per_page: 1,}) const {HEAD_SHA} = process.env;
const response = await github.rest.search.issuesAndPullRequests({q: `repo:${{ github.repository }} is:pr sha:${HEAD_SHA}`,per_page: 1,})
const items = response.data.items const items = response.data.items
if (items.length < 1) { if (items.length < 1) {
throw new Error("No pull request found for the commit") throw new Error("No pull request found for the commit")
@ -95,10 +101,16 @@ jobs:
name: Docs Deploy name: Docs Deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: checks needs: checks
permissions:
contents: read
actions: read
pull-requests: write
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Load parameters - name: Load parameters
id: parameters id: parameters
@ -162,9 +174,11 @@ jobs:
- name: Output Cleaning - name: Output Cleaning
id: clean id: clean
env:
TG_OUTPUT: ${{ steps.docs-output.outputs.tg_action_output }}
run: | run: |
TG_OUT=$(echo '${{ steps.docs-output.outputs.tg_action_output }}' | sed 's|%0A|\n|g ; s|%3C|<|g' | jq -c .) CLEANED=$(echo "$TG_OUTPUT" | sed 's|%0A|\n|g ; s|%3C|<|g' | jq -c .)
echo "output=$TG_OUT" >> $GITHUB_OUTPUT echo "output=$CLEANED" >> $GITHUB_OUTPUT
- name: Publish to Cloudflare Pages - name: Publish to Cloudflare Pages
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1

View File

@ -3,13 +3,20 @@ on:
pull_request_target: pull_request_target:
types: [closed] types: [closed]
permissions: {}
jobs: jobs:
deploy: deploy:
name: Docs Destroy name: Docs Destroy
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Destroy Docs Subdomain - name: Destroy Docs Subdomain
env: env:

View File

@ -4,11 +4,14 @@ on:
pull_request: pull_request:
types: [labeled] types: [labeled]
permissions: {}
jobs: jobs:
fix-formatting: fix-formatting:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'fix:formatting' }} if: ${{ github.event.label.name == 'fix:formatting' }}
permissions: permissions:
contents: write
pull-requests: write pull-requests: write
steps: steps:
- name: Generate a token - name: Generate a token
@ -23,6 +26,7 @@ jobs:
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View File

@ -4,6 +4,8 @@ on:
pull_request_target: pull_request_target:
types: [opened, labeled, unlabeled, synchronize] types: [opened, labeled, unlabeled, synchronize]
permissions: {}
jobs: jobs:
validate-release-label: validate-release-label:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -2,6 +2,8 @@ name: 'Pull Request Labeler'
on: on:
- pull_request_target - pull_request_target
permissions: {}
jobs: jobs:
labeler: labeler:
permissions: permissions:

View File

@ -4,9 +4,13 @@ on:
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened, edited]
permissions: {}
jobs: jobs:
validate-pr-title: validate-pr-title:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
pull-requests: write
steps: steps:
- name: PR Conventional Commit Validation - name: PR Conventional Commit Validation
uses: ytanikin/PRConventionalCommits@b628c5a234cc32513014b7bfdd1e47b532124d98 # 1.3.0 uses: ytanikin/PRConventionalCommits@b628c5a234cc32513014b7bfdd1e47b532124d98 # 1.3.0

View File

@ -21,13 +21,14 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-root group: ${{ github.workflow }}-${{ github.ref }}-root
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
bump_version: bump_version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }} ref: ${{ steps.push-tag.outputs.commit_long_sha }}
permissions: {} # No job-level permissions are needed because it uses the app-token
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
@ -40,6 +41,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5 uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5
@ -59,14 +61,20 @@ jobs:
build_mobile: build_mobile:
uses: ./.github/workflows/build-mobile.yml uses: ./.github/workflows/build-mobile.yml
needs: bump_version needs: bump_version
secrets: inherit secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
with: with:
ref: ${{ needs.bump_version.outputs.ref }} ref: ${{ needs.bump_version.outputs.ref }}
prepare_release: prepare_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build_mobile needs: build_mobile
permissions:
actions: read # To download the app artifact
# No content permissions are needed because it uses the app-token
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
@ -79,6 +87,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
- name: Download APK - name: Download APK
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
@ -90,6 +99,7 @@ jobs:
with: with:
draft: true draft: true
tag_name: ${{ env.IMMICH_VERSION }} tag_name: ${{ env.IMMICH_VERSION }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true generate_release_notes: true
body_path: misc/release/notes.tmpl body_path: misc/release/notes.tmpl
files: | files: |

View File

@ -4,6 +4,8 @@ on:
pull_request: pull_request:
types: [labeled, closed] types: [labeled, closed]
permissions: {}
jobs: jobs:
comment-status: comment-status:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -4,18 +4,22 @@ on:
release: release:
types: [published] types: [published]
permissions: permissions: {}
packages: write
jobs: jobs:
publish: publish:
name: Publish `@immich/sdk` name: Publish `@immich/sdk`
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:

View File

@ -9,14 +9,20 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
outputs: outputs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
@ -33,12 +39,14 @@ jobs:
name: Run Dart Code Analysis name: Run Dart Code Analysis
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run == 'true' }} if: ${{ needs.pre-job.outputs.should_run == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2 uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2
@ -69,9 +77,11 @@ jobs:
- name: Verify files have not changed - name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: | run: |
echo "ERROR: Generated files not up to date! Run make_build inside the mobile directory" echo "ERROR: Generated files not up to date! Run make_build inside the mobile directory"
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" echo "Changed files: ${CHANGED_FILES}"
exit 1 exit 1
- name: Run dart analyze - name: Run dart analyze

View File

@ -9,9 +9,13 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
outputs: outputs:
should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
@ -25,6 +29,9 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
@ -58,6 +65,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./server working-directory: ./server
@ -65,6 +74,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -95,6 +106,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./cli working-directory: ./cli
@ -102,6 +115,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -136,6 +151,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
runs-on: windows-latest runs-on: windows-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./cli working-directory: ./cli
@ -143,6 +160,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -170,6 +189,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_web == 'true' }} if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./web working-directory: ./web
@ -177,6 +198,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -215,6 +238,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }} if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
@ -222,6 +247,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -254,6 +281,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./server working-directory: ./server
@ -261,6 +290,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -279,6 +310,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }} if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
runs-on: mich runs-on: mich
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
@ -287,6 +320,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
- name: Setup Node - name: Setup Node
@ -321,6 +355,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }} if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
runs-on: mich runs-on: mich
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
@ -329,6 +365,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
- name: Setup Node - name: Setup Node
@ -362,8 +399,13 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }} if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2 uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2
with: with:
@ -378,11 +420,16 @@ jobs:
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: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./machine-learning working-directory: ./machine-learning
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5 uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5
@ -411,6 +458,8 @@ jobs:
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs['should_run_.github'] == 'true' }} if: ${{ needs.pre-job.outputs['should_run_.github'] == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
defaults: defaults:
run: run:
working-directory: ./.github working-directory: ./.github
@ -418,6 +467,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -434,22 +485,31 @@ jobs:
shellcheck: shellcheck:
name: ShellCheck name: ShellCheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Run ShellCheck - name: Run ShellCheck
uses: ludeeus/action-shellcheck@master uses: ludeeus/action-shellcheck@master
with: with:
ignore_paths: >- ignore_paths: >-
**/open-api/** **/open-api/**
**/openapi/** **/openapi**
**/node_modules/** **/node_modules/**
generated-api-up-to-date: generated-api-up-to-date:
name: OpenAPI Clients name: OpenAPI Clients
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -476,14 +536,18 @@ jobs:
- name: Verify files have not changed - name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: | run: |
echo "ERROR: Generated files not up to date!" echo "ERROR: Generated files not up to date!"
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" echo "Changed files: ${CHANGED_FILES}"
exit 1 exit 1
generated-typeorm-migrations-up-to-date: generated-typeorm-migrations-up-to-date:
name: TypeORM Checks name: TypeORM Checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
postgres: postgres:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
@ -505,6 +569,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@ -521,7 +587,7 @@ jobs:
run: npm run migrations:run run: npm run migrations:run
- name: Test npm run schema:reset command works - name: Test npm run schema:reset command works
run: npm run typeorm:schema:reset run: npm run schema:reset
- name: Generate new migrations - name: Generate new migrations
continue-on-error: true continue-on-error: true
@ -535,9 +601,11 @@ jobs:
server/src server/src
- name: Verify migration files have not changed - name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: | run: |
echo "ERROR: Generated migration files not up to date!" echo "ERROR: Generated migration files not up to date!"
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" echo "Changed files: ${CHANGED_FILES}"
cat ./src/*-TestMigration.ts cat ./src/*-TestMigration.ts
exit 1 exit 1
@ -555,9 +623,11 @@ jobs:
- name: Verify SQL files have not changed - name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true' if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-sql-files.outputs.changed_files }}
run: | run: |
echo "ERROR: Generated SQL files not up to date!" echo "ERROR: Generated SQL files not up to date!"
echo "Changed files: ${{ steps.verify-changed-sql-files.outputs.changed_files }}" echo "Changed files: ${CHANGED_FILES}"
exit 1 exit 1
# mobile-integration-tests: # mobile-integration-tests:

View File

@ -4,30 +4,32 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions: {}
jobs: jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
outputs: outputs:
should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}} should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with: with:
filters: | filters: |
i18n: i18n:
- 'i18n/!(en)**\.json' - 'i18n/!(en)**\.json'
- name: Debug
run: |
echo "Should run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}}"
echo "Found i18n paths: ${{ steps.found_paths.outputs.i18n }}"
echo "Head ref: ${{ github.head_ref }}"
enforce-lock: enforce-lock:
name: Check Weblate Lock name: Check Weblate Lock
needs: [pre-job] needs: [pre-job]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: {}
if: ${{ needs.pre-job.outputs.should_run == 'true' }} if: ${{ needs.pre-job.outputs.should_run == 'true' }}
steps: steps:
- name: Check weblate lock - name: Check weblate lock
@ -47,6 +49,7 @@ jobs:
name: Weblate Lock Check Success name: Weblate Lock Check Success
needs: [enforce-lock] needs: [enforce-lock]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: {}
if: always() if: always()
steps: steps:
- name: Any jobs failed? - name: Any jobs failed?

4
cli/package-lock.json generated
View File

@ -27,7 +27,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@ -61,7 +61,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },

View File

@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",

View File

@ -23,23 +23,32 @@ Refer to the official [postgres documentation](https://www.postgresql.org/docs/c
It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored. It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored.
::: :::
### Automatic Database Backups ### Automatic Database Dumps
For convenience, Immich will automatically create database backups by default. The backups are stored in `UPLOAD_LOCATION/backups`. :::warning
As mentioned above, you should make your own backup of these together with the asset folders as noted below. The automatic database dumps can be used to restore the database in the event of damage to the Postgres database files.
You can adjust the schedule and amount of kept backups in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup). There is no monitoring for these dumps and you will not be notified if they are unsuccessful.
By default, Immich will keep the last 14 backups and create a new backup every day at 2:00 AM. :::
#### Trigger Backup :::caution
The database dumps do **NOT** contain any pictures or videos, only metadata. They are only usable with a copy of the other files in `UPLOAD_LOCATION` as outlined below.
:::
You are able to trigger a backup in the [admin job status page](http://my.immich.app/admin/jobs-status). For disaster-recovery purposes, Immich will automatically create database dumps. The dumps are stored in `UPLOAD_LOCATION/backups`.
Visit the page, open the "Create job" modal from the top right, select "Backup Database" and click "Confirm". Please be sure to make your own, independent backup of the database together with the asset folders as noted below.
A job will run and trigger a backup, you can verify this worked correctly by checking the logs or the backup folder. You can adjust the schedule and amount of kept database dumps in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup).
This backup will count towards the last X backups that will be kept based on your settings. By default, Immich will keep the last 14 database dumps and create a new dump every day at 2:00 AM.
#### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
This dumps will count towards the last `X` dumps that will be kept based on your settings.
#### Restoring #### Restoring
We hope to make restoring simpler in future versions, for now you can find the backups in the `UPLOAD_LOCATION/backups` folder on your host. We hope to make restoring simpler in future versions, for now you can find the database dumps in the `UPLOAD_LOCATION/backups` folder on your host.
Then please follow the steps in the following section for restoring the database. Then please follow the steps in the following section for restoring the database.
### Manual Backup and Restore ### Manual Backup and Restore

View File

@ -1,14 +1,14 @@
# Database Migrations # Database Migrations
After making any changes in the `server/src/entities`, a database migration need to run in order to register the changes in the database. Follow the steps below to create a new migration. After making any changes in the `server/src/schema`, a database migration need to run in order to register the changes in the database. Follow the steps below to create a new migration.
1. Run the command 1. Run the command
```bash ```bash
npm run typeorm:migrations:generate <migration-name> npm run migrations:generate <migration-name>
``` ```
2. Check if the migration file makes sense. 2. Check if the migration file makes sense.
3. Move the migration file to folder `./server/src/migrations` in your code editor. 3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately. The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.

View File

@ -42,7 +42,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
- The GPU must have compute capability 5.2 or greater. - The GPU must have compute capability 5.2 or greater.
- The server must have the official NVIDIA driver installed. - The server must have the official NVIDIA driver installed.
- The installed driver must be >= 535 (it must support CUDA 12.2). - The installed driver must be >= 545 (it must support CUDA 12.3).
- On Linux (except for WSL2), you also need to have [NVIDIA Container Toolkit][nvct] installed. - On Linux (except for WSL2), you also need to have [NVIDIA Container Toolkit][nvct] installed.
#### ROCm #### ROCm

5
docs/src/pages/errors.md Normal file
View File

@ -0,0 +1,5 @@
# Errors
## TypeORM Upgrade
The upgrade to Immich `v2.x.x` has a required upgrade path to `v1.132.0+`. This means it is required to start up the application at least once on version `1.132.0` (or later). Doing so will complete database schema upgrades that are required for `v2.0.0`. After Immich has successfully booted on this version, shut the system down and try the `v2.x.x` upgrade again.

View File

@ -4,6 +4,7 @@ import Layout from '@theme/Layout';
import { discordPath, discordViewBox } from '@site/src/components/svg-paths'; import { discordPath, discordViewBox } from '@site/src/components/svg-paths';
import ThemedImage from '@theme/ThemedImage'; import ThemedImage from '@theme/ThemedImage';
import Icon from '@mdi/react'; import Icon from '@mdi/react';
import { mdiAndroid } from '@mdi/js';
function HomepageHeader() { function HomepageHeader() {
return ( return (
<header> <header>
@ -88,11 +89,18 @@ function HomepageHeader() {
<img className="h-24" alt="Get it on Google Play" src="/img/google-play-badge.png" /> <img className="h-24" alt="Get it on Google Play" src="/img/google-play-badge.png" />
</a> </a>
</div> </div>
<div className="h-24"> <div className="h-24">
<a href="https://apps.apple.com/sg/app/immich/id1613945652"> <a href="https://apps.apple.com/sg/app/immich/id1613945652">
<img className="h-24 sm:p-3.5 p-3" alt="Download on the App Store" src="/img/ios-app-store-badge.svg" /> <img className="h-24 sm:p-3.5 p-3" alt="Download on the App Store" src="/img/ios-app-store-badge.svg" />
</a> </a>
</div> </div>
<div className="h-24">
<a href="https://github.com/immich-app/immich/releases/latest">
<img className="h-24 sm:p-3.5 p-3" alt="Download APK" src="/img/download-apk-github.svg" />
</a>
</div>
</div> </div>
<ThemedImage <ThemedImage
sources={{ dark: '/img/app-qr-code-dark.svg', light: '/img/app-qr-code-light.svg' }} sources={{ dark: '/img/app-qr-code-dark.svg', light: '/img/app-qr-code-light.svg' }}

View File

@ -76,6 +76,7 @@ import {
mdiWeb, mdiWeb,
mdiDatabaseOutline, mdiDatabaseOutline,
mdiLinkEdit, mdiLinkEdit,
mdiTagFaces,
mdiMovieOpenPlayOutline, mdiMovieOpenPlayOutline,
} from '@mdi/js'; } from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
@ -83,6 +84,8 @@ import React from 'react';
import { Item, Timeline } from '../components/timeline'; import { Item, Timeline } from '../components/timeline';
const releases = { const releases = {
'v1.130.0': new Date(2025, 2, 25),
'v1.127.0': new Date(2025, 1, 26),
'v1.122.0': new Date(2024, 11, 5), 'v1.122.0': new Date(2024, 11, 5),
'v1.120.0': new Date(2024, 10, 6), 'v1.120.0': new Date(2024, 10, 6),
'v1.114.0': new Date(2024, 8, 6), 'v1.114.0': new Date(2024, 8, 6),
@ -242,6 +245,21 @@ const roadmap: Item[] = [
]; ];
const milestones: Item[] = [ const milestones: Item[] = [
withRelease({
icon: mdiFolderMultiple,
iconColor: 'brown',
title: 'Folders view in the mobile app',
description: 'Browse your photos and videos in their folder structure inside the mobile app',
release: 'v1.130.0',
}),
withRelease({
icon: mdiTagFaces,
iconColor: 'teal',
title: 'Manual face tagging',
description:
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
release: 'v1.127.0',
}),
{ {
icon: mdiStar, icon: mdiStar,
iconColor: 'gold', iconColor: 'gold',
@ -266,8 +284,8 @@ const milestones: Item[] = [
withRelease({ withRelease({
icon: mdiDatabaseOutline, icon: mdiDatabaseOutline,
iconColor: 'brown', iconColor: 'brown',
title: 'Automatic database backups', title: 'Automatic database dumps',
description: 'Database backups are now integrated into the Immich server', description: 'Database dumps are now integrated into the Immich server',
release: 'v1.120.0', release: 'v1.120.0',
}), }),
{ {
@ -300,7 +318,7 @@ const milestones: Item[] = [
withRelease({ withRelease({
icon: mdiFolderMultiple, icon: mdiFolderMultiple,
iconColor: 'brown', iconColor: 'brown',
title: 'Folders', title: 'Folders view',
description: 'Browse your photos and videos in their folder structure', description: 'Browse your photos and videos in their folder structure',
release: 'v1.113.0', release: 'v1.113.0',
}), }),

13
docs/static/img/download-apk-github.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

6
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.14.0", "@types/node": "^22.14.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",
@ -66,7 +66,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@ -100,7 +100,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },

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.14.0", "@types/node": "^22.14.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",

View File

@ -39,11 +39,11 @@
"authentication_settings_disable_all": "Are you sure you want to disable all login methods? Login will be completely disabled.", "authentication_settings_disable_all": "Are you sure you want to disable all login methods? Login will be completely disabled.",
"authentication_settings_reenable": "To re-enable, use a <link>Server Command</link>.", "authentication_settings_reenable": "To re-enable, use a <link>Server Command</link>.",
"background_task_job": "Background Tasks", "background_task_job": "Background Tasks",
"backup_database": "Backup Database", "backup_database": "Create Database Dump",
"backup_database_enable_description": "Enable database backups", "backup_database_enable_description": "Enable database dumps",
"backup_keep_last_amount": "Amount of previous backups to keep", "backup_keep_last_amount": "Amount of previous dumps to keep",
"backup_settings": "Backup Settings", "backup_settings": "Database Dump Settings",
"backup_settings_description": "Manage database backup settings", "backup_settings_description": "Manage database dump settings. Note: These jobs are not monitored and you will not be notified of failure.",
"check_all": "Check All", "check_all": "Check All",
"cleanup": "Cleanup", "cleanup": "Cleanup",
"cleared_jobs": "Cleared jobs for: {job}", "cleared_jobs": "Cleared jobs for: {job}",
@ -996,6 +996,7 @@
"filetype": "Filetype", "filetype": "Filetype",
"filter": "Filter", "filter": "Filter",
"filter_people": "Filter people", "filter_people": "Filter people",
"filter_places": "Filter places",
"find_them_fast": "Find them fast by name with search", "find_them_fast": "Find them fast by name with search",
"fix_incorrect_match": "Fix incorrect match", "fix_incorrect_match": "Fix incorrect match",
"folder": "Folder", "folder": "Folder",

View File

@ -35,6 +35,7 @@ linter:
analyzer: analyzer:
exclude: exclude:
- openapi/** - openapi/**
- build/**
- lib/generated_plugin_registrant.dart - lib/generated_plugin_registrant.dart
- lib/**/*.g.dart - lib/**/*.g.dart
- lib/**/*.drift.dart - lib/**/*.drift.dart
@ -92,6 +93,9 @@ custom_lint:
allowed: allowed:
# required / wanted # required / wanted
- lib/repositories/*_api.repository.dart - lib/repositories/*_api.repository.dart
- lib/domain/models/sync_event.model.dart
- lib/{domain,infrastructure}/**/sync_stream.*
- lib/{domain,infrastructure}/**/sync_api.*
- lib/infrastructure/repositories/*_api.repository.dart - lib/infrastructure/repositories/*_api.repository.dart
- lib/infrastructure/utils/*.converter.dart - lib/infrastructure/utils/*.converter.dart
# acceptable exceptions for the time being # acceptable exceptions for the time being
@ -144,7 +148,9 @@ dart_code_metrics:
- avoid-global-state - avoid-global-state
- avoid-inverted-boolean-checks - avoid-inverted-boolean-checks
- avoid-late-final-reassignment - avoid-late-final-reassignment
- avoid-local-functions - avoid-local-functions:
exclude:
- test/**.dart
- avoid-negated-conditions - avoid-negated-conditions
- avoid-nested-streams-and-futures - avoid-nested-streams-and-futures
- avoid-referencing-subclasses - avoid-referencing-subclasses

View File

@ -1,3 +1,4 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- drift: true

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
# platform :ios, '12.0' platform :ios, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@ -45,7 +45,7 @@ post_install do |installer|
installer.generated_projects.each do |project| installer.generated_projects.each do |project|
project.targets.each do |target| project.targets.each do |target|
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
end end
end end
end end

View File

@ -224,7 +224,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
background_downloader: b42a56120f5348bff70e74222f0e9e6f7f1a1537 background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
@ -261,6 +261,6 @@ SPEC CHECKSUMS:
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
PODFILE CHECKSUM: 03b7eead4ee77b9e778179eeb0f3b5513617451c PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@ -546,7 +546,7 @@
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -690,7 +690,7 @@
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -720,7 +720,7 @@
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@ -5,5 +5,9 @@ const double downloadFailed = -2;
// Number of log entries to retain on app start // Number of log entries to retain on app start
const int kLogTruncateLimit = 250; const int kLogTruncateLimit = 250;
// Sync
const int kSyncEventBatchSize = 5000;
// Hash batch limits
const int kBatchHashFileLimit = 128; const int kBatchHashFileLimit = 128;
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB

View File

@ -1,7 +1,12 @@
import 'package:immich_mobile/domain/models/sync/sync_event.model.dart'; import 'package:http/http.dart' as http;
import 'package:immich_mobile/domain/models/sync_event.model.dart';
abstract interface class ISyncApiRepository { abstract interface class ISyncApiRepository {
Future<void> ack(String data); Future<void> ack(List<String> data);
Stream<List<SyncEvent>> watchUserSyncEvent(); Future<void> streamChanges(
Function(List<SyncEvent>, Function() abort) onData, {
int batchSize,
http.Client? httpClient,
});
} }

View File

@ -0,0 +1,18 @@
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:openapi/api.dart';
abstract interface class ISyncStreamRepository implements IDatabaseRepository {
Future<void> updateUsersV1(Iterable<SyncUserV1> data);
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data);
Future<void> updatePartnerV1(Iterable<SyncPartnerV1> data);
Future<void> deletePartnerV1(Iterable<SyncPartnerDeleteV1> data);
Future<void> updateAssetsV1(Iterable<SyncAssetV1> data);
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data);
Future<void> updateAssetsExifV1(Iterable<SyncAssetExifV1> data);
Future<void> updatePartnerAssetsV1(Iterable<SyncAssetV1> data);
Future<void> deletePartnerAssetsV1(Iterable<SyncAssetDeleteV1> data);
Future<void> updatePartnerAssetsExifV1(Iterable<SyncAssetExifV1> data);
}

View File

@ -1,14 +0,0 @@
class SyncEvent {
// dynamic
final dynamic data;
final String ack;
SyncEvent({
required this.data,
required this.ack,
});
@override
String toString() => 'SyncEvent(data: $data, ack: $ack)';
}

View File

@ -0,0 +1,13 @@
import 'package:openapi/api.dart';
class SyncEvent {
final SyncEntityType type;
// ignore: avoid-dynamic
final dynamic data;
final String ack;
const SyncEvent({required this.type, required this.data, required this.ack});
@override
String toString() => 'SyncEvent(type: $type, data: $data, ack: $ack)';
}

View File

@ -1,49 +1,92 @@
// ignore_for_file: avoid-passing-async-when-sync-expected
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart'; import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class SyncStreamService { class SyncStreamService {
final Logger _logger = Logger('SyncStreamService');
final ISyncApiRepository _syncApiRepository; final ISyncApiRepository _syncApiRepository;
final ISyncStreamRepository _syncStreamRepository;
final bool Function()? _cancelChecker;
SyncStreamService(this._syncApiRepository); SyncStreamService({
required ISyncApiRepository syncApiRepository,
required ISyncStreamRepository syncStreamRepository,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
_cancelChecker = cancelChecker;
StreamSubscription? _userSyncSubscription; bool get isCancelled => _cancelChecker?.call() ?? false;
void syncUsers() { Future<void> sync() => _syncApiRepository.streamChanges(_handleEvents);
_userSyncSubscription =
_syncApiRepository.watchUserSyncEvent().listen((events) async { Future<void> _handleEvents(List<SyncEvent> events, Function() abort) async {
List<SyncEvent> items = [];
for (final event in events) { for (final event in events) {
if (event.data is SyncUserV1) { if (isCancelled) {
final data = event.data as SyncUserV1; _logger.warning("Sync stream cancelled");
debugPrint("User Update: $data"); abort();
return;
// final user = await _userRepository.get(data.id);
// if (user == null) {
// continue;
// }
// user.name = data.name;
// user.email = data.email;
// user.updatedAt = DateTime.now();
// await _userRepository.update(user);
// await _syncApiRepository.ack(event.ack);
} }
if (event.data is SyncUserDeleteV1) { if (event.type != items.firstOrNull?.type) {
final data = event.data as SyncUserDeleteV1; await _processBatch(items);
debugPrint("User delete: $data");
// await _syncApiRepository.ack(event.ack);
}
}
});
} }
Future<void> dispose() async { items.add(event);
await _userSyncSubscription?.cancel(); }
await _processBatch(items);
}
Future<void> _processBatch(List<SyncEvent> batch) async {
if (batch.isEmpty) {
return;
}
final type = batch.first.type;
await _handleSyncData(type, batch.map((e) => e.data));
await _syncApiRepository.ack([batch.last.ack]);
batch.clear();
}
Future<void> _handleSyncData(
SyncEntityType type,
// ignore: avoid-dynamic
Iterable<dynamic> data,
) async {
_logger.fine("Processing sync data for $type of length ${data.length}");
// ignore: prefer-switch-expression
switch (type) {
case SyncEntityType.userV1:
return _syncStreamRepository.updateUsersV1(data.cast());
case SyncEntityType.userDeleteV1:
return _syncStreamRepository.deleteUsersV1(data.cast());
case SyncEntityType.partnerV1:
return _syncStreamRepository.updatePartnerV1(data.cast());
case SyncEntityType.partnerDeleteV1:
return _syncStreamRepository.deletePartnerV1(data.cast());
case SyncEntityType.assetV1:
return _syncStreamRepository.updateAssetsV1(data.cast());
case SyncEntityType.assetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updatePartnerAssetsV1(data.cast());
case SyncEntityType.partnerAssetDeleteV1:
return _syncStreamRepository.deletePartnerAssetsV1(data.cast());
case SyncEntityType.partnerAssetExifV1:
return _syncStreamRepository.updatePartnerAssetsExifV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}
} }
} }

View File

@ -0,0 +1,39 @@
// ignore_for_file: avoid-passing-async-when-sync-expected
import 'dart:async';
import 'package:immich_mobile/providers/infrastructure/sync_stream.provider.dart';
import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart';
class BackgroundSyncManager {
Cancelable<void>? _syncTask;
BackgroundSyncManager();
Future<void> cancel() {
final futures = <Future>[];
if (_syncTask != null) {
futures.add(_syncTask!.future);
}
_syncTask?.cancel();
_syncTask = null;
return Future.wait(futures);
}
Future<void> sync() {
if (_syncTask != null) {
return _syncTask!.future;
}
_syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
);
_syncTask!.whenComplete(() {
_syncTask = null;
});
return _syncTask!.future;
}
}

View File

@ -1,3 +1,7 @@
import 'dart:typed_data';
import 'package:uuid/parsing.dart';
extension StringExtension on String { extension StringExtension on String {
String capitalize() { String capitalize() {
return split(" ") return split(" ")
@ -29,3 +33,8 @@ extension DurationExtension on String {
return int.parse(this); return int.parse(this);
} }
} }
extension UUIDExtension on String {
Uint8List toUuidByte({bool shouldValidate = false}) =>
UuidParsing.parseAsByteList(this, validate: shouldValidate);
}

View File

@ -1,55 +1,72 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
import 'package:immich_mobile/domain/models/sync/sync_event.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class SyncApiRepository implements ISyncApiRepository { class SyncApiRepository implements ISyncApiRepository {
final Logger _logger = Logger('SyncApiRepository');
final ApiService _api; final ApiService _api;
const SyncApiRepository(this._api); SyncApiRepository(this._api);
@override @override
Stream<List<SyncEvent>> watchUserSyncEvent() { Future<void> ack(List<String> data) {
return _getSyncStream( return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
SyncStreamDto(types: [SyncRequestType.usersV1]),
);
} }
@override @override
Future<void> ack(String data) { Future<void> streamChanges(
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: [data])); Function(List<SyncEvent>, Function() abort) onData, {
} int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
Stream<List<SyncEvent>> _getSyncStream( }) async {
SyncStreamDto dto, { // ignore: avoid-unused-assignment
int batchSize = 5000, final stopwatch = Stopwatch()..start();
}) async* { final client = httpClient ?? http.Client();
final client = http.Client();
final endpoint = "${_api.apiClient.basePath}/sync/stream"; final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = <String, String>{ final headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/jsonlines+json', 'Accept': 'application/jsonlines+json',
}; };
final queryParams = <QueryParam>[];
final headerParams = <String, String>{}; final headerParams = <String, String>{};
await _api.applyToParams(queryParams, headerParams); await _api.applyToParams([], headerParams);
headers.addAll(headerParams); headers.addAll(headerParams);
final request = http.Request('POST', Uri.parse(endpoint)); final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers); request.headers.addAll(headers);
request.body = jsonEncode(dto.toJson()); request.body = jsonEncode(
SyncStreamDto(
types: [
SyncRequestType.usersV1,
SyncRequestType.partnersV1,
SyncRequestType.assetsV1,
SyncRequestType.partnerAssetsV1,
SyncRequestType.assetExifsV1,
SyncRequestType.partnerAssetExifsV1,
],
).toJson(),
);
String previousChunk = ''; String previousChunk = '';
List<String> lines = []; List<String> lines = [];
bool shouldAbort = false;
void abort() {
_logger.warning("Abort requested, stopping sync stream");
shouldAbort = true;
}
try { try {
final response = await client.send(request); final response =
await client.send(request).timeout(const Duration(seconds: 20));
if (response.statusCode != 200) { if (response.statusCode != 200) {
final errorBody = await response.stream.bytesToString(); final errorBody = await response.stream.bytesToString();
@ -60,8 +77,12 @@ class SyncApiRepository implements ISyncApiRepository {
} }
await for (final chunk in response.stream.transform(utf8.decoder)) { await for (final chunk in response.stream.transform(utf8.decoder)) {
if (shouldAbort) {
break;
}
previousChunk += chunk; previousChunk += chunk;
final parts = previousChunk.split('\n'); final parts = previousChunk.toString().split('\n');
previousChunk = parts.removeLast(); previousChunk = parts.removeLast();
lines.addAll(parts); lines.addAll(parts);
@ -69,28 +90,28 @@ class SyncApiRepository implements ISyncApiRepository {
continue; continue;
} }
yield await compute(_parseSyncResponse, lines); await onData(_parseLines(lines), abort);
lines.clear(); lines.clear();
} }
} finally {
if (lines.isNotEmpty) { if (lines.isNotEmpty && !shouldAbort) {
yield await compute(_parseSyncResponse, lines); await onData(_parseLines(lines), abort);
} }
} catch (error, stack) {
_logger.severe("error processing stream", error, stack);
return Future.error(error, stack);
} finally {
client.close(); client.close();
} }
} stopwatch.stop();
_logger
.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
} }
const _kResponseMap = <SyncEntityType, Function(dynamic)>{ List<SyncEvent> _parseLines(List<String> lines) {
SyncEntityType.userV1: SyncUserV1.fromJson,
SyncEntityType.userDeleteV1: SyncUserDeleteV1.fromJson,
};
// Need to be outside of the class to be able to use compute
List<SyncEvent> _parseSyncResponse(List<String> lines) {
final List<SyncEvent> data = []; final List<SyncEvent> data = [];
for (var line in lines) { for (final line in lines) {
try { try {
final jsonData = jsonDecode(line); final jsonData = jsonDecode(line);
final type = SyncEntityType.fromJson(jsonData['type'])!; final type = SyncEntityType.fromJson(jsonData['type'])!;
@ -98,15 +119,30 @@ List<SyncEvent> _parseSyncResponse(List<String> lines) {
final ack = jsonData['ack']; final ack = jsonData['ack'];
final converter = _kResponseMap[type]; final converter = _kResponseMap[type];
if (converter == null) { if (converter == null) {
debugPrint("[_parseSyncReponse] Unknown type $type"); _logger.warning("[_parseSyncResponse] Unknown type $type");
continue; continue;
} }
data.add(SyncEvent(data: converter(dataJson), ack: ack)); data.add(SyncEvent(type: type, data: converter(dataJson), ack: ack));
} catch (error, stack) { } catch (error, stack) {
debugPrint("[_parseSyncReponse] Error parsing json $error $stack"); _logger.severe("[_parseSyncResponse] Error parsing json", error, stack);
} }
} }
return data; return data;
} }
}
// ignore: avoid-dynamic
const _kResponseMap = <SyncEntityType, Function(dynamic)>{
SyncEntityType.userV1: SyncUserV1.fromJson,
SyncEntityType.userDeleteV1: SyncUserDeleteV1.fromJson,
SyncEntityType.partnerV1: SyncPartnerV1.fromJson,
SyncEntityType.partnerDeleteV1: SyncPartnerDeleteV1.fromJson,
SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson,
};

View File

@ -0,0 +1,134 @@
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class DriftSyncStreamRepository extends DriftDatabaseRepository
implements ISyncStreamRepository {
final Logger _logger = Logger('DriftSyncStreamRepository');
final Drift _db;
DriftSyncStreamRepository(super.db) : _db = db;
@override
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final user in data) {
batch.delete(
_db.userEntity,
UserEntityCompanion(id: Value(user.userId.toUuidByte())),
);
}
});
} catch (error, stack) {
_logger.severe('Error while processing SyncUserDeleteV1', error, stack);
rethrow;
}
}
@override
Future<void> updateUsersV1(Iterable<SyncUserV1> data) async {
try {
await _db.batch((batch) {
for (final user in data) {
final companion = UserEntityCompanion(
name: Value(user.name),
email: Value(user.email),
);
batch.insert(
_db.userEntity,
companion.copyWith(id: Value(user.id.toUuidByte())),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error while processing SyncUserV1', error, stack);
rethrow;
}
}
@override
Future<void> deletePartnerV1(Iterable<SyncPartnerDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final partner in data) {
batch.delete(
_db.partnerEntity,
PartnerEntityCompanion(
sharedById: Value(partner.sharedById.toUuidByte()),
sharedWithId: Value(partner.sharedWithId.toUuidByte()),
),
);
}
});
} catch (e, s) {
_logger.severe('Error while processing SyncPartnerDeleteV1', e, s);
rethrow;
}
}
@override
Future<void> updatePartnerV1(Iterable<SyncPartnerV1> data) async {
try {
await _db.batch((batch) {
for (final partner in data) {
final companion =
PartnerEntityCompanion(inTimeline: Value(partner.inTimeline));
batch.insert(
_db.partnerEntity,
companion.copyWith(
sharedById: Value(partner.sharedById.toUuidByte()),
sharedWithId: Value(partner.sharedWithId.toUuidByte()),
),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (e, s) {
_logger.severe('Error while processing SyncPartnerV1', e, s);
rethrow;
}
}
// Assets
@override
Future<void> updateAssetsV1(Iterable<SyncAssetV1> data) async {
debugPrint("updateAssetsV1 - ${data.length}");
}
@override
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data) async {
debugPrint("deleteAssetsV1 - ${data.length}");
}
// Partner Assets
@override
Future<void> updatePartnerAssetsV1(Iterable<SyncAssetV1> data) async {
debugPrint("updatePartnerAssetsV1 - ${data.length}");
}
@override
Future<void> deletePartnerAssetsV1(Iterable<SyncAssetDeleteV1> data) async {
debugPrint("deletePartnerAssetsV1 - ${data.length}");
}
// EXIF
@override
Future<void> updateAssetsExifV1(Iterable<SyncAssetExifV1> data) async {
debugPrint("updateAssetsExifV1 - ${data.length}");
}
@override
Future<void> updatePartnerAssetsExifV1(Iterable<SyncAssetExifV1> data) async {
debugPrint("updatePartnerAssetsExifV1 - ${data.length}");
}
}

View File

@ -11,6 +11,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
@ -31,13 +32,15 @@ import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart'; import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:worker_manager/worker_manager.dart';
void main() async { void main() async {
ImmichWidgetsBinding(); ImmichWidgetsBinding();
final db = await Bootstrap.initIsar(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db); await Bootstrap.initDomain(db);
await initApp(); await initApp();
// Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true);
await migrateDatabaseIfNeeded(db); await migrateDatabaseIfNeeded(db);
HttpOverrides.global = HttpSSLCertOverride(); HttpOverrides.global = HttpSSLCertOverride();

View File

@ -63,9 +63,12 @@ class GalleryViewerPage extends HookConsumerWidget {
final loadAsset = renderList.loadAsset; final loadAsset = renderList.loadAsset;
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
// This key is to prevent the video player from being re-initialized during final videoPlayerKeys = useRef<Map<int, GlobalKey>>({});
// hero animation or device rotation.
final videoPlayerKey = useMemoized(() => GlobalKey()); GlobalKey getVideoPlayerKey(int id) {
videoPlayerKeys.value.putIfAbsent(id, () => GlobalKey());
return videoPlayerKeys.value[id]!;
}
Future<void> precacheNextImage(int index) async { Future<void> precacheNextImage(int index) async {
if (!context.mounted) { if (!context.mounted) {
@ -243,7 +246,7 @@ class GalleryViewerPage extends HookConsumerWidget {
width: context.width, width: context.width,
height: context.height, height: context.height,
child: NativeVideoViewerPage( child: NativeVideoViewerPage(
key: videoPlayerKey, key: getVideoPlayerKey(asset.id),
asset: asset, asset: asset,
image: Image( image: Image(
key: ValueKey(asset), key: ValueKey(asset),

View File

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
@ -12,6 +13,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart'; import 'package:immich_mobile/widgets/common/user_avatar.dart';
@ -297,11 +299,12 @@ class LocalAlbumsCollectionCard extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( SizedBox(
height: size, height: size,
width: size, width: size,
child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(30),
@ -325,6 +328,7 @@ class LocalAlbumsCollectionCard extends HookConsumerWidget {
}).toList(), }).toList(),
), ),
), ),
),
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
@ -353,28 +357,49 @@ class PlacesCollectionCard extends StatelessWidget {
final widthFactor = isTablet ? 0.25 : 0.5; final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0; final size = context.width * widthFactor - 20.0;
return FutureBuilder<(Position?, LocationPermission?)>(
future: MapUtils.checkPermAndGetLocation(
context: context,
silent: true,
),
builder: (context, snapshot) {
var position = snapshot.data?.$1;
return GestureDetector( return GestureDetector(
onTap: () => context.pushRoute(const PlacesCollectionRoute()), onTap: () => context.pushRoute(
PlacesCollectionRoute(
currentLocation: position != null
? LatLng(position.latitude, position.longitude)
: null,
),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( SizedBox(
height: size, height: size,
width: size, width: size,
child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius:
color: context.colorScheme.secondaryContainer.withAlpha(100), const BorderRadius.all(Radius.circular(20)),
color: context.colorScheme.secondaryContainer
.withAlpha(100),
), ),
child: IgnorePointer( child: IgnorePointer(
child: MapThumbnail( child: snapshot.connectionState ==
ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: MapThumbnail(
zoom: 8, zoom: 8,
centre: const LatLng( centre: LatLng(
21.44950, position?.latitude ?? 21.44950,
-157.91959, position?.longitude ?? -157.91959,
), ),
showAttribution: false, showAttribution: false,
themeMode: themeMode: context.isDarkTheme
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, ? ThemeMode.dark
: ThemeMode.light,
),
), ),
), ),
), ),
@ -393,6 +418,8 @@ class PlacesCollectionCard extends StatelessWidget {
); );
}, },
); );
},
);
} }
} }

View File

@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@RoutePage() @RoutePage()
@ -42,47 +42,12 @@ class PeopleCollectionPage extends HookConsumerWidget {
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: search.value == null, automaticallyImplyLeading: search.value == null,
title: search.value != null title: search.value != null
? TextField( ? SearchField(
focusNode: formFocus, focusNode: formFocus,
onTapOutside: (_) => formFocus.unfocus(), onTapOutside: (_) => formFocus.unfocus(),
onChanged: (value) => search.value = value, onChanged: (value) => search.value = value,
decoration: InputDecoration(
contentPadding: const EdgeInsets.only(left: 24),
filled: true, filled: true,
fillColor: context.primaryColor.withValues(alpha: 0.1),
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.themeData.colorScheme.onSurfaceSecondary,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.surfaceContainerHighest,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.surfaceContainerHighest,
),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.surfaceContainerHighest,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.primary.withAlpha(150),
),
),
prefixIcon: Icon(
Icons.search_rounded,
color: context.colorScheme.primary,
),
hintText: 'filter_people'.tr(), hintText: 'filter_people'.tr(),
),
autofocus: true, autofocus: true,
) )
: Text('people'.tr()), : Text('people'.tr()),

View File

@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
@ -12,32 +13,57 @@ import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage() @RoutePage()
class PlacesCollectionPage extends HookConsumerWidget { class PlacesCollectionPage extends HookConsumerWidget {
const PlacesCollectionPage({super.key}); const PlacesCollectionPage({super.key, this.currentLocation});
final LatLng? currentLocation;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(getAllPlacesProvider); final places = ref.watch(getAllPlacesProvider);
final formFocus = useFocusNode();
final ValueNotifier<String?> search = useState(null);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('places'.tr()), automaticallyImplyLeading: search.value == null,
title: search.value != null
? SearchField(
autofocus: true,
filled: true,
focusNode: formFocus,
onChanged: (value) => search.value = value,
onTapOutside: (_) => formFocus.unfocus(),
hintText: 'filter_places'.tr(),
)
: Text('places'.tr()),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
onPressed: () {
search.value = search.value == null ? '' : null;
},
),
],
), ),
body: ListView( body: ListView(
shrinkWrap: true, shrinkWrap: true,
children: [ children: [
if (search.value == null)
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: SizedBox( child: SizedBox(
height: 200, height: 200,
width: context.width, width: context.width,
child: MapThumbnail( child: MapThumbnail(
onTap: (_, __) => context.pushRoute(const MapRoute()), onTap: (_, __) => context
.pushRoute(MapRoute(initialLocation: currentLocation)),
zoom: 8, zoom: 8,
centre: const LatLng( centre: currentLocation ??
const LatLng(
21.44950, 21.44950,
-157.91959, -157.91959,
), ),
@ -49,6 +75,13 @@ class PlacesCollectionPage extends HookConsumerWidget {
), ),
places.when( places.when(
data: (places) { data: (places) {
if (search.value != null) {
places = places.where((place) {
return place.label
.toLowerCase()
.contains(search.value!.toLowerCase());
}).toList();
}
return ListView.builder( return ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),

View File

@ -34,7 +34,8 @@ import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage() @RoutePage()
class MapPage extends HookConsumerWidget { class MapPage extends HookConsumerWidget {
const MapPage({super.key}); const MapPage({super.key, this.initialLocation});
final LatLng? initialLocation;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -235,7 +236,8 @@ class MapPage extends HookConsumerWidget {
} }
void onZoomToLocation() async { void onZoomToLocation() async {
final (location, error) = await MapUtils.checkPermAndGetLocation(context); final (location, error) =
await MapUtils.checkPermAndGetLocation(context: context);
if (error != null) { if (error != null) {
if (error == LocationPermission.unableToDetermine && context.mounted) { if (error == LocationPermission.unableToDetermine && context.mounted) {
ImmichToast.show( ImmichToast.show(
@ -272,6 +274,7 @@ class MapPage extends HookConsumerWidget {
body: Stack( body: Stack(
children: [ children: [
_MapWithMarker( _MapWithMarker(
initialLocation: initialLocation,
style: style, style: style,
selectedMarker: selectedMarker, selectedMarker: selectedMarker,
onMapCreated: onMapCreated, onMapCreated: onMapCreated,
@ -303,6 +306,7 @@ class MapPage extends HookConsumerWidget {
body: Stack( body: Stack(
children: [ children: [
_MapWithMarker( _MapWithMarker(
initialLocation: initialLocation,
style: style, style: style,
selectedMarker: selectedMarker, selectedMarker: selectedMarker,
onMapCreated: onMapCreated, onMapCreated: onMapCreated,
@ -368,6 +372,7 @@ class _MapWithMarker extends StatelessWidget {
final OnStyleLoadedCallback onStyleLoaded; final OnStyleLoadedCallback onStyleLoaded;
final Function()? onMarkerTapped; final Function()? onMarkerTapped;
final ValueNotifier<_AssetMarkerMeta?> selectedMarker; final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
final LatLng? initialLocation;
const _MapWithMarker({ const _MapWithMarker({
required this.style, required this.style,
@ -377,6 +382,7 @@ class _MapWithMarker extends StatelessWidget {
required this.onStyleLoaded, required this.onStyleLoaded,
required this.selectedMarker, required this.selectedMarker,
this.onMarkerTapped, this.onMarkerTapped,
this.initialLocation,
}); });
@override @override
@ -389,8 +395,10 @@ class _MapWithMarker extends StatelessWidget {
children: [ children: [
style.widgetWhen( style.widgetWhen(
onData: (style) => MapLibreMap( onData: (style) => MapLibreMap(
initialCameraPosition: initialCameraPosition: CameraPosition(
const CameraPosition(target: LatLng(0, 0)), target: initialLocation ?? const LatLng(0, 0),
zoom: initialLocation != null ? 12 : 0,
),
styleString: style, styleString: style,
// This is needed to update the selectedMarker's position on map camera updates // This is needed to update the selectedMarker's position on map camera updates
// The changes are notified through the mapController ValueListener which is added in [onMapCreated] // The changes are notified through the mapController ValueListener which is added in [onMapCreated]

View File

@ -46,7 +46,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
Future<void> getCurrentLocation() async { Future<void> getCurrentLocation() async {
var (currentLocation, _) = var (currentLocation, _) =
await MapUtils.checkPermAndGetLocation(context); await MapUtils.checkPermAndGetLocation(context: context);
if (currentLocation == null) { if (currentLocation == null) {
return; return;

View File

@ -0,0 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
final manager = BackgroundSyncManager();
ref.onDispose(manager.cancel);
return manager;
});

View File

@ -106,12 +106,11 @@ class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is ImmichLocalImageProvider) { if (other is ImmichLocalImageProvider) {
return asset == other.asset; return asset.id == other.asset.id && asset.localId == other.asset.localId;
} }
return false; return false;
} }
@override @override
int get hashCode => asset.hashCode; int get hashCode => Object.hash(asset.id, asset.localId);
} }

View File

@ -82,11 +82,13 @@ class ImmichLocalThumbnailProvider
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! ImmichLocalThumbnailProvider) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return asset == other.asset; if (other is ImmichLocalThumbnailProvider) {
return asset.id == other.asset.id && asset.localId == other.asset.localId;
}
return false;
} }
@override @override
int get hashCode => asset.hashCode; int get hashCode => Object.hash(asset.id, asset.localId);
} }

View File

@ -0,0 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Provider holding a boolean function that returns true when cancellation is requested.
/// A computation running in the isolate uses the function to implement cooperative cancellation.
final cancellationProvider = Provider<bool Function()>(
// This will be overridden in the isolate's container.
// Throwing ensures it's not used without an override.
(ref) => throw UnimplementedError(
"cancellationProvider must be overridden in the isolate's ProviderContainer and not to be used in the root isolate",
),
name: 'cancellationProvider',
);

View File

@ -1,4 +1,7 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -6,3 +9,9 @@ part 'db.provider.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
Isar isar(Ref ref) => throw UnimplementedError('isar'); Isar isar(Ref ref) => throw UnimplementedError('isar');
final driftProvider = Provider<Drift>((ref) {
final drift = Drift();
ref.onDispose(() => unawaited(drift.close()));
return drift;
});

View File

@ -1,24 +1,23 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final syncStreamServiceProvider = Provider( final syncStreamServiceProvider = Provider(
(ref) { (ref) => SyncStreamService(
final instance = SyncStreamService( syncApiRepository: ref.watch(syncApiRepositoryProvider),
ref.watch(syncApiRepositoryProvider), syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
); cancelChecker: ref.watch(cancellationProvider),
),
ref.onDispose(() => unawaited(instance.dispose()));
return instance;
},
); );
final syncApiRepositoryProvider = Provider( final syncApiRepositoryProvider = Provider(
(ref) => SyncApiRepository( (ref) => SyncApiRepository(ref.watch(apiServiceProvider)),
ref.watch(apiServiceProvider), );
),
final syncStreamRepositoryProvider = Provider(
(ref) => DriftSyncStreamRepository(ref.watch(driftProvider)),
); );

View File

@ -1024,10 +1024,17 @@ class MapLocationPickerRouteArgs {
/// generated route for /// generated route for
/// [MapPage] /// [MapPage]
class MapRoute extends PageRouteInfo<void> { class MapRoute extends PageRouteInfo<MapRouteArgs> {
const MapRoute({List<PageRouteInfo>? children}) MapRoute({
: super( Key? key,
LatLng? initialLocation,
List<PageRouteInfo>? children,
}) : super(
MapRoute.name, MapRoute.name,
args: MapRouteArgs(
key: key,
initialLocation: initialLocation,
),
initialChildren: children, initialChildren: children,
); );
@ -1036,11 +1043,32 @@ class MapRoute extends PageRouteInfo<void> {
static PageInfo page = PageInfo( static PageInfo page = PageInfo(
name, name,
builder: (data) { builder: (data) {
return const MapPage(); final args =
data.argsAs<MapRouteArgs>(orElse: () => const MapRouteArgs());
return MapPage(
key: args.key,
initialLocation: args.initialLocation,
);
}, },
); );
} }
class MapRouteArgs {
const MapRouteArgs({
this.key,
this.initialLocation,
});
final Key? key;
final LatLng? initialLocation;
@override
String toString() {
return 'MapRouteArgs{key: $key, initialLocation: $initialLocation}';
}
}
/// generated route for /// generated route for
/// [MemoryPage] /// [MemoryPage]
class MemoryRoute extends PageRouteInfo<MemoryRouteArgs> { class MemoryRoute extends PageRouteInfo<MemoryRouteArgs> {
@ -1333,10 +1361,17 @@ class PhotosRoute extends PageRouteInfo<void> {
/// generated route for /// generated route for
/// [PlacesCollectionPage] /// [PlacesCollectionPage]
class PlacesCollectionRoute extends PageRouteInfo<void> { class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
const PlacesCollectionRoute({List<PageRouteInfo>? children}) PlacesCollectionRoute({
: super( Key? key,
LatLng? currentLocation,
List<PageRouteInfo>? children,
}) : super(
PlacesCollectionRoute.name, PlacesCollectionRoute.name,
args: PlacesCollectionRouteArgs(
key: key,
currentLocation: currentLocation,
),
initialChildren: children, initialChildren: children,
); );
@ -1345,11 +1380,32 @@ class PlacesCollectionRoute extends PageRouteInfo<void> {
static PageInfo page = PageInfo( static PageInfo page = PageInfo(
name, name,
builder: (data) { builder: (data) {
return const PlacesCollectionPage(); final args = data.argsAs<PlacesCollectionRouteArgs>(
orElse: () => const PlacesCollectionRouteArgs());
return PlacesCollectionPage(
key: args.key,
currentLocation: args.currentLocation,
);
}, },
); );
} }
class PlacesCollectionRouteArgs {
const PlacesCollectionRouteArgs({
this.key,
this.currentLocation,
});
final Key? key;
final LatLng? currentLocation;
@override
String toString() {
return 'PlacesCollectionRouteArgs{key: $key, currentLocation: $currentLocation}';
}
}
/// generated route for /// generated route for
/// [RecentlyAddedPage] /// [RecentlyAddedPage]
class RecentlyAddedRoute extends PageRouteInfo<void> { class RecentlyAddedRoute extends PageRouteInfo<void> {

View File

@ -3,12 +3,14 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart';
import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@ -22,6 +24,7 @@ final authServiceProvider = Provider(
ref.watch(authRepositoryProvider), ref.watch(authRepositoryProvider),
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(networkServiceProvider), ref.watch(networkServiceProvider),
ref.watch(backgroundSyncProvider),
), ),
); );
@ -30,6 +33,7 @@ class AuthService {
final IAuthRepository _authRepository; final IAuthRepository _authRepository;
final ApiService _apiService; final ApiService _apiService;
final NetworkService _networkService; final NetworkService _networkService;
final BackgroundSyncManager _backgroundSyncManager;
final _log = Logger("AuthService"); final _log = Logger("AuthService");
@ -38,6 +42,7 @@ class AuthService {
this._authRepository, this._authRepository,
this._apiService, this._apiService,
this._networkService, this._networkService,
this._backgroundSyncManager,
); );
/// Validates the provided server URL by resolving and setting the endpoint. /// Validates the provided server URL by resolving and setting the endpoint.
@ -115,8 +120,10 @@ class AuthService {
/// - Asset ETag /// - Asset ETag
/// ///
/// All deletions are executed in parallel using [Future.wait]. /// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() { Future<void> clearLocalData() async {
return Future.wait([ // Cancel any ongoing background sync operations before clearing data
await _backgroundSyncManager.cancel();
await Future.wait([
_authRepository.clearLocalData(), _authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),

View File

@ -351,7 +351,6 @@ class BackupService {
); );
baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.headers["Transfer-Encoding"] = "chunked";
baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] = baseRequest.fields['fileCreatedAt'] =

View File

@ -48,11 +48,15 @@ abstract final class Bootstrap {
); );
} }
static Future<void> initDomain(Isar db) async { static Future<void> initDomain(
Isar db, {
bool shouldBufferLogs = true,
}) async {
await StoreService.init(storeRepository: IsarStoreRepository(db)); await StoreService.init(storeRepository: IsarStoreRepository(db));
await LogService.init( await LogService.init(
logRepository: IsarLogRepository(db), logRepository: IsarLogRepository(db),
storeRepository: IsarStoreRepository(db), storeRepository: IsarStoreRepository(db),
shouldBuffer: shouldBufferLogs,
); );
} }
} }

View File

@ -0,0 +1,69 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
class InvalidIsolateUsageException implements Exception {
const InvalidIsolateUsageException();
@override
String toString() =>
"IsolateHelper should only be used from the root isolate";
}
// !! Should be used only from the root isolate
Cancelable<T?> runInIsolateGentle<T>({
required Future<T> Function(ProviderContainer ref) computation,
String? debugLabel,
}) {
final token = RootIsolateToken.instance;
if (token == null) {
throw const InvalidIsolateUsageException();
}
return workerManager.executeGentle((cancelledChecker) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
DartPluginRegistrant.ensureInitialized();
final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db, shouldBufferLogs: false);
final ref = ProviderContainer(
overrides: [
// TODO: Remove once isar is removed
dbProvider.overrideWithValue(db),
isarProvider.overrideWithValue(db),
cancellationProvider.overrideWithValue(cancelledChecker),
],
);
Logger log = Logger("IsolateLogger");
try {
return await computation(ref);
} on CanceledError {
log.warning(
"Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}",
);
} catch (error, stack) {
log.severe(
"Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}",
error,
stack,
);
} finally {
// Wait for the logs to flush
await Future.delayed(const Duration(seconds: 2));
// Always close the new db connection on Isolate end
ref.read(driftProvider).close();
ref.read(isarProvider).close();
}
return null;
});
}

View File

@ -64,12 +64,13 @@ class MapUtils {
'features': markers.map(_addFeature).toList(), 'features': markers.map(_addFeature).toList(),
}; };
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation( static Future<(Position?, LocationPermission?)> checkPermAndGetLocation({
BuildContext context, required BuildContext context,
) async { bool silent = false,
}) async {
try { try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled && !silent) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => _LocationServiceDisabledDialog(), builder: (context) => _LocationServiceDisabledDialog(),
@ -80,7 +81,7 @@ class MapUtils {
LocationPermission permission = await Geolocator.checkPermission(); LocationPermission permission = await Geolocator.checkPermission();
bool shouldRequestPermission = false; bool shouldRequestPermission = false;
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied && !silent) {
shouldRequestPermission = await showDialog( shouldRequestPermission = await showDialog(
context: context, context: context,
builder: (context) => _LocationPermissionDisabledDialog(), builder: (context) => _LocationPermissionDisabledDialog(),
@ -94,15 +95,19 @@ class MapUtils {
permission == LocationPermission.deniedForever) { permission == LocationPermission.deniedForever) {
// Open app settings only if you did not request for permission before // Open app settings only if you did not request for permission before
if (permission == LocationPermission.deniedForever && if (permission == LocationPermission.deniedForever &&
!shouldRequestPermission) { !shouldRequestPermission &&
!silent) {
await Geolocator.openAppSettings(); await Geolocator.openAppSettings();
} }
return (null, LocationPermission.deniedForever); return (null, LocationPermission.deniedForever);
} }
Position currentUserLocation = await Geolocator.getCurrentPosition( Position currentUserLocation = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium, locationSettings: const LocationSettings(
timeLimit: const Duration(seconds: 5), accuracy: LocationAccuracy.high,
distanceFilter: 0,
timeLimit: Duration(seconds: 5),
),
); );
return (currentUserLocation, null); return (currentUserLocation, null);
} catch (error, stack) { } catch (error, stack) {

View File

@ -1,11 +1,13 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/providers/background_sync.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';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -178,6 +180,11 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: action, child: action,
), ),
), ),
if (kDebugMode)
IconButton(
onPressed: () => ref.read(backgroundSyncProvider).sync(),
icon: const Icon(Icons.sync),
),
if (showUploadButton) if (showUploadButton)
Padding( Padding(
padding: const EdgeInsets.only(right: 20), padding: const EdgeInsets.only(right: 20),

View File

@ -12,14 +12,11 @@ class ImmichLogo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Hero( return Image(
tag: heroTag,
child: Image(
image: const AssetImage('assets/immich-logo.png'), image: const AssetImage('assets/immich-logo.png'),
width: size, width: size,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
isAntiAlias: true, isAntiAlias: true,
),
); );
} }
} }

View File

@ -46,12 +46,39 @@ class MapAssetGrid extends HookConsumerWidget {
final gridScrollThrottler = final gridScrollThrottler =
useThrottler(interval: const Duration(milliseconds: 300)); useThrottler(interval: const Duration(milliseconds: 300));
// Add a cache for assets we've already loaded
final assetCache = useRef<Map<String, Asset>>({});
void handleMapEvents(MapEvent event) async { void handleMapEvents(MapEvent event) async {
if (event is MapAssetsInBoundsUpdated) { if (event is MapAssetsInBoundsUpdated) {
assetsInBounds.value = await ref final assetIds = event.assetRemoteIds;
.read(dbProvider) final missingIds = <String>[];
.assets final currentAssets = <Asset>[];
.getAllByRemoteId(event.assetRemoteIds);
for (final id in assetIds) {
final asset = assetCache.value[id];
if (asset != null) {
currentAssets.add(asset);
} else {
missingIds.add(id);
}
}
// Only fetch missing assets
if (missingIds.isNotEmpty) {
final newAssets =
await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
// Add new assets to cache and current list
for (final asset in newAssets) {
if (asset.remoteId != null) {
assetCache.value[asset.remoteId!] = asset;
currentAssets.add(asset);
}
}
}
assetsInBounds.value = currentAssets;
return; return;
} }
} }
@ -124,7 +151,7 @@ class MapAssetGrid extends HookConsumerWidget {
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: FractionallySizedBox( child: FractionallySizedBox(
// Place it just below the drag handle // Place it just below the drag handle
heightFactor: 0.80, heightFactor: 0.87,
child: assetsInBounds.value.isNotEmpty child: assetsInBounds.value.isNotEmpty
? ref ? ref
.watch(assetsTimelineProvider(assetsInBounds.value)) .watch(assetsTimelineProvider(assetsInBounds.value))
@ -251,8 +278,18 @@ class _MapSheetDragRegion extends StatelessWidget {
const SizedBox(height: 15), const SizedBox(height: 15),
const CustomDraggingHandle(), const CustomDraggingHandle(),
const SizedBox(height: 15), const SizedBox(height: 15),
Text(assetsInBoundsText, style: context.textTheme.bodyLarge), Center(
const Divider(height: 35), child: Text(
assetsInBoundsText,
style: TextStyle(
fontSize: 20,
color: context.textTheme.displayLarge?.color
?.withValues(alpha: 0.75),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 8),
], ],
), ),
ValueListenableBuilder( ValueListenableBuilder(
@ -260,14 +297,14 @@ class _MapSheetDragRegion extends StatelessWidget {
builder: (_, value, __) => Visibility( builder: (_, value, __) => Visibility(
visible: value != null, visible: value != null,
child: Positioned( child: Positioned(
right: 15, right: 18,
top: 15, top: 24,
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
Icons.map_outlined, Icons.map_outlined,
color: context.textTheme.displayLarge?.color, color: context.textTheme.displayLarge?.color,
), ),
iconSize: 20, iconSize: 24,
tooltip: 'Zoom to bounds', tooltip: 'Zoom to bounds',
onPressed: () => onZoomToAsset?.call(value!), onPressed: () => onZoomToAsset?.call(value!),
), ),

View File

@ -20,7 +20,7 @@ class SearchMapThumbnail extends StatelessWidget {
return ThumbnailWithInfoContainer( return ThumbnailWithInfoContainer(
label: 'search_page_your_map'.tr(), label: 'search_page_your_map'.tr(),
onTap: () { onTap: () {
context.pushRoute(const MapRoute()); context.pushRoute(MapRoute());
}, },
child: IgnorePointer( child: IgnorePointer(
child: MapThumbnail( child: MapThumbnail(

View File

@ -145,8 +145,8 @@ Class | Method | HTTP request | Description
*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |
*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |
*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} |
*NotificationsApi* | [**getNotificationTemplate**](doc//NotificationsApi.md#getnotificationtemplate) | **POST** /notifications/templates/{name} | *NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} |
*NotificationsApi* | [**sendTestEmail**](doc//NotificationsApi.md#sendtestemail) | **POST** /notifications/test-email | *NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email |
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |

View File

@ -44,7 +44,7 @@ part 'api/jobs_api.dart';
part 'api/libraries_api.dart'; part 'api/libraries_api.dart';
part 'api/map_api.dart'; part 'api/map_api.dart';
part 'api/memories_api.dart'; part 'api/memories_api.dart';
part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart';
part 'api/o_auth_api.dart'; part 'api/o_auth_api.dart';
part 'api/partners_api.dart'; part 'api/partners_api.dart';
part 'api/people_api.dart'; part 'api/people_api.dart';

View File

@ -11,20 +11,20 @@
part of openapi.api; part of openapi.api;
class NotificationsApi { class NotificationsAdminApi {
NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; NotificationsAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient; final ApiClient apiClient;
/// Performs an HTTP 'POST /notifications/templates/{name}' operation and returns the [Response]. /// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [String] name (required): /// * [String] name (required):
/// ///
/// * [TemplateDto] templateDto (required): /// * [TemplateDto] templateDto (required):
Future<Response> getNotificationTemplateWithHttpInfo(String name, TemplateDto templateDto,) async { Future<Response> getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/notifications/templates/{name}' final apiPath = r'/notifications/admin/templates/{name}'
.replaceAll('{name}', name); .replaceAll('{name}', name);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@ -53,8 +53,8 @@ class NotificationsApi {
/// * [String] name (required): /// * [String] name (required):
/// ///
/// * [TemplateDto] templateDto (required): /// * [TemplateDto] templateDto (required):
Future<TemplateResponseDto?> getNotificationTemplate(String name, TemplateDto templateDto,) async { Future<TemplateResponseDto?> getNotificationTemplateAdmin(String name, TemplateDto templateDto,) async {
final response = await getNotificationTemplateWithHttpInfo(name, templateDto,); final response = await getNotificationTemplateAdminWithHttpInfo(name, templateDto,);
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@ -68,13 +68,13 @@ class NotificationsApi {
return null; return null;
} }
/// Performs an HTTP 'POST /notifications/test-email' operation and returns the [Response]. /// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
Future<Response> sendTestEmailWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async { Future<Response> sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/notifications/test-email'; final apiPath = r'/notifications/admin/test-email';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = systemConfigSmtpDto; Object? postBody = systemConfigSmtpDto;
@ -100,8 +100,8 @@ class NotificationsApi {
/// Parameters: /// Parameters:
/// ///
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
Future<TestEmailResponseDto?> sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { Future<TestEmailResponseDto?> sendTestEmailAdmin(SystemConfigSmtpDto systemConfigSmtpDto,) async {
final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,); final response = await sendTestEmailAdminWithHttpInfo(systemConfigSmtpDto,);
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View File

@ -696,18 +696,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: geolocator name: geolocator
sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27" sha256: e7ebfa04ce451daf39b5499108c973189a71a919aa53c1204effda1c5b93b822
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.1.0" version: "14.0.0"
geolocator_android: geolocator_android:
dependency: transitive dependency: transitive
description: description:
name: geolocator_android name: geolocator_android
sha256: "7aefc530db47d90d0580b552df3242440a10fe60814496a979aa67aa98b1fd47" sha256: "114072db5d1dce0ec0b36af2697f55c133bc89a2c8dd513e137c0afe59696ed4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.1" version: "5.0.1+1"
geolocator_apple: geolocator_apple:
dependency: transitive dependency: transitive
description: description:
@ -728,10 +728,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_web name: geolocator_web
sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed" sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "4.1.3"
geolocator_windows: geolocator_windows:
dependency: transitive dependency: transitive
description: description:
@ -1806,7 +1806,7 @@ packages:
source: hosted source: hosted
version: "3.1.4" version: "3.1.4"
uuid: uuid:
dependency: transitive dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
@ -1933,6 +1933,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.3" version: "0.0.3"
worker_manager:
dependency: "direct main"
description:
name: worker_manager
sha256: "086ed63e9b36266e851404ca90fd44e37c0f4c9bbf819e5f8d7c87f9741c0591"
url: "https://pub.dev"
source: hosted
version: "7.2.3"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -35,7 +35,7 @@ dependencies:
flutter_udid: ^3.0.0 flutter_udid: ^3.0.0
flutter_web_auth_2: ^5.0.0-alpha.0 flutter_web_auth_2: ^5.0.0-alpha.0
fluttertoast: ^8.2.12 fluttertoast: ^8.2.12
geolocator: ^11.0.0 geolocator: ^14.0.0
hooks_riverpod: ^2.6.1 hooks_riverpod: ^2.6.1
http: ^1.3.0 http: ^1.3.0
image_picker: ^1.1.2 image_picker: ^1.1.2
@ -60,7 +60,9 @@ dependencies:
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1
timezone: ^0.9.4 timezone: ^0.9.4
url_launcher: ^6.3.1 url_launcher: ^6.3.1
uuid: ^4.5.1
wakelock_plus: ^1.2.10 wakelock_plus: ^1.2.10
worker_manager: ^7.2.3
native_video_player: native_video_player:
git: git:

View File

@ -2,3 +2,5 @@ import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class MockAssetsApi extends Mock implements AssetsApi {} class MockAssetsApi extends Mock implements AssetsApi {}
class MockSyncApi extends Mock implements SyncApi {}

View File

@ -1,7 +1,10 @@
import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
class MockStoreService extends Mock implements StoreService {} class MockStoreService extends Mock implements StoreService {}
class MockUserService extends Mock implements UserService {} class MockUserService extends Mock implements UserService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}

View File

@ -0,0 +1,222 @@
// ignore_for_file: avoid-declaring-call-method, avoid-unnecessary-futures
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
class _AbortCallbackWrapper {
const _AbortCallbackWrapper();
bool call() => false;
}
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
class _CancellationWrapper {
const _CancellationWrapper();
bool call() => false;
}
class _MockCancellationWrapper extends Mock implements _CancellationWrapper {}
void main() {
late SyncStreamService sut;
late ISyncStreamRepository mockSyncStreamRepo;
late ISyncApiRepository mockSyncApiRepo;
late Function(List<SyncEvent>, Function()) handleEventsCallback;
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
successHandler(Invocation _) async => true;
setUp(() {
mockSyncStreamRepo = MockSyncStreamRepository();
mockSyncApiRepo = MockSyncApiRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
when(() => mockAbortCallbackWrapper()).thenReturn(false);
when(() => mockSyncApiRepo.streamChanges(any()))
.thenAnswer((invocation) async {
// ignore: avoid-unsafe-collection-methods
handleEventsCallback = invocation.positionalArguments.first;
});
when(() => mockSyncApiRepo.ack(any())).thenAnswer((_) async => {});
when(() => mockSyncStreamRepo.updateUsersV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updatePartnerV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deletePartnerV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updateAssetsV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteAssetsV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updateAssetsExifV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updatePartnerAssetsV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deletePartnerAssetsV1(any()))
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updatePartnerAssetsExifV1(any()))
.thenAnswer(successHandler);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
);
});
Future<void> simulateEvents(List<SyncEvent> events) async {
await sut.sync();
await handleEventsCallback(events, mockAbortCallbackWrapper.call);
}
group("SyncStreamService - _handleEvents", () {
test(
"processes events and acks successfully when handlers succeed",
() async {
final events = [
SyncStreamStub.userDeleteV1,
SyncStreamStub.userV1Admin,
SyncStreamStub.userV1User,
SyncStreamStub.partnerDeleteV1,
SyncStreamStub.partnerV1,
];
await simulateEvents(events);
verifyInOrder([
() => mockSyncStreamRepo.deleteUsersV1(any()),
() => mockSyncApiRepo.ack(["2"]),
() => mockSyncStreamRepo.updateUsersV1(any()),
() => mockSyncApiRepo.ack(["5"]),
() => mockSyncStreamRepo.deletePartnerV1(any()),
() => mockSyncApiRepo.ack(["4"]),
() => mockSyncStreamRepo.updatePartnerV1(any()),
() => mockSyncApiRepo.ack(["3"]),
]);
verifyNever(() => mockAbortCallbackWrapper());
},
);
test("processes final batch correctly", () async {
final events = [
SyncStreamStub.userDeleteV1,
SyncStreamStub.userV1Admin,
];
await simulateEvents(events);
verifyInOrder([
() => mockSyncStreamRepo.deleteUsersV1(any()),
() => mockSyncApiRepo.ack(["2"]),
() => mockSyncStreamRepo.updateUsersV1(any()),
() => mockSyncApiRepo.ack(["1"]),
]);
verifyNever(() => mockAbortCallbackWrapper());
});
test("does not process or ack when event list is empty", () async {
await simulateEvents([]);
verifyNever(() => mockSyncStreamRepo.updateUsersV1(any()));
verifyNever(() => mockSyncStreamRepo.deleteUsersV1(any()));
verifyNever(() => mockSyncStreamRepo.updatePartnerV1(any()));
verifyNever(() => mockSyncStreamRepo.deletePartnerV1(any()));
verifyNever(() => mockAbortCallbackWrapper());
verifyNever(() => mockSyncApiRepo.ack(any()));
});
test("aborts and stops processing if cancelled during iteration", () async {
final cancellationChecker = _MockCancellationWrapper();
when(() => cancellationChecker()).thenReturn(false);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
cancelChecker: cancellationChecker.call,
);
await sut.sync();
final events = [
SyncStreamStub.userDeleteV1,
SyncStreamStub.userV1Admin,
SyncStreamStub.partnerDeleteV1,
];
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer((_) async {
when(() => cancellationChecker()).thenReturn(true);
});
await handleEventsCallback(events, mockAbortCallbackWrapper.call);
verify(() => mockSyncStreamRepo.deleteUsersV1(any())).called(1);
verifyNever(() => mockSyncStreamRepo.updateUsersV1(any()));
verifyNever(() => mockSyncStreamRepo.deletePartnerV1(any()));
verify(() => mockAbortCallbackWrapper()).called(1);
verify(() => mockSyncApiRepo.ack(["2"])).called(1);
});
test(
"aborts and stops processing if cancelled before processing batch",
() async {
final cancellationChecker = _MockCancellationWrapper();
when(() => cancellationChecker()).thenReturn(false);
final processingCompleter = Completer<void>();
bool handler1Started = false;
when(() => mockSyncStreamRepo.deleteUsersV1(any()))
.thenAnswer((_) async {
handler1Started = true;
return processingCompleter.future;
});
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
cancelChecker: cancellationChecker.call,
);
await sut.sync();
final events = [
SyncStreamStub.userDeleteV1,
SyncStreamStub.userV1Admin,
SyncStreamStub.partnerDeleteV1,
];
final processingFuture =
handleEventsCallback(events, mockAbortCallbackWrapper.call);
await pumpEventQueue();
expect(handler1Started, isTrue);
// Signal cancellation while handler 1 is waiting
when(() => cancellationChecker()).thenReturn(true);
await pumpEventQueue();
processingCompleter.complete();
await processingFuture;
verifyNever(() => mockSyncStreamRepo.updateUsersV1(any()));
verify(() => mockSyncApiRepo.ack(["2"])).called(1);
},
);
});
}

View File

@ -0,0 +1,45 @@
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:openapi/api.dart';
abstract final class SyncStreamStub {
static final userV1Admin = SyncEvent(
type: SyncEntityType.userV1,
data: SyncUserV1(
deletedAt: DateTime(2020),
email: "admin@admin",
id: "1",
name: "Admin",
),
ack: "1",
);
static final userV1User = SyncEvent(
type: SyncEntityType.userV1,
data: SyncUserV1(
deletedAt: DateTime(2021),
email: "user@user",
id: "5",
name: "User",
),
ack: "5",
);
static final userDeleteV1 = SyncEvent(
type: SyncEntityType.userDeleteV1,
data: SyncUserDeleteV1(userId: "2"),
ack: "2",
);
static final partnerV1 = SyncEvent(
type: SyncEntityType.partnerV1,
data: SyncPartnerV1(
inTimeline: true,
sharedById: "1",
sharedWithId: "2",
),
ack: "3",
);
static final partnerDeleteV1 = SyncEvent(
type: SyncEntityType.partnerDeleteV1,
data: SyncPartnerDeleteV1(sharedById: "3", sharedWithId: "4"),
ack: "4",
);
}

View File

@ -0,0 +1,299 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../../api.mocks.dart';
import '../../service.mocks.dart';
class MockHttpClient extends Mock implements http.Client {}
class MockApiClient extends Mock implements ApiClient {}
class MockStreamedResponse extends Mock implements http.StreamedResponse {}
class FakeBaseRequest extends Fake implements http.BaseRequest {}
String _createJsonLine(String type, Map<String, dynamic> data, String ack) {
return '${jsonEncode({'type': type, 'data': data, 'ack': ack})}\n';
}
void main() {
late SyncApiRepository sut;
late MockApiService mockApiService;
late MockApiClient mockApiClient;
late MockSyncApi mockSyncApi;
late MockHttpClient mockHttpClient;
late MockStreamedResponse mockStreamedResponse;
late StreamController<List<int>> responseStreamController;
late int testBatchSize = 3;
setUp(() {
mockApiService = MockApiService();
mockApiClient = MockApiClient();
mockSyncApi = MockSyncApi();
mockHttpClient = MockHttpClient();
mockStreamedResponse = MockStreamedResponse();
responseStreamController =
StreamController<List<int>>.broadcast(sync: true);
registerFallbackValue(FakeBaseRequest());
when(() => mockApiService.apiClient).thenReturn(mockApiClient);
when(() => mockApiService.syncApi).thenReturn(mockSyncApi);
when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api');
when(() => mockApiService.applyToParams(any(), any()))
.thenAnswer((_) async => {});
// Mock HTTP client behavior
when(() => mockHttpClient.send(any()))
.thenAnswer((_) async => mockStreamedResponse);
when(() => mockStreamedResponse.statusCode).thenReturn(200);
when(() => mockStreamedResponse.stream)
.thenAnswer((_) => http.ByteStream(responseStreamController.stream));
when(() => mockHttpClient.close()).thenAnswer((_) => {});
sut = SyncApiRepository(mockApiService);
});
tearDown(() async {
if (!responseStreamController.isClosed) {
await responseStreamController.close();
}
});
Future<void> streamChanges(
Function(List<SyncEvent>, Function() abort) onDataCallback,
) {
return sut.streamChanges(
onDataCallback,
batchSize: testBatchSize,
httpClient: mockHttpClient,
);
}
test('streamChanges stops processing stream when abort is called', () async {
int onDataCallCount = 0;
bool abortWasCalledInCallback = false;
List<SyncEvent> receivedEventsBatch1 = [];
onDataCallback(List<SyncEvent> events, Function() abort) {
onDataCallCount++;
if (onDataCallCount == 1) {
receivedEventsBatch1 = events;
abort();
abortWasCalledInCallback = true;
} else {
fail("onData called more than once after abort was invoked");
}
}
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
for (int i = 0; i < testBatchSize; i++) {
responseStreamController.add(
utf8.encode(
_createJsonLine(
SyncEntityType.userDeleteV1.toString(),
SyncUserDeleteV1(userId: "user$i").toJson(),
'ack$i',
),
),
);
}
for (int i = testBatchSize; i < testBatchSize * 2; i++) {
responseStreamController.add(
utf8.encode(
_createJsonLine(
SyncEntityType.userDeleteV1.toString(),
SyncUserDeleteV1(userId: "user$i").toJson(),
'ack$i',
),
),
);
}
await responseStreamController.close();
await expectLater(streamChangesFuture, completes);
expect(onDataCallCount, 1);
expect(abortWasCalledInCallback, isTrue);
expect(receivedEventsBatch1.length, testBatchSize);
verify(() => mockHttpClient.close()).called(1);
});
test(
'streamChanges does not process remaining lines in finally block if aborted',
() async {
int onDataCallCount = 0;
bool abortWasCalledInCallback = false;
onDataCallback(List<SyncEvent> events, Function() abort) {
onDataCallCount++;
if (onDataCallCount == 1) {
abort();
abortWasCalledInCallback = true;
} else {
fail("onData called more than once after abort was invoked");
}
}
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
for (int i = 0; i < testBatchSize; i++) {
responseStreamController.add(
utf8.encode(
_createJsonLine(
SyncEntityType.userDeleteV1.toString(),
SyncUserDeleteV1(userId: "user$i").toJson(),
'ack$i',
),
),
);
}
// emit a single event to skip batching and trigger finally
responseStreamController.add(
utf8.encode(
_createJsonLine(
SyncEntityType.userDeleteV1.toString(),
SyncUserDeleteV1(userId: "user100").toJson(),
'ack100',
),
),
);
await responseStreamController.close();
await expectLater(streamChangesFuture, completes);
expect(onDataCallCount, 1);
expect(abortWasCalledInCallback, isTrue);
verify(() => mockHttpClient.close()).called(1);
},
);
test(
'streamChanges processes remaining lines in finally block if not aborted',
() async {
int onDataCallCount = 0;
List<SyncEvent> receivedEventsBatch1 = [];
List<SyncEvent> receivedEventsBatch2 = [];
onDataCallback(List<SyncEvent> events, Function() _) {
onDataCallCount++;
if (onDataCallCount == 1) {
receivedEventsBatch1 = events;
} else if (onDataCallCount == 2) {
receivedEventsBatch2 = events;
} else {
fail("onData called more than expected");
}
}
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
// Batch 1
for (int i = 0; i < testBatchSize; i++) {
responseStreamController.add(
utf8.encode(
_createJsonLine(
SyncEntityType.userDeleteV1.toString(),
SyncUserDeleteV1(userId: "user$i").toJson(),
'ack$i',
),
),
);
}
// Partial Batch 2
responseStreamController.add(
utf8.encode(
_createJsonLine(
SyncEntityType.userDeleteV1.toString(),
SyncUserDeleteV1(userId: "user100").toJson(),
'ack100',
),
),
);
await responseStreamController.close();
await expectLater(streamChangesFuture, completes);
expect(onDataCallCount, 2);
expect(receivedEventsBatch1.length, testBatchSize);
expect(receivedEventsBatch2.length, 1);
verify(() => mockHttpClient.close()).called(1);
},
);
test('streamChanges handles stream error gracefully', () async {
final streamError = Exception("Network Error");
int onDataCallCount = 0;
onDataCallback(List<SyncEvent> events, Function() _) {
onDataCallCount++;
}
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
responseStreamController.add(
utf8.encode(
_createJsonLine(
SyncEntityType.userDeleteV1.toString(),
SyncUserDeleteV1(userId: "user1").toJson(),
'ack1',
),
),
);
responseStreamController.addError(streamError);
await expectLater(streamChangesFuture, throwsA(streamError));
expect(onDataCallCount, 0);
verify(() => mockHttpClient.close()).called(1);
});
test('streamChanges throws ApiException on non-200 status code', () async {
when(() => mockStreamedResponse.statusCode).thenReturn(401);
final errorBodyController = StreamController<List<int>>(sync: true);
when(() => mockStreamedResponse.stream)
.thenAnswer((_) => http.ByteStream(errorBodyController.stream));
int onDataCallCount = 0;
onDataCallback(List<SyncEvent> events, Function() _) {
onDataCallCount++;
}
final future = streamChanges(onDataCallback);
errorBodyController.add(utf8.encode('{"error":"Unauthorized"}'));
await errorBodyController.close();
await expectLater(
future,
throwsA(
isA<ApiException>()
.having((e) => e.code, 'code', 401)
.having((e) => e.message, 'message', contains('Unauthorized')),
),
);
expect(onDataCallCount, 0);
verify(() => mockHttpClient.close()).called(1);
});
}

View File

@ -1,6 +1,8 @@
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart';
import 'package:immich_mobile/domain/interfaces/user_api.interface.dart'; import 'package:immich_mobile/domain/interfaces/user_api.interface.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@ -14,5 +16,9 @@ class MockUserRepository extends Mock implements IUserRepository {}
class MockDeviceAssetRepository extends Mock class MockDeviceAssetRepository extends Mock
implements IDeviceAssetRepository {} implements IDeviceAssetRepository {}
class MockSyncStreamRepository extends Mock implements ISyncStreamRepository {}
// API Repos // API Repos
class MockUserApiRepository extends Mock implements IUserApiRepository {} class MockUserApiRepository extends Mock implements IUserApiRepository {}
class MockSyncApiRepository extends Mock implements ISyncApiRepository {}

View File

@ -29,4 +29,3 @@ class MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {} class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {} class MockBackgroundService extends Mock implements BackgroundService {}

View File

@ -8,6 +8,7 @@ import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import '../domain/service.mock.dart';
import '../repository.mocks.dart'; import '../repository.mocks.dart';
import '../service.mocks.dart'; import '../service.mocks.dart';
import '../test_utils.dart'; import '../test_utils.dart';
@ -18,6 +19,7 @@ void main() {
late MockAuthRepository authRepository; late MockAuthRepository authRepository;
late MockApiService apiService; late MockApiService apiService;
late MockNetworkService networkService; late MockNetworkService networkService;
late MockBackgroundSyncManager backgroundSyncManager;
late Isar db; late Isar db;
setUp(() async { setUp(() async {
@ -25,12 +27,14 @@ void main() {
authRepository = MockAuthRepository(); authRepository = MockAuthRepository();
apiService = MockApiService(); apiService = MockApiService();
networkService = MockNetworkService(); networkService = MockNetworkService();
backgroundSyncManager = MockBackgroundSyncManager();
sut = AuthService( sut = AuthService(
authApiRepository, authApiRepository,
authRepository, authRepository,
apiService, apiService,
networkService, networkService,
backgroundSyncManager,
); );
registerFallbackValue(Uri()); registerFallbackValue(Uri());
@ -116,24 +120,28 @@ void main() {
group('logout', () { group('logout', () {
test('Should logout user', () async { test('Should logout user', () async {
when(() => authApiRepository.logout()).thenAnswer((_) async => {}); when(() => authApiRepository.logout()).thenAnswer((_) async => {});
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
when(() => authRepository.clearLocalData()) when(() => authRepository.clearLocalData())
.thenAnswer((_) => Future.value(null)); .thenAnswer((_) => Future.value(null));
await sut.logout(); await sut.logout();
verify(() => authApiRepository.logout()).called(1); verify(() => authApiRepository.logout()).called(1);
verify(() => backgroundSyncManager.cancel()).called(1);
verify(() => authRepository.clearLocalData()).called(1); verify(() => authRepository.clearLocalData()).called(1);
}); });
test('Should clear local data even on server error', () async { test('Should clear local data even on server error', () async {
when(() => authApiRepository.logout()) when(() => authApiRepository.logout())
.thenThrow(Exception('Server error')); .thenThrow(Exception('Server error'));
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
when(() => authRepository.clearLocalData()) when(() => authRepository.clearLocalData())
.thenAnswer((_) => Future.value(null)); .thenAnswer((_) => Future.value(null));
await sut.logout(); await sut.logout();
verify(() => authApiRepository.logout()).called(1); verify(() => authApiRepository.logout()).called(1);
verify(() => backgroundSyncManager.cancel()).called(1);
verify(() => authRepository.clearLocalData()).called(1); verify(() => authRepository.clearLocalData()).called(1);
}); });
}); });

View File

@ -3485,9 +3485,9 @@
] ]
} }
}, },
"/notifications/templates/{name}": { "/notifications/admin/templates/{name}": {
"post": { "post": {
"operationId": "getNotificationTemplate", "operationId": "getNotificationTemplateAdmin",
"parameters": [ "parameters": [
{ {
"name": "name", "name": "name",
@ -3532,13 +3532,13 @@
} }
], ],
"tags": [ "tags": [
"Notifications" "Notifications (Admin)"
] ]
} }
}, },
"/notifications/test-email": { "/notifications/admin/test-email": {
"post": { "post": {
"operationId": "sendTestEmail", "operationId": "sendTestEmailAdmin",
"parameters": [], "parameters": [],
"requestBody": { "requestBody": {
"content": { "content": {
@ -3574,7 +3574,7 @@
} }
], ],
"tags": [ "tags": [
"Notifications" "Notifications (Admin)"
] ]
} }
}, },

View File

@ -12,7 +12,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@ -23,9 +23,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.14.0", "version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"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.14.0", "@types/node": "^22.14.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {

View File

@ -2318,26 +2318,26 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
body: bulkIdsDto body: bulkIdsDto
}))); })));
} }
export function getNotificationTemplate({ name, templateDto }: { export function getNotificationTemplateAdmin({ name, templateDto }: {
name: string; name: string;
templateDto: TemplateDto; templateDto: TemplateDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: TemplateResponseDto; data: TemplateResponseDto;
}>(`/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({ }>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: templateDto body: templateDto
}))); })));
} }
export function sendTestEmail({ systemConfigSmtpDto }: { export function sendTestEmailAdmin({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto; systemConfigSmtpDto: SystemConfigSmtpDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: TestEmailResponseDto; data: TestEmailResponseDto;
}>("/notifications/test-email", oazapfts.json({ }>("/notifications/admin/test-email", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: systemConfigSmtpDto body: systemConfigSmtpDto

View File

@ -90,7 +90,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.14.0", "@types/node": "^22.14.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",

View File

@ -26,9 +26,8 @@
"migrations:generate": "node ./dist/bin/migrations.js generate", "migrations:generate": "node ./dist/bin/migrations.js generate",
"migrations:create": "node ./dist/bin/migrations.js create", "migrations:create": "node ./dist/bin/migrations.js create",
"migrations:run": "node ./dist/bin/migrations.js run", "migrations:run": "node ./dist/bin/migrations.js run",
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js", "schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
"typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'", "schema:reset": "npm run schema:drop && npm run migrations:run",
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run migrations:run",
"kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts", "kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts",
"sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js", "sync:sql": "node ./dist/bin/sync-sql.js",
@ -116,7 +115,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.14.0", "@types/node": "^22.14.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",

View File

@ -1,11 +0,0 @@
import { ConfigRepository } from 'src/repositories/config.repository';
import { DataSource } from 'typeorm';
const { database } = new ConfigRepository().getEnv();
/**
* @deprecated - DO NOT USE THIS
*
* this export is ONLY to be used for TypeORM commands in package.json#scripts
*/
export const dataSource = new DataSource({ ...database.config.typeorm, host: 'localhost' });

View File

@ -1,8 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
import { Kysely } from 'kysely'; import { Kysely, sql } from 'kysely';
import { writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path'; import { basename, dirname, extname, join } from 'node:path';
import postgres from 'postgres'; import postgres from 'postgres';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
@ -23,8 +23,13 @@ const main = async () => {
} }
case 'run': { case 'run': {
const only = process.argv[3] as 'kysely' | 'typeorm' | undefined; await runMigrations();
await run(only); return;
}
case 'query': {
const query = process.argv[3];
await runQuery(query);
return; return;
} }
@ -48,14 +53,25 @@ const main = async () => {
} }
}; };
const run = async (only?: 'kysely' | 'typeorm') => { const getDatabaseClient = () => {
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv(); const { database } = configRepository.getEnv();
const logger = new LoggingRepository(undefined, configRepository); return new Kysely<any>(getKyselyConfig(database.config.kysely));
const db = new Kysely<any>(getKyselyConfig(database.config.kysely)); };
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
await databaseRepository.runMigrations({ only }); const runQuery = async (query: string) => {
const db = getDatabaseClient();
await sql.raw(query).execute(db);
await db.destroy();
};
const runMigrations = async () => {
const configRepository = new ConfigRepository();
const logger = new LoggingRepository(undefined, configRepository);
const db = getDatabaseClient();
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
await databaseRepository.runMigrations();
await db.destroy();
}; };
const debug = async () => { const debug = async () => {
@ -81,7 +97,8 @@ const create = (path: string, up: string[], down: string[]) => {
const filename = `${timestamp}-${name}.ts`; const filename = `${timestamp}-${name}.ts`;
const folder = dirname(path); const folder = dirname(path);
const fullPath = join(folder, filename); const fullPath = join(folder, filename);
writeFileSync(fullPath, asMigration('typeorm', { name, timestamp, up, down })); mkdirSync(folder, { recursive: true });
writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down }));
console.log(`Wrote ${fullPath}`); console.log(`Wrote ${fullPath}`);
}; };

View File

@ -13,7 +13,7 @@ import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller'; import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller'; import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller'; import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationController } from 'src/controllers/notification.controller'; import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
import { OAuthController } from 'src/controllers/oauth.controller'; import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller'; import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller'; import { PersonController } from 'src/controllers/person.controller';
@ -47,7 +47,7 @@ export const controllers = [
LibraryController, LibraryController,
MapController, MapController,
MemoryController, MemoryController,
NotificationController, NotificationAdminController,
OAuthController, OAuthController,
PartnerController, PartnerController,
PersonController, PersonController,

View File

@ -4,25 +4,25 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/notification.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
@ApiTags('Notifications') @ApiTags('Notifications (Admin)')
@Controller('notifications') @Controller('notifications/admin')
export class NotificationController { export class NotificationAdminController {
constructor(private service: NotificationService) {} constructor(private service: NotificationService) {}
@Post('test-email') @Post('test-email')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Authenticated({ admin: true }) @Authenticated({ admin: true })
sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> { sendTestEmailAdmin(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto); return this.service.sendTestEmail(auth.user.id, dto);
} }
@Post('templates/:name') @Post('templates/:name')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Authenticated({ admin: true }) @Authenticated({ admin: true })
getNotificationTemplate( getNotificationTemplateAdmin(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param('name') name: EmailTemplate, @Param('name') name: EmailTemplate,
@Body() dto: TemplateDto, @Body() dto: TemplateDto,

View File

@ -1,13 +1,13 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { AssetJobStatus as DatabaseAssetJobStatus, Exif as DatabaseExif } from 'src/db'; import { Albums, Exif as DatabaseExif } from 'src/db';
import { AssetEntity } from 'src/entities/asset.entity'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { import {
AlbumUserRole, AlbumUserRole,
AssetFileType, AssetFileType,
AssetStatus,
AssetType, AssetType,
MemoryType, MemoryType,
Permission, Permission,
SharedLinkType,
SourceType, SourceType,
UserStatus, UserStatus,
} from 'src/enum'; } from 'src/enum';
@ -44,7 +44,7 @@ export type Library = {
exclusionPatterns: string[]; exclusionPatterns: string[];
deletedAt: Date | null; deletedAt: Date | null;
refreshedAt: Date | null; refreshedAt: Date | null;
assets?: Asset[]; assets?: MapAsset[];
}; };
export type AuthApiKey = { export type AuthApiKey = {
@ -96,7 +96,26 @@ export type Memory = {
data: OnThisDayData; data: OnThisDayData;
ownerId: string; ownerId: string;
isSaved: boolean; isSaved: boolean;
assets: Asset[]; assets: MapAsset[];
};
export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
isExternal: boolean;
isVisible: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date;
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
type: AssetType;
}; };
export type User = { export type User = {
@ -128,39 +147,6 @@ export type StorageAsset = {
encodedVideoPath: string | null; encodedVideoPath: string | null;
}; };
export type Asset = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
id: string;
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
encodedVideoPath: string | null;
fileCreatedAt: Date | null;
fileModifiedAt: Date | null;
isArchived: boolean;
isExternal: boolean;
isFavorite: boolean;
isOffline: boolean;
isVisible: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date | null;
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
stack?: Stack | null;
stackId: string | null;
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
};
export type SidecarWriteAsset = { export type SidecarWriteAsset = {
id: string; id: string;
sidecarPath: string | null; sidecarPath: string | null;
@ -173,7 +159,7 @@ export type Stack = {
primaryAssetId: string; primaryAssetId: string;
owner?: User; owner?: User;
ownerId: string; ownerId: string;
assets: AssetEntity[]; assets: MapAsset[];
assetCount?: number; assetCount?: number;
}; };
@ -187,6 +173,28 @@ export type AuthSharedLink = {
password: string | null; password: string | null;
}; };
export type SharedLink = {
id: string;
album?: Album | null;
albumId: string | null;
allowDownload: boolean;
allowUpload: boolean;
assets: MapAsset[];
createdAt: Date;
description: string | null;
expiresAt: Date | null;
key: Buffer;
password: string | null;
showExif: boolean;
type: SharedLinkType;
userId: string;
};
export type Album = Selectable<Albums> & {
owner: User;
assets: MapAsset[];
};
export type AuthSession = { export type AuthSession = {
id: string; id: string;
}; };
@ -256,10 +264,6 @@ export type AssetFace = {
person?: Person | null; person?: Person | null;
}; };
export type AssetJobStatus = Selectable<DatabaseAssetJobStatus> & {
asset: AssetEntity;
};
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
export const columns = { export const columns = {

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

@ -143,8 +143,8 @@ export interface Assets {
duplicateId: string | null; duplicateId: string | null;
duration: string | null; duration: string | null;
encodedVideoPath: Generated<string | null>; encodedVideoPath: Generated<string | null>;
fileCreatedAt: Timestamp | null; fileCreatedAt: Timestamp;
fileModifiedAt: Timestamp | null; fileModifiedAt: Timestamp;
id: Generated<string>; id: Generated<string>;
isArchived: Generated<boolean>; isArchived: Generated<boolean>;
isExternal: Generated<boolean>; isExternal: Generated<boolean>;
@ -153,7 +153,7 @@ export interface Assets {
isVisible: Generated<boolean>; isVisible: Generated<boolean>;
libraryId: string | null; libraryId: string | null;
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
localDateTime: Timestamp | null; localDateTime: Timestamp;
originalFileName: string; originalFileName: string;
originalPath: string; originalPath: string;
ownerId: string; ownerId: string;

View File

@ -11,7 +11,8 @@ import { setUnion } from 'src/utils/set';
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) => const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` }); Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` });
export const UpdateIdColumn = () => GeneratedUuidV7Column(); export const UpdateIdColumn = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
GeneratedUuidV7Column(options);
export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true }); export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true });

View File

@ -2,10 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsEnum, IsString, ValidateNested } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsEnum, IsString, ValidateNested } from 'class-validator';
import _ from 'lodash'; import _ from 'lodash';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { AlbumUserRole, AssetOrder } from 'src/enum'; import { AlbumUserRole, AssetOrder } from 'src/enum';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
@ -142,7 +142,23 @@ export class AlbumResponseDto {
order?: AssetOrder; order?: AssetOrder;
} }
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { export type MapAlbumDto = {
albumUsers?: AlbumUser[];
assets?: MapAsset[];
sharedLinks?: AuthSharedLink[];
albumName: string;
description: string;
albumThumbnailAssetId: string | null;
createdAt: Date;
updatedAt: Date;
id: string;
ownerId: string;
owner: User;
isActivityEnabled: boolean;
order: AssetOrder;
};
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = [];
if (entity.albumUsers) { if (entity.albumUsers) {
@ -159,7 +175,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
const assets = entity.assets || []; const assets = entity.assets || [];
const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
const hasSharedUser = albumUsers.length > 0; const hasSharedUser = albumUsers.length > 0;
let startDate = assets.at(0)?.localDateTime; let startDate = assets.at(0)?.localDateTime;
@ -190,5 +206,5 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
}; };
}; };
export const mapAlbumWithAssets = (entity: AlbumEntity) => mapAlbum(entity, true); export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true);
export const mapAlbumWithoutAssets = (entity: AlbumEntity) => mapAlbum(entity, false); export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false);

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { AssetFace } from 'src/database'; import { Selectable } from 'kysely';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
@ -11,8 +12,7 @@ import {
} from 'src/dtos/person.dto'; } from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus, AssetType } from 'src/enum';
import { AssetType } from 'src/enum';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
export class SanitizedAssetResponseDto { export class SanitizedAssetResponseDto {
@ -56,6 +56,44 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
resized?: boolean; resized?: boolean;
} }
export type MapAsset = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
id: string;
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
encodedVideoPath: string | null;
exifInfo?: Selectable<Exif> | null;
faces?: AssetFace[];
fileCreatedAt: Date;
fileModifiedAt: Date;
files?: AssetFile[];
isArchived: boolean;
isExternal: boolean;
isFavorite: boolean;
isOffline: boolean;
isVisible: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date;
originalFileName: string;
originalPath: string;
owner?: User | null;
ownerId: string;
sidecarPath: string | null;
stack?: Stack | null;
stackId: string | null;
tags?: Tag[];
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
};
export class AssetStackResponseDto { export class AssetStackResponseDto {
id!: string; id!: string;
@ -72,7 +110,7 @@ export type AssetMapOptions = {
}; };
// TODO: this is inefficient // TODO: this is inefficient
const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => { const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = []; const result: PersonWithFacesResponseDto[] = [];
if (faces) { if (faces) {
for (const face of faces) { for (const face of faces) {
@ -90,7 +128,7 @@ const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => {
return result; return result;
}; };
const mapStack = (entity: AssetEntity) => { const mapStack = (entity: { stack?: Stack | null }) => {
if (!entity.stack) { if (!entity.stack) {
return null; return null;
} }
@ -111,7 +149,7 @@ export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
return encoded.toString('base64'); return encoded.toString('base64');
}; };
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options; const { stripMetadata = false, withStack = false } = options;
if (stripMetadata) { if (stripMetadata) {

View File

@ -4,7 +4,6 @@ import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-valid
import { Memory } from 'src/database'; import { Memory } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryType } from 'src/enum'; import { MemoryType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
@ -103,6 +102,6 @@ export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
type: entity.type as MemoryType, type: entity.type as MemoryType,
data: entity.data as unknown as MemoryData, data: entity.data as unknown as MemoryData,
isSaved: entity.isSaved, isSaved: entity.isSaved,
assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(asset as AssetEntity, { auth })), assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(asset, { auth })),
}; };
}; };

View File

@ -1,9 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator'; import { IsEnum, IsString } from 'class-validator';
import _ from 'lodash'; import _ from 'lodash';
import { SharedLink } from 'src/database';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
@ -102,7 +102,7 @@ export class SharedLinkResponseDto {
showMetadata!: boolean; showMetadata!: boolean;
} }
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
return { return {
@ -122,7 +122,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
}; };
} }
export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): 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);
@ -137,7 +137,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
type: sharedLink.type, type: sharedLink.type,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[], assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,

View File

@ -2,7 +2,7 @@ import { Img, Link, Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumInviteEmailProps } from 'src/repositories/notification.repository'; import { AlbumInviteEmailProps } from 'src/repositories/email.repository';
import { replaceTemplateTags } from 'src/utils/replace-template-tags'; import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumInviteEmail = ({ export const AlbumInviteEmail = ({

View File

@ -2,7 +2,7 @@ import { Img, Link, Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumUpdateEmailProps } from 'src/repositories/notification.repository'; import { AlbumUpdateEmailProps } from 'src/repositories/email.repository';
import { replaceTemplateTags } from 'src/utils/replace-template-tags'; import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumUpdateEmail = ({ export const AlbumUpdateEmail = ({

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