mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
Merge branch 'main' into improve_focus
This commit is contained in:
commit
dccf33af2d
29
.github/workflows/build-mobile.yml
vendored
29
.github/workflows/build-mobile.yml
vendored
@ -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:
|
||||||
|
17
.github/workflows/cache-cleanup.yml
vendored
17
.github/workflows/cache-cleanup.yml
vendored
@ -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 }}
|
|
||||||
|
13
.github/workflows/cli.yml
vendored
13
.github/workflows/cli.yml
vendored
@ -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
|
||||||
|
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@ -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
|
||||||
|
85
.github/workflows/docker.yml
vendored
85
.github/workflows/docker.yml
vendored
@ -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:
|
||||||
|
10
.github/workflows/docs-build.yml
vendored
10
.github/workflows/docs-build.yml
vendored
@ -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
|
||||||
|
20
.github/workflows/docs-deploy.yml
vendored
20
.github/workflows/docs-deploy.yml
vendored
@ -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
|
||||||
|
7
.github/workflows/docs-destroy.yml
vendored
7
.github/workflows/docs-destroy.yml
vendored
@ -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:
|
||||||
|
4
.github/workflows/fix-format.yml
vendored
4
.github/workflows/fix-format.yml
vendored
@ -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
|
||||||
|
2
.github/workflows/pr-label-validation.yml
vendored
2
.github/workflows/pr-label-validation.yml
vendored
@ -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
|
||||||
|
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
@ -2,6 +2,8 @@ name: 'Pull Request Labeler'
|
|||||||
on:
|
on:
|
||||||
- pull_request_target
|
- pull_request_target
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
permissions:
|
permissions:
|
||||||
|
@ -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
|
||||||
|
18
.github/workflows/prepare-release.yml
vendored
18
.github/workflows/prepare-release.yml
vendored
@ -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: |
|
||||||
|
2
.github/workflows/preview-label.yaml
vendored
2
.github/workflows/preview-label.yaml
vendored
@ -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
|
||||||
|
8
.github/workflows/sdk.yml
vendored
8
.github/workflows/sdk.yml
vendored
@ -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:
|
||||||
|
16
.github/workflows/static_analysis.yml
vendored
16
.github/workflows/static_analysis.yml
vendored
@ -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
|
||||||
|
80
.github/workflows/test.yml
vendored
80
.github/workflows/test.yml
vendored
@ -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:
|
||||||
|
13
.github/workflows/weblate-lock.yml
vendored
13
.github/workflows/weblate-lock.yml
vendored
@ -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
4
cli/package-lock.json
generated
@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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
5
docs/src/pages/errors.md
Normal 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.
|
@ -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' }}
|
||||||
|
@ -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
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
6
e2e/package-lock.json
generated
@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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",
|
||||||
|
11
i18n/en.json
11
i18n/en.json
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
18
mobile/lib/domain/interfaces/sync_stream.interface.dart
Normal file
18
mobile/lib/domain/interfaces/sync_stream.interface.dart
Normal 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);
|
||||||
|
}
|
@ -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)';
|
|
||||||
}
|
|
13
mobile/lib/domain/models/sync_event.model.dart
Normal file
13
mobile/lib/domain/models/sync_event.model.dart
Normal 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)';
|
||||||
|
}
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
39
mobile/lib/domain/utils/background_sync.dart
Normal file
39
mobile/lib/domain/utils/background_sync.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
@ -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 {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()),
|
||||||
|
@ -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(),
|
||||||
|
@ -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]
|
||||||
|
@ -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;
|
||||||
|
8
mobile/lib/providers/background_sync.provider.dart
Normal file
8
mobile/lib/providers/background_sync.provider.dart
Normal 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;
|
||||||
|
});
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
12
mobile/lib/providers/infrastructure/cancel.provider.dart
Normal file
12
mobile/lib/providers/infrastructure/cancel.provider.dart
Normal 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',
|
||||||
|
);
|
@ -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;
|
||||||
|
});
|
||||||
|
@ -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)),
|
||||||
);
|
);
|
||||||
|
@ -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> {
|
||||||
|
@ -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),
|
||||||
|
@ -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'] =
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
69
mobile/lib/utils/isolate.dart
Normal file
69
mobile/lib/utils/isolate.dart
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
@ -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) {
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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!),
|
||||||
),
|
),
|
||||||
|
@ -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(
|
||||||
|
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@ -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 |
|
||||||
|
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
@ -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';
|
||||||
|
@ -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));
|
||||||
}
|
}
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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 {}
|
||||||
|
@ -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 {}
|
||||||
|
222
mobile/test/domain/services/sync_stream_service_test.dart
Normal file
222
mobile/test/domain/services/sync_stream_service_test.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
45
mobile/test/fixtures/sync_stream.stub.dart
vendored
Normal file
45
mobile/test/fixtures/sync_stream.stub.dart
vendored
Normal 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",
|
||||||
|
);
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
@ -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 {}
|
||||||
|
@ -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 {}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
8
open-api/typescript-sdk/package-lock.json
generated
8
open-api/typescript-sdk/package-lock.json
generated
@ -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": {
|
||||||
|
@ -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": {
|
||||||
|
@ -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
|
||||||
|
2
server/package-lock.json
generated
2
server/package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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' });
|
|
@ -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}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
@ -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
6
server/src/db.d.ts
vendored
@ -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;
|
||||||
|
@ -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 });
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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) {
|
||||||
|
@ -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 })),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -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 = ({
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user