Compare commits

..

21 Commits

Author SHA1 Message Date
Daniel Dietzler c66d7e97cb chore: face cluster abstraction 2026-03-25 12:23:04 +01:00
renovate[bot] 4af9edc20b chore(deps): update github-actions (#27215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 14:31:00 +01:00
renovate[bot] c975fe5bc7 chore(deps): update github-actions (major) (#27225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 12:40:10 +00:00
renovate[bot] 12a4d8e2ee chore(deps): update ghcr.io/jdx/mise docker tag to v2026.3.12 (#27224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 12:06:19 +00:00
github-actions ce9b32a61a chore: version v2.6.2 2026-03-24 02:51:55 +00:00
Yaros 4ddc288cd1 fix(mobile/web): album cover buttons consistency (#27213)
* fix(mobile/web): album cover buttons consistency

* test: adjust test
2026-03-23 21:40:17 -05:00
Yaros 94b15b8678 fix(server): album permissions for editors (#27214)
* fix(server): album permissions for editors

* test: adjust e2e test

* test: fix test
2026-03-23 21:39:30 -05:00
Daniel Dietzler ff9ae24219 fix: album picker show all albums (#27211) 2026-03-23 19:08:57 -05:00
Matthew Momjian b456f78771 fix(docs): clarify ML CPU architecture (#27187)
* ML architecture

* format

* clarify amd/arm
2026-03-23 18:29:58 -04:00
Mert 1506776891 fix(mobile): add cookie for auxiliary url (#27209)
add cookie before validating
2026-03-23 16:22:46 -05:00
Yaros 0e93aa74cf fix(mobile): add keys to people list (#27112)
mobile(fix): add keys to people list
2026-03-23 10:50:56 -05:00
Yaros e95ad9d2eb fix(mobile): option padding on search dropdowns (#27154)
* fix(mobile): option padding on search dropdowns

* chore: prevent height fill up screen and block the bottom menu entry

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-23 15:03:07 +00:00
Nicolas-micuda-becker b98a227bbd fix(web): update upload summary when removing items (#27035) (#27139) 2026-03-23 10:02:09 -05:00
Michel Heusschen 2dd785e3e2 fix(web): restore duplicate viewer arrow key navigation (#27176) 2026-03-23 10:01:15 -05:00
Daniel Dietzler 7e754125cd fix: download original stale cache when edited (#27195) 2026-03-23 10:00:32 -05:00
Yaros e2eb03d3a4 fix(mobile): star rating always defaults to 0 (#27157) 2026-03-23 09:56:27 -05:00
Yaros bf065a834f fix(mobile): no results before applying filter (#27155) 2026-03-23 09:41:13 -05:00
Daniel Dietzler db79173b5b chore: vite 8 (#26913) 2026-03-23 15:39:46 +01:00
Yaros 33666ccd21 fix(mobile): view similar photos from search (#27149)
* fix(mobile): view similar photos from search

* clean up

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-03-23 09:36:42 -05:00
bo0tzz be93b9040c feat: consolidate auto-close workflows (#27172) 2026-03-23 11:22:44 +01:00
Luis Nachtigall 00dae6ac38 fix(mobile): cronet image cache clearing on android (#27054) 2026-03-20 18:28:24 -04:00
80 changed files with 1549 additions and 694 deletions
+143
View File
@@ -0,0 +1,143 @@
name: Auto-close PRs
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, labeled]
permissions: {}
jobs:
parse_template:
runs-on: ubuntu-latest
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
close_template:
runs-on: ubuntu-latest
needs: parse_template
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Add label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
reopen:
runs-on: ubuntu-latest
needs: parse_template
if: >-
${{
needs.parse_template.outputs.uses_template == 'true'
&& github.event.pull_request.state == 'closed'
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
}}
permissions:
pull-requests: write
steps:
- name: Remove template label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
- name: Reopen PR
if: ${{ steps.check_labels.outputs.remaining == '0' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
+11 -11
View File
@@ -51,14 +51,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -79,7 +79,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -103,7 +103,7 @@ jobs:
- name: Restore Gradle Cache - name: Restore Gradle Cache
id: cache-gradle-restore id: cache-gradle-restore
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -114,7 +114,7 @@ jobs:
key: build-mobile-gradle-${{ runner.os }}-main key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0 uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
@@ -153,14 +153,14 @@ jobs:
fi fi
- name: Publish Android Artifact - name: Publish Android Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: release-apk-signed name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache - name: Save Gradle Cache
id: cache-gradle-save id: cache-gradle-save
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
with: with:
path: | path: |
@@ -185,13 +185,13 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ inputs.ref || github.sha }} ref: ${{ inputs.ref || github.sha }}
persist-credentials: false persist-credentials: false
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2 uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with: with:
ruby-version: '3.3' ruby-version: '3.3'
bundler-cache: true bundler-cache: true
@@ -291,7 +291,7 @@ jobs:
security delete-keychain build.keychain || true security delete-keychain build.keychain || true
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: ios-release-ipa name: ios-release-ipa
path: mobile/ios/Runner.ipa path: mobile/ios/Runner.ipa
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write actions: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31 uses: oasdiff/oasdiff-action/breaking@2a37bc82462349c03a533b8b608bebbaf57b3e60 # v0.0.33
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
-97
View File
@@ -1,97 +0,0 @@
name: Check PR Template
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]
permissions: {}
env:
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
addLabelsToLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR was auto-closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f query='
mutation ReopenPR($prId: ID!, $labelId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
removeLabelsFromLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
+8 -8
View File
@@ -31,7 +31,7 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -42,7 +42,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -71,7 +71,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -83,13 +83,13 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
@@ -104,7 +104,7 @@ jobs:
- name: Generate docker image tags - name: Generate docker image tags
id: metadata id: metadata
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with: with:
flavor: | flavor: |
latest=false latest=false
@@ -115,7 +115,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f image: ghcr.io/immich-app/mdq:main@sha256:df7188ba88abb0800d73cc97d3633280f0c0c3d4c441d678225067bf154150fb
outputs: outputs:
checked: ${{ steps.get_checkbox.outputs.checked }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:
-38
View File
@@ -1,38 +0,0 @@
name: Close LLM-generated PRs
on:
pull_request_target:
types: [labeled]
permissions: {}
jobs:
comment_and_close:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'llm-generated' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
+4 -4
View File
@@ -44,7 +44,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+6 -6
View File
@@ -23,14 +23,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: [''] suffix: ['']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm' suffixes: '-rocm'
platforms: linux/amd64 platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}' runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server name: Build and Push Server
needs: pre-job needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
permissions: permissions:
contents: read contents: read
actions: read actions: read
+5 -5
View File
@@ -21,14 +21,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -54,7 +54,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -67,7 +67,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -86,7 +86,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Upload build output - name: Upload build output
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: docs-build-output name: docs-build-output
path: docs/build/ path: docs/build/
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }} artifact: ${{ steps.get-artifact.outputs.result }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
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:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2 uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
- name: Load parameters - name: Load parameters
id: parameters id: parameters
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2 uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
- name: Destroy Docs Subdomain - name: Destroy Docs Subdomain
env: env:
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
persist-credentials: true persist-credentials: true
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token - name: Generate a token
id: generate_token id: generate_token
if: ${{ inputs.skip != true }} if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -14,13 +14,13 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label - name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1 uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
with: with:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
mode: exactly mode: exactly
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+6 -6
View File
@@ -50,7 +50,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -63,10 +63,10 @@ jobs:
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -124,7 +124,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -136,13 +136,13 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Download APK - name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: release-apk-signed name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with: with:
draft: true draft: true
tag_name: ${{ needs.bump_version.outputs.version }} tag_name: ${{ needs.bump_version.outputs.version }}
+5 -5
View File
@@ -14,12 +14,12 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 - uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status' message-id: 'preview-status'
@@ -32,7 +32,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -48,14 +48,14 @@ jobs:
name: 'preview' name: 'preview'
}) })
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 - uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
if: ${{ github.event.pull_request.head.repo.fork }} if: ${{ github.event.pull_request.head.repo.fork }}
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status' message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.' message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 - uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,7 +30,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+4 -4
View File
@@ -20,14 +20,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0 uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
+38 -38
View File
@@ -17,14 +17,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -63,7 +63,7 @@ jobs:
working-directory: ./server working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,7 +75,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -108,7 +108,7 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -155,7 +155,7 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -166,7 +166,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -197,7 +197,7 @@ jobs:
working-directory: ./web working-directory: ./web
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -208,7 +208,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -241,7 +241,7 @@ jobs:
working-directory: ./web working-directory: ./web
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -252,7 +252,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -279,7 +279,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -290,7 +290,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -327,7 +327,7 @@ jobs:
working-directory: ./e2e working-directory: ./e2e
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -338,7 +338,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -373,7 +373,7 @@ jobs:
working-directory: ./server working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -385,7 +385,7 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -412,7 +412,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm] runner: [ubuntu-latest, ubuntu-24.04-arm]
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -424,7 +424,7 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -464,7 +464,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e working-directory: ./e2e
- name: Archive Docker logs - name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always() if: always()
with: with:
name: e2e-server-docker-logs-${{ matrix.runner }} name: e2e-server-docker-logs-${{ matrix.runner }}
@@ -484,7 +484,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm] runner: [ubuntu-latest, ubuntu-24.04-arm]
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -496,7 +496,7 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -522,7 +522,7 @@ jobs:
run: pnpm test:web run: pnpm test:web
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive e2e test (web) results - name: Archive e2e test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure() if: success() || failure()
with: with:
name: e2e-web-test-results-${{ matrix.runner }} name: e2e-web-test-results-${{ matrix.runner }}
@@ -533,7 +533,7 @@ jobs:
run: pnpm test:web:ui run: pnpm test:web:ui
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive ui test (web) results - name: Archive ui test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure() if: success() || failure()
with: with:
name: e2e-ui-test-results-${{ matrix.runner }} name: e2e-ui-test-results-${{ matrix.runner }}
@@ -544,7 +544,7 @@ jobs:
run: pnpm test:web:maintenance run: pnpm test:web:maintenance
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results - name: Archive maintenance tests (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure() if: success() || failure()
with: with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }} name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
@@ -554,7 +554,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e working-directory: ./e2e
- name: Archive Docker logs - name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always() if: always()
with: with:
name: e2e-web-docker-logs-${{ matrix.runner }} name: e2e-web-docker-logs-${{ matrix.runner }}
@@ -578,7 +578,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -588,7 +588,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0 uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
@@ -610,7 +610,7 @@ jobs:
working-directory: ./machine-learning working-directory: ./machine-learning
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with: with:
python-version: 3.11 python-version: 3.11
- name: Install dependencies - name: Install dependencies
@@ -650,7 +650,7 @@ jobs:
working-directory: ./.github working-directory: ./.github
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -661,7 +661,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -680,7 +680,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -701,7 +701,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -712,7 +712,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -763,7 +763,7 @@ jobs:
working-directory: ./server working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -774,7 +774,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
+3 -3
View File
@@ -24,14 +24,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }} if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.6.1", "version": "2.6.2",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -35,8 +35,7 @@
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"vite": "^7.0.0", "vite": "^8.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.0", "vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0", "vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1" "yaml": "^2.3.1"
+5 -4
View File
@@ -1,10 +1,12 @@
import { defineConfig, UserConfig } from 'vite'; import { defineConfig, UserConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ export default defineConfig({
resolve: { alias: { src: '/src' } }, resolve: {
alias: { src: '/src' },
tsconfigPaths: true,
},
build: { build: {
rollupOptions: { rolldownOptions: {
input: 'src/index.ts', input: 'src/index.ts',
output: { output: {
dir: 'dist', dir: 'dist',
@@ -16,7 +18,6 @@ export default defineConfig({
// bundle everything except for Node built-ins // bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/, noExternal: /^(?!node:).*$/,
}, },
plugins: [tsconfigPaths()],
test: { test: {
name: 'cli:unit', name: 'cli:unit',
globals: true, globals: true,
+5 -1
View File
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
## Hardware ## Hardware
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc). - **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged. - Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced. Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
If you still want to try to use a non-Linux OS, you can set it up as follows: If you still want to try to use a non-Linux OS, you can set it up as follows:
@@ -19,6 +19,10 @@ Hardware and software requirements for Immich:
If you have issues, we recommend that you switch to a supported VM deployment. If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 6GB, recommended 8GB. - **RAM**: Minimum 6GB, recommended 8GB.
- **CPU**: Minimum 2 cores, recommended 4 cores. - **CPU**: Minimum 2 cores, recommended 4 cores.
- Immich runs on the `amd64` and `arm64` platforms.
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
Most CPUs released since ~2012 support this microarchitecture.
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
+2 -2
View File
@@ -1,7 +1,7 @@
[ [
{ {
"label": "v2.6.1", "label": "v2.6.2",
"url": "https://docs.v2.6.1.archive.immich.app" "url": "https://docs.v2.6.2.archive.immich.app"
}, },
{ {
"label": "v2.5.6", "label": "v2.5.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.6.1", "version": "2.6.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+8 -3
View File
@@ -524,14 +524,19 @@ describe('/albums', () => {
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access')); expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
}); });
it('should not be able to update as an editor', async () => { it('should be able to update as an editor', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.patch(`/albums/${user1Albums[0].id}`) .patch(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`) .set('Authorization', `Bearer ${user2.accessToken}`)
.send({ albumName: 'New album name' }); .send({ albumName: 'New album name' });
expect(status).toBe(400); expect(status).toBe(200);
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access')); expect(body).toEqual(
expect.objectContaining({
id: user1Albums[0].id,
albumName: 'New album name',
}),
);
}); });
}); });
+51
View File
@@ -0,0 +1,51 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-i18n", "name": "immich-i18n",
"version": "2.6.1", "version": "2.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"format": "prettier --cache --check .", "format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.6.1" version = "2.6.2"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0" requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]] [[package]]
name = "immich-ml" name = "immich-ml"
version = "2.6.1" version = "2.6.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocache" }, { name = "aiocache" },
@@ -23,10 +23,18 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.net.Authenticator import java.net.Authenticator
import java.net.CookieHandler import java.net.CookieHandler
import java.net.PasswordAuthentication import java.net.PasswordAuthentication
@@ -277,10 +285,13 @@ object HttpClientManager {
return result return result
} }
fun rebuildCronetEngine(): CronetEngine { suspend fun rebuildCronetEngine(): Result<Long> {
val old = cronetEngine!! return runCatching {
cronetEngine = buildCronetEngine() cronetEngine?.shutdown()
return old val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
cronetEngine = buildCronetEngine()
deletionResult
}
} }
val cronetStoragePath: File get() = cronetStorageDir val cronetStoragePath: File get() = cronetStorageDir
@@ -301,7 +312,7 @@ object HttpClientManager {
} }
} }
private fun buildCronetEngine(): CronetEngine { fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext) return CronetEngine.Builder(appContext)
.enableHttp2(true) .enableHttp2(true)
.enableQuic(true) .enableQuic(true)
@@ -312,6 +323,27 @@ object HttpClientManager {
.build() .build()
} }
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
private fun build(cacheDir: File): OkHttpClient { private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool( val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS, maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -21,11 +21,6 @@ import java.io.EOFException
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
private class RemoteRequest(val cancellationSignal: CancellationSignal) private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -205,18 +200,15 @@ private class CronetImageFetcher : ImageFetcher {
private fun onDrained() { private fun onDrained() {
val onCacheCleared = synchronized(stateLock) { val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared val onCacheCleared = this.onCacheCleared
this.onCacheCleared = null this.onCacheCleared = null
onCacheCleared onCacheCleared
} } ?: return
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine() CoroutineScope(Dispatchers.IO).launch {
oldEngine.shutdown() val result = HttpClientManager.rebuildCronetEngine()
CoroutineScope(Dispatchers.IO).launch { synchronized(stateLock) { draining = false }
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) } onCacheCleared(result)
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
} }
} }
@@ -306,26 +298,6 @@ private class CronetImageFetcher : ImageFetcher {
} }
} }
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
} }
private class OkHttpImageFetcher private constructor( private class OkHttpImageFetcher private constructor(
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3039, "android.injected.version.code" => 3040,
"android.injected.version.name" => "2.6.1", "android.injected.version.name" => "2.6.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.6.1</string> <string>2.6.2</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -79,6 +79,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
final person = people[index]; final person = people[index];
return Column( return Column(
key: ValueKey(person.id),
children: [ children: [
GestureDetector( GestureDetector(
onTap: () { onTap: () {
@@ -88,6 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
shape: const CircleBorder(side: BorderSide.none), shape: const CircleBorder(side: BorderSide.none),
elevation: 3, elevation: 3,
child: CircleAvatar( child: CircleAvatar(
key: ValueKey('avatar-${person.id}'),
maxRadius: isTablet ? 100 / 2 : 96 / 2, maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)), backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
), ),
@@ -69,6 +69,7 @@ class DriftSearchPage extends HookConsumerWidget {
); );
final previousFilter = useState<SearchFilter?>(null); final previousFilter = useState<SearchFilter?>(null);
final hasRequestedSearch = useState<bool>(false);
final dateInputFilter = useState<DateFilterInputModel?>(null); final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null); final peopleCurrentFilterWidget = useState<Widget?>(null);
@@ -91,9 +92,11 @@ class DriftSearchPage extends HookConsumerWidget {
if (filter.isEmpty) { if (filter.isEmpty) {
previousFilter.value = null; previousFilter.value = null;
hasRequestedSearch.value = false;
return; return;
} }
hasRequestedSearch.value = true;
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter)); unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter; previousFilter.value = filter;
} }
@@ -107,6 +110,8 @@ class DriftSearchPage extends HookConsumerWidget {
searchPreFilter() { searchPreFilter() {
if (preFilter != null) { if (preFilter != null) {
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
filter.value = preFilter;
textSearchController.clear();
searchFilter(preFilter); searchFilter(preFilter);
if (preFilter.location.city != null) { if (preFilter.location.city != null) {
@@ -719,7 +724,7 @@ class DriftSearchPage extends HookConsumerWidget {
), ),
), ),
), ),
if (filter.value.isEmpty) if (!hasRequestedSearch.value)
const _SearchSuggestions() const _SearchSuggestions()
else else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults), _SearchResultGrid(onScrollEnd: loadMoreSearchResults),
@@ -24,20 +24,22 @@ class SimilarPhotosActionButton extends ConsumerWidget {
} }
ref.invalidate(assetViewerProvider); ref.invalidate(assetViewerProvider);
ref ref.invalidate(paginatedSearchProvider);
.read(searchPreFilterProvider.notifier)
.setFilter( ref.read(searchPreFilterProvider.notifier)
SearchFilter( ..clear()
assetId: assetId, ..setFilter(
people: {}, SearchFilter(
location: SearchLocationFilter(), assetId: assetId,
camera: SearchCameraFilter(), people: {},
date: SearchDateFilter(), location: SearchLocationFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), camera: SearchCameraFilter(),
rating: SearchRatingFilter(), date: SearchDateFilter(),
mediaType: AssetType.image, display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
), rating: SearchRatingFilter(),
); mediaType: AssetType.image,
),
);
unawaited(context.navigateTo(const DriftSearchRoute())); unawaited(context.navigateTo(const DriftSearchRoute()));
} }
@@ -39,6 +39,16 @@ class _RatingBarState extends State<RatingBar> {
_currentRating = widget.initialRating; _currentRating = widget.initialRating;
} }
@override
void didUpdateWidget(covariant RatingBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialRating != widget.initialRating && _currentRating != widget.initialRating) {
setState(() {
_currentRating = widget.initialRating;
});
}
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) { void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding; final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx; double dx = localPosition.dx;
+3
View File
@@ -67,6 +67,9 @@ class AuthService {
bool isValid = false; bool isValid = false;
try { try {
final urls = ApiService.getServerUrls();
urls.add(url);
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), urls);
final uri = Uri.parse('$url/users/me'); final uri = Uri.parse('$url/users/me');
final response = await NetworkRepository.client.get(uri); final response = await NetworkRepository.client.get(uri);
if (response.statusCode == 200) { if (response.statusCode == 200) {
+1 -2
View File
@@ -143,8 +143,7 @@ enum ActionButtonType {
!context.isInLockedView && // !context.isInLockedView && //
context.currentAlbum != null, context.currentAlbum != null,
ActionButtonType.setAlbumCover => ActionButtonType.setAlbumCover =>
context.isOwner && // !context.isInLockedView && //
!context.isInLockedView && //
context.currentAlbum != null && // context.currentAlbum != null && //
context.selectedCount == 1, context.selectedCount == 1,
ActionButtonType.unstack => ActionButtonType.unstack =>
@@ -16,9 +16,15 @@ class SearchDropdown<T> extends StatelessWidget {
final Widget? label; final Widget? label;
final Widget? leadingIcon; final Widget? leadingIcon;
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final menuStyle = const MenuStyle( final mediaQuery = MediaQuery.of(context);
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
const menuStyle = MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>( shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))), RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
), ),
@@ -26,11 +32,26 @@ class SearchDropdown<T> extends StatelessWidget {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final styledEntries = dropdownMenuEntries
.map(
(entry) => DropdownMenuEntry<T>(
value: entry.value,
label: entry.label,
labelWidget: entry.labelWidget,
enabled: entry.enabled,
leadingIcon: entry.leadingIcon,
trailingIcon: entry.trailingIcon,
style: (entry.style ?? const ButtonStyle()).copyWith(padding: _optionPadding),
),
)
.toList(growable: false);
return DropdownMenu( return DropdownMenu(
controller: controller, controller: controller,
leadingIcon: leadingIcon, leadingIcon: leadingIcon,
width: constraints.maxWidth, width: constraints.maxWidth,
dropdownMenuEntries: dropdownMenuEntries, menuHeight: maxMenuHeight,
dropdownMenuEntries: styledEntries,
label: label, label: label,
menuStyle: menuStyle, menuStyle: menuStyle,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded), trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.6.1 - API version: 2.6.2
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+2 -2
View File
@@ -1218,8 +1218,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "2f38a2a84184141ec3ae708ada5d7dda87b5a409" ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: "2f38a2a84184141ec3ae708ada5d7dda87b5a409" resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"
+2 -2
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.6.1+3039 version: 2.6.2+3040
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
@@ -56,7 +56,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: '2f38a2a84184141ec3ae708ada5d7dda87b5a409' ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi:
@@ -727,7 +727,7 @@ void main() {
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue); expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
}); });
test('should not show when not owner', () { test('should show when not owner', () {
final album = createRemoteAlbum(); final album = createRemoteAlbum();
final context = ActionButtonContext( final context = ActionButtonContext(
asset: mergedAsset, asset: mergedAsset,
@@ -742,7 +742,7 @@ void main() {
selectedCount: 1, selectedCount: 1,
); );
expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
}); });
test('should not show when in locked view', () { test('should not show when in locked view', () {
+185 -4
View File
@@ -8409,7 +8409,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/MergePersonDto" "$ref": "#/components/schemas/MergeFaceClusterDto"
} }
} }
}, },
@@ -15166,7 +15166,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "2.6.1", "version": "2.6.2",
"contact": {} "contact": {}
}, },
"tags": [ "tags": [
@@ -18878,10 +18878,10 @@
}, },
"type": "object" "type": "object"
}, },
"MergePersonDto": { "MergeFaceClusterDto": {
"properties": { "properties": {
"ids": { "ids": {
"description": "Person IDs to merge", "description": "Face cluster IDs to merge",
"items": { "items": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@@ -23055,6 +23055,70 @@
], ],
"type": "object" "type": "object"
}, },
"SyncAssetFaceV3": {
"properties": {
"assetId": {
"description": "Asset ID",
"type": "string"
},
"boundingBoxX1": {
"type": "integer"
},
"boundingBoxX2": {
"type": "integer"
},
"boundingBoxY1": {
"type": "integer"
},
"boundingBoxY2": {
"type": "integer"
},
"deletedAt": {
"description": "Face deleted at",
"format": "date-time",
"nullable": true,
"type": "string"
},
"faceClusterId": {
"description": "Face cluster ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Asset face ID",
"type": "string"
},
"imageHeight": {
"type": "integer"
},
"imageWidth": {
"type": "integer"
},
"isVisible": {
"description": "Is the face visible in the asset",
"type": "boolean"
},
"sourceType": {
"description": "Source type",
"type": "string"
}
},
"required": [
"assetId",
"boundingBoxX1",
"boundingBoxX2",
"boundingBoxY1",
"boundingBoxY2",
"deletedAt",
"faceClusterId",
"id",
"imageHeight",
"imageWidth",
"isVisible",
"sourceType"
],
"type": "object"
},
"SyncAssetMetadataDeleteV1": { "SyncAssetMetadataDeleteV1": {
"properties": { "properties": {
"assetId": { "assetId": {
@@ -23348,10 +23412,14 @@
"StackV1", "StackV1",
"StackDeleteV1", "StackDeleteV1",
"PersonV1", "PersonV1",
"PersonV2",
"PersonDeleteV1", "PersonDeleteV1",
"AssetFaceV1", "AssetFaceV1",
"AssetFaceV2", "AssetFaceV2",
"AssetFaceV3",
"AssetFaceDeleteV1", "AssetFaceDeleteV1",
"FaceClusterV1",
"FaceClusterDeleteV1",
"UserMetadataV1", "UserMetadataV1",
"UserMetadataDeleteV1", "UserMetadataDeleteV1",
"SyncAckV1", "SyncAckV1",
@@ -23360,6 +23428,47 @@
], ],
"type": "string" "type": "string"
}, },
"SyncFaceClusterDeleteV1": {
"properties": {
"faceClusterId": {
"description": "Face cluster ID",
"type": "string"
}
},
"required": [
"faceClusterId"
],
"type": "object"
},
"SyncFaceClusterV1": {
"properties": {
"createdAt": {
"description": "Created at",
"format": "date-time",
"type": "string"
},
"id": {
"description": "Face cluster ID",
"type": "string"
},
"ownerId": {
"description": "Owner ID",
"type": "string"
},
"updatedAt": {
"description": "Updated at",
"format": "date-time",
"type": "string"
}
},
"required": [
"createdAt",
"id",
"ownerId",
"updatedAt"
],
"type": "object"
},
"SyncMemoryAssetDeleteV1": { "SyncMemoryAssetDeleteV1": {
"properties": { "properties": {
"assetId": { "assetId": {
@@ -23602,6 +23711,75 @@
], ],
"type": "object" "type": "object"
}, },
"SyncPersonV2": {
"properties": {
"birthDate": {
"description": "Birth date",
"format": "date-time",
"nullable": true,
"type": "string"
},
"color": {
"description": "Color",
"nullable": true,
"type": "string"
},
"createdAt": {
"description": "Created at",
"format": "date-time",
"type": "string"
},
"faceAssetId": {
"description": "Face asset ID",
"nullable": true,
"type": "string"
},
"faceClusterId": {
"description": "Face cluster ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Person ID",
"type": "string"
},
"isFavorite": {
"description": "Is favorite",
"type": "boolean"
},
"isHidden": {
"description": "Is hidden",
"type": "boolean"
},
"name": {
"description": "Person name",
"type": "string"
},
"ownerId": {
"description": "Owner ID",
"type": "string"
},
"updatedAt": {
"description": "Updated at",
"format": "date-time",
"type": "string"
}
},
"required": [
"birthDate",
"color",
"createdAt",
"faceAssetId",
"faceClusterId",
"id",
"isFavorite",
"isHidden",
"name",
"ownerId",
"updatedAt"
],
"type": "object"
},
"SyncRequestType": { "SyncRequestType": {
"description": "Sync request types", "description": "Sync request types",
"enum": [ "enum": [
@@ -23624,8 +23802,11 @@
"StacksV1", "StacksV1",
"UsersV1", "UsersV1",
"PeopleV1", "PeopleV1",
"PeopleV2",
"AssetFacesV1", "AssetFacesV1",
"AssetFacesV2", "AssetFacesV2",
"AssetFacesV3",
"FaceClusterV1",
"UserMetadataV1" "UserMetadataV1"
], ],
"type": "string" "type": "string"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.6.1", "version": "2.6.2",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 2.6.1 * 2.6.2
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-monorepo", "name": "immich-monorepo",
"version": "2.6.1", "version": "2.6.2",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
+529 -252
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -52,7 +52,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/ COPY ./plugins/mise.toml ./plugins/
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "2.6.1", "version": "2.6.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+2 -2
View File
@@ -19,7 +19,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
AssetFaceUpdateDto, AssetFaceUpdateDto,
MergePersonDto, MergeFaceClusterDto,
PeopleResponseDto, PeopleResponseDto,
PeopleUpdateDto, PeopleUpdateDto,
PersonCreateDto, PersonCreateDto,
@@ -182,7 +182,7 @@ export class PersonController {
mergePerson( mergePerson(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Body() dto: MergePersonDto, @Body() dto: MergeFaceClusterDto,
): Promise<BulkIdResponseDto[]> { ): Promise<BulkIdResponseDto[]> {
return this.service.mergePerson(auth, id, dto); return this.service.mergePerson(auth, id, dto);
} }
+1 -1
View File
@@ -269,7 +269,7 @@ export type AssetFace = {
boundingBoxY2: number; boundingBoxY2: number;
imageHeight: number; imageHeight: number;
imageWidth: number; imageWidth: number;
personId: string | null; faceClusterId: string | null;
sourceType: SourceType; sourceType: SourceType;
person?: ShallowDehydrateObject<Person> | null; person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date; updatedAt: Date;
+2 -2
View File
@@ -67,8 +67,8 @@ export class PeopleUpdateItem extends PersonUpdateDto {
id!: string; id!: string;
} }
export class MergePersonDto { export class MergeFaceClusterDto {
@ValidateUUID({ each: true, description: 'Person IDs to merge' }) @ValidateUUID({ each: true, description: 'Face cluster IDs to merge' })
ids!: string[]; ids!: string[];
} }
+68
View File
@@ -411,6 +411,18 @@ export class SyncPersonV1 {
faceAssetId!: string | null; faceAssetId!: string | null;
} }
@ExtraModel()
export class SyncPersonV2 extends SyncPersonV1 {
@ApiProperty({ description: 'Face cluster ID' })
faceClusterId!: string | null;
}
export function syncPersonV2ToV1(personV2: SyncPersonV2): SyncPersonV1 {
const { faceClusterId: _, ...personV1 } = personV2;
return personV1;
}
@ExtraModel() @ExtraModel()
export class SyncPersonDeleteV1 { export class SyncPersonDeleteV1 {
@ApiProperty({ description: 'Person ID' }) @ApiProperty({ description: 'Person ID' })
@@ -449,6 +461,40 @@ export class SyncAssetFaceV2 extends SyncAssetFaceV1 {
isVisible!: boolean; isVisible!: boolean;
} }
@ExtraModel()
export class SyncAssetFaceV3 {
@ApiProperty({ description: 'Asset face ID' })
id!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Face cluster ID' })
faceClusterId!: string | null;
@ApiProperty({ type: 'integer' })
imageWidth!: number;
@ApiProperty({ type: 'integer' })
imageHeight!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX2!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY2!: number;
@ApiProperty({ description: 'Source type' })
sourceType!: string;
@ApiProperty({ description: 'Face deleted at' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Is the face visible in the asset' })
isVisible!: boolean;
}
export function syncAssetFaceV3ToV2(faceV3: SyncAssetFaceV3, personId: string | null): SyncAssetFaceV2 {
const { faceClusterId: _, ...face } = faceV3;
return { ...face, personId };
}
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 { export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2; const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
@@ -461,6 +507,24 @@ export class SyncAssetFaceDeleteV1 {
assetFaceId!: string; assetFaceId!: string;
} }
@ExtraModel()
export class SyncFaceClusterV1 {
@ApiProperty({ description: 'Face cluster ID' })
id!: string;
@ApiProperty({ description: 'Created at' })
createdAt!: Date;
@ApiProperty({ description: 'Updated at' })
updatedAt!: Date;
@ApiProperty({ description: 'Owner ID' })
ownerId!: string;
}
@ExtraModel()
export class SyncFaceClusterDeleteV1 {
@ApiProperty({ description: 'Face cluster ID' })
faceClusterId!: string;
}
@ExtraModel() @ExtraModel()
export class SyncUserMetadataV1 { export class SyncUserMetadataV1 {
@ApiProperty({ description: 'User ID' }) @ApiProperty({ description: 'User ID' })
@@ -530,10 +594,14 @@ export type SyncItem = {
[SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1;
[SyncEntityType.PartnerStackV1]: SyncStackV1; [SyncEntityType.PartnerStackV1]: SyncStackV1;
[SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonV1]: SyncPersonV1;
[SyncEntityType.PersonV2]: SyncPersonV2;
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2; [SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
[SyncEntityType.AssetFaceV3]: SyncAssetFaceV3;
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
[SyncEntityType.FaceClusterV1]: SyncFaceClusterV1;
[SyncEntityType.FaceClusterDeleteV1]: SyncFaceClusterDeleteV1;
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1; [SyncEntityType.SyncAckV1]: SyncAckV1;
+8
View File
@@ -735,8 +735,11 @@ export enum SyncRequestType {
StacksV1 = 'StacksV1', StacksV1 = 'StacksV1',
UsersV1 = 'UsersV1', UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1', PeopleV1 = 'PeopleV1',
PeopleV2 = 'PeopleV2',
AssetFacesV1 = 'AssetFacesV1', AssetFacesV1 = 'AssetFacesV1',
AssetFacesV2 = 'AssetFacesV2', AssetFacesV2 = 'AssetFacesV2',
AssetFacesV3 = 'AssetFacesV3',
FaceClusterV1 = 'FaceClusterV1',
UserMetadataV1 = 'UserMetadataV1', UserMetadataV1 = 'UserMetadataV1',
} }
@@ -794,12 +797,17 @@ export enum SyncEntityType {
StackDeleteV1 = 'StackDeleteV1', StackDeleteV1 = 'StackDeleteV1',
PersonV1 = 'PersonV1', PersonV1 = 'PersonV1',
PersonV2 = 'PersonV2',
PersonDeleteV1 = 'PersonDeleteV1', PersonDeleteV1 = 'PersonDeleteV1',
AssetFaceV1 = 'AssetFaceV1', AssetFaceV1 = 'AssetFaceV1',
AssetFaceV2 = 'AssetFaceV2', AssetFaceV2 = 'AssetFaceV2',
AssetFaceV3 = 'AssetFaceV3',
AssetFaceDeleteV1 = 'AssetFaceDeleteV1', AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
FaceClusterV1 = 'FaceClusterV1',
FaceClusterDeleteV1 = 'FaceClusterDeleteV1',
UserMetadataV1 = 'UserMetadataV1', UserMetadataV1 = 'UserMetadataV1',
UserMetadataDeleteV1 = 'UserMetadataDeleteV1', UserMetadataDeleteV1 = 'UserMetadataDeleteV1',
+35 -23
View File
@@ -33,9 +33,9 @@ export interface AssetFaceId {
} }
export interface UpdateFacesData { export interface UpdateFacesData {
oldPersonId?: string; oldFaceClusterId?: string;
faceIds?: string[]; faceIds?: string[];
newPersonId: string; newFaceClusterId: string;
} }
export interface PersonStatistics { export interface PersonStatistics {
@@ -54,7 +54,7 @@ export interface GetAllPeopleOptions {
} }
export interface GetAllFacesOptions { export interface GetAllFacesOptions {
personId?: string | null; faceClusterId?: string | null;
assetId?: string; assetId?: string;
sourceType?: SourceType; sourceType?: SourceType;
} }
@@ -65,7 +65,7 @@ export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => { const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom( return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'), eb.selectFrom('person').selectAll('person').whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId'),
).as('person'); ).as('person');
}; };
@@ -80,11 +80,11 @@ export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> { async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise<number> {
const result = await this.db const result = await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ personId: newPersonId }) .set({ faceClusterId: newFaceClusterId })
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!)) .$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!)) .$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.executeTakeFirst(); .executeTakeFirst();
@@ -94,7 +94,7 @@ export class PersonRepository {
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ personId: null }) .set({ faceClusterId: null })
.where('asset_face.sourceType', '=', sourceType) .where('asset_face.sourceType', '=', sourceType)
.execute(); .execute();
} }
@@ -117,8 +117,8 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null)) .$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null))
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!)) .$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
@@ -153,7 +153,7 @@ export class PersonRepository {
const items = await this.db const items = await this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.innerJoin('asset_face', 'asset_face.personId', 'person.id') .innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.innerJoin('asset', (join) => .innerJoin('asset', (join) =>
join join
.onRef('asset_face.assetId', '=', 'asset.id') .onRef('asset_face.assetId', '=', 'asset.id')
@@ -209,7 +209,7 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.leftJoin('asset_face', 'asset_face.personId', 'person.id') .leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
@@ -248,7 +248,7 @@ export class PersonRepository {
getFaceForFacialRecognitionJob(id: string) { getFaceForFacialRecognitionJob(id: string) {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType']) .select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType'])
.select((eb) => .select((eb) =>
jsonObjectFrom( jsonObjectFrom(
eb eb
@@ -297,10 +297,10 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> { async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise<number> {
const result = await this.db const result = await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ personId: newPersonId }) .set({ faceClusterId: newFaceClusterId })
.where('asset_face.id', '=', assetFaceId) .where('asset_face.id', '=', assetFaceId)
.executeTakeFirst(); .executeTakeFirst();
@@ -346,13 +346,13 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> { async getStatistics(faceClusterId: string): Promise<PersonStatistics> {
const result = await this.db const result = await this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.leftJoin('asset', (join) => .leftJoin('asset', (join) =>
join join
.onRef('asset.id', '=', 'asset_face.assetId') .onRef('asset.id', '=', 'asset_face.assetId')
.on('asset_face.personId', '=', personId) .on('asset_face.faceClusterId', '=', faceClusterId)
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null), .on('asset.deletedAt', 'is', null),
) )
@@ -375,7 +375,7 @@ export class PersonRepository {
eb.exists((eb) => eb.exists((eb) =>
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.whereRef('asset_face.personId', '=', 'person.id') .whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId')
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true) .where('asset_face.isVisible', '=', true)
.where((eb) => .where((eb) =>
@@ -395,7 +395,16 @@ export class PersonRepository {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
create(person: Insertable<PersonTable>) { async create(person: Insertable<PersonTable>) {
if (!person.faceClusterId) {
const { id } = await this.db
.insertInto('face_cluster')
.values({ ownerId: person.ownerId })
.returning('id')
.executeTakeFirstOrThrow();
person.faceClusterId = id;
}
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
} }
@@ -486,18 +495,19 @@ export class PersonRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.select(withPerson) .select(withPerson)
.innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId'))
.where('person.id', 'in', personIds)
.where('asset_face.assetId', 'in', assetIds) .where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getRandomFace(personId: string) { getRandomFace(faceClusterId: string) {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.where('asset_face.personId', '=', personId) .where('asset_face.faceClusterId', '=', faceClusterId)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.executeTakeFirst(); .executeTakeFirst();
@@ -584,7 +594,9 @@ export class PersonRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.select('asset_face.id') .select('asset_face.id')
.where('asset_face.assetId', '=', assetId) .where('asset_face.assetId', '=', assetId)
.where('asset_face.personId', '=', personId) .innerJoin('person', (join) =>
join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId),
)
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
.executeTakeFirst(); .executeTakeFirst();
} }
+3 -3
View File
@@ -338,15 +338,15 @@ export class SearchRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.select([ .select([
'asset_face.id', 'asset_face.id',
'asset_face.personId', 'asset_face.faceClusterId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'), sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
]) ])
.innerJoin('asset', 'asset.id', 'asset_face.assetId') .innerJoin('asset', 'asset.id', 'asset_face.assetId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id') .innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.leftJoin('person', 'person.id', 'asset_face.personId') .leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('asset.ownerId', '=', anyUuid(userIds)) .where('asset.ownerId', '=', anyUuid(userIds))
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null)) .$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null))
.$if(!!minBirthDate, (qb) => .$if(!!minBirthDate, (qb) =>
qb.where((eb) => qb.where((eb) =>
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
+35 -1
View File
@@ -57,6 +57,7 @@ export class SyncRepository {
assetFace: AssetFaceSync; assetFace: AssetFaceSync;
assetMetadata: AssetMetadataSync; assetMetadata: AssetMetadataSync;
authUser: AuthUserSync; authUser: AuthUserSync;
faceCluster: FaceClusterSync;
memory: MemorySync; memory: MemorySync;
memoryToAsset: MemoryToAssetSync; memoryToAsset: MemoryToAssetSync;
partner: PartnerSync; partner: PartnerSync;
@@ -80,6 +81,7 @@ export class SyncRepository {
this.assetFace = new AssetFaceSync(this.db); this.assetFace = new AssetFaceSync(this.db);
this.assetMetadata = new AssetMetadataSync(this.db); this.assetMetadata = new AssetMetadataSync(this.db);
this.authUser = new AuthUserSync(this.db); this.authUser = new AuthUserSync(this.db);
this.faceCluster = new FaceClusterSync(this.db);
this.memory = new MemorySync(this.db); this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db);
this.partner = new PartnerSync(this.db); this.partner = new PartnerSync(this.db);
@@ -447,6 +449,7 @@ class PersonSync extends BaseSync {
'color', 'color',
'updateId', 'updateId',
'faceAssetId', 'faceAssetId',
'faceClusterId',
]) ])
.where('ownerId', '=', options.userId) .where('ownerId', '=', options.userId)
.stream(); .stream();
@@ -473,7 +476,7 @@ class AssetFaceSync extends BaseSync {
.select([ .select([
'asset_face.id', 'asset_face.id',
'assetId', 'assetId',
'personId', 'faceClusterId',
'imageWidth', 'imageWidth',
'imageHeight', 'imageHeight',
'boundingBoxX1', 'boundingBoxX1',
@@ -485,6 +488,8 @@ class AssetFaceSync extends BaseSync {
'asset_face.deletedAt', 'asset_face.deletedAt',
'asset_face.updateId', 'asset_face.updateId',
]) ])
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.select('person.id as personId')
.leftJoin('asset', 'asset.id', 'asset_face.assetId') .leftJoin('asset', 'asset.id', 'asset_face.assetId')
.where('asset.ownerId', '=', options.userId) .where('asset.ownerId', '=', options.userId)
.where('asset_face.isVisible', '=', true) .where('asset_face.isVisible', '=', true)
@@ -492,6 +497,35 @@ class AssetFaceSync extends BaseSync {
} }
} }
class FaceClusterSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getDeletes(options: SyncQueryOptions) {
return this.auditQuery('face_cluster_audit', options)
.select(['face_cluster_audit.id', 'face_cluster_audit.faceClusterId'])
.leftJoin('face_cluster', 'face_cluster.id', 'face_cluster_audit.id')
.where('face_cluster.ownerId', '=', options.userId)
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('face_cluster_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('face_cluster', options)
.select([
'face_cluster.id',
'face_cluster.createdAt',
'face_cluster.updatedAt',
'face_cluster.ownerId',
'face_cluster.updateId',
])
.where('face_cluster.ownerId', '=', options.userId)
.stream();
}
}
class AssetExifSync extends BaseSync { class AssetExifSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true }) @GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) { getUpserts(options: SyncQueryOptions) {
+13
View File
@@ -299,3 +299,16 @@ export const asset_edit_audit = registerFunction({
RETURN NULL; RETURN NULL;
END`, END`,
}); });
export const face_cluster_delete_audit = registerFunction({
name: 'face_cluster_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO face_cluster_audit ("faceClusterId", "ownerId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});
+4
View File
@@ -41,6 +41,8 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table'; import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceClusterAuditTable } from 'src/schema/tables/face-cluster-audit.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table'; import { LibraryTable } from 'src/schema/tables/library.table';
@@ -199,6 +201,8 @@ export interface DB {
audit: AuditTable; audit: AuditTable;
face_cluster: FaceClusterTable;
face_cluster_audit: FaceClusterAuditTable;
face_search: FaceSearchTable; face_search: FaceSearchTable;
geodata_places: GeodataPlacesTable; geodata_places: GeodataPlacesTable;
+8 -8
View File
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums'; import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions'; import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table'; import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
@Table({ name: 'asset_face' }) @Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt') @UpdatedAtTrigger('asset_face_updatedAt')
@@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table';
when: 'pg_trigger_depth() = 0', when: 'pg_trigger_depth() = 0',
}) })
// schemaFromDatabase does not preserve column order // schemaFromDatabase does not preserve column order
@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) @Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] })
@Index({ @Index({
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx',
columns: ['personId', 'assetId'], columns: ['faceClusterId', 'assetId'],
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
}) })
@Index({ columns: ['personId', 'assetId'] }) @Index({ columns: ['faceClusterId', 'assetId'] })
export class AssetFaceTable { export class AssetFaceTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: Generated<string>; id!: Generated<string>;
@@ -45,14 +45,14 @@ export class AssetFaceTable {
}) })
assetId!: string; assetId!: string;
@ForeignKeyColumn(() => PersonTable, { @ForeignKeyColumn(() => FaceClusterTable, {
onDelete: 'SET NULL', onDelete: 'SET NULL',
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
nullable: true, nullable: true,
// [personId, assetId] makes this redundant // [faceClusterId, assetId] makes this redundant
index: false, index: false,
}) })
personId!: string | null; faceClusterId!: string | null;
@Column({ default: 0, type: 'integer' }) @Column({ default: 0, type: 'integer' })
imageWidth!: Generated<number>; imageWidth!: Generated<number>;
@@ -0,0 +1,17 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
@Table('face_cluster_audit')
export class FaceClusterAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
faceClusterId!: string;
@Column({ type: 'uuid', index: true })
ownerId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}
@@ -0,0 +1,38 @@
import {
AfterDeleteTrigger,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { face_cluster_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
@Table('face_cluster')
@UpdatedAtTrigger('face_cluster_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: face_cluster_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class FaceClusterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
}
+4
View File
@@ -13,6 +13,7 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions'; import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@Table('person') @Table('person')
@@ -60,4 +61,7 @@ export class PersonTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
faceClusterId!: string | null;
} }
+1 -1
View File
@@ -876,7 +876,7 @@ export class MetadataService extends BaseService {
} }
if (missing.length > 0) { if (missing.length > 0) {
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); this.logger.debug(`Creating missing people: ${missing.map((p) => `${p.name}/${p.id}`)}`);
const newPersonIds = await this.personRepository.createAll(missing); const newPersonIds = await this.personRepository.createAll(missing);
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const); const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
await this.jobRepository.queueAll(jobs); await this.jobRepository.queueAll(jobs);
+21 -15
View File
@@ -13,7 +13,7 @@ import {
FaceDto, FaceDto,
mapFaces, mapFaces,
mapPerson, mapPerson,
MergePersonDto, MergeFaceClusterDto,
PeopleResponseDto, PeopleResponseDto,
PeopleUpdateDto, PeopleUpdateDto,
PersonCreateDto, PersonCreateDto,
@@ -438,7 +438,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString(); const lastRun = new Date().toISOString();
const facePagination = this.personRepository.getAllFaces( const facePagination = this.personRepository.getAllFaces(
force ? undefined : { personId: null, sourceType: SourceType.MachineLearning }, force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning },
); );
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = []; let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
@@ -481,8 +481,8 @@ export class PersonService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
if (face.personId) { if (face.faceClusterId) {
this.logger.debug(`Face ${id} already has a person assigned`); this.logger.debug(`Face ${id} already belongs to a face cluster`);
return JobStatus.Skipped; return JobStatus.Skipped;
} }
@@ -511,8 +511,8 @@ export class PersonService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
let personId = matches.find((match) => match.personId)?.personId; let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId;
if (!personId) { if (!faceClusterId) {
const matchWithPerson = await this.searchRepository.searchFaces({ const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId], userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding, embedding: face.faceSearch.embedding,
@@ -523,20 +523,20 @@ export class PersonService extends BaseService {
}); });
if (matchWithPerson.length > 0) { if (matchWithPerson.length > 0) {
personId = matchWithPerson[0].personId; faceClusterId = matchWithPerson[0].faceClusterId;
} }
} }
if (isCore && !personId) { if (isCore && !faceClusterId) {
this.logger.log(`Creating new person for face ${id}`); this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
personId = newPerson.id; faceClusterId = newPerson.faceClusterId;
} }
if (personId) { if (faceClusterId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`); this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
} }
return JobStatus.Success; return JobStatus.Success;
@@ -554,7 +554,7 @@ export class PersonService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> { async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids; const mergeIds = dto.ids;
if (mergeIds.includes(id)) { if (mergeIds.includes(id)) {
throw new BadRequestException('Cannot merge a person into themselves'); throw new BadRequestException('Cannot merge a person into themselves');
@@ -600,7 +600,7 @@ export class PersonService extends BaseService {
} }
const mergeName = mergePerson.name || mergePerson.id; const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`); this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData); await this.personRepository.reassignFaces(mergeData);
@@ -678,8 +678,14 @@ export class PersonService extends BaseService {
dto.imageHeight = originalDimensions.height; dto.imageHeight = originalDimensions.height;
} }
const person = await this.personRepository.getById(dto.personId);
if (!person?.faceClusterId) {
throw new Error('Person must already have some recognized faces and belong to a face cluster');
}
await this.personRepository.createAssetFace({ await this.personRepository.createAssetFace({
personId: dto.personId, faceClusterId: person.faceClusterId,
assetId: dto.assetId, assetId: dto.assetId,
imageHeight: dto.imageHeight, imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth, imageWidth: dto.imageWidth,
+49 -2
View File
@@ -13,8 +13,10 @@ import {
SyncAckDeleteDto, SyncAckDeleteDto,
SyncAckSetDto, SyncAckSetDto,
syncAssetFaceV2ToV1, syncAssetFaceV2ToV1,
syncAssetFaceV3ToV2,
SyncAssetV1, SyncAssetV1,
SyncItem, SyncItem,
syncPersonV2ToV1,
SyncStreamDto, SyncStreamDto,
} from 'src/dtos/sync.dto'; } from 'src/dtos/sync.dto';
import { import {
@@ -192,8 +194,11 @@ export class SyncService extends BaseService {
[SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap), [SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap),
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id),
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
[SyncRequestType.PeopleV2]: () => this.syncPeopleV2(options, response, checkpointMap),
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap), [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap), [SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
[SyncRequestType.AssetFacesV3]: async () => this.syncAssetFacesV3(options, response, checkpointMap),
[SyncRequestType.FaceClusterV1]: async () => this.syncFaceClusterV1(options, response, checkpointMap),
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
}; };
@@ -796,6 +801,20 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.PersonV1; const upsertType = SyncEntityType.PersonV1;
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: syncPersonV2ToV1(data) });
}
}
private async syncPeopleV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.PersonDeleteV1;
const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.PersonV2;
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) { for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data }); send(response, { type: upsertType, ids: [updateId], data });
} }
@@ -810,8 +829,8 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.AssetFaceV1; const upsertType = SyncEntityType.AssetFaceV1;
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) { for await (const { updateId, personId, ...data } of upserts) {
const v1 = syncAssetFaceV2ToV1(data); const v1 = syncAssetFaceV2ToV1(syncAssetFaceV3ToV2(data, personId));
send(response, { type: upsertType, ids: [updateId], data: v1 }); send(response, { type: upsertType, ids: [updateId], data: v1 });
} }
} }
@@ -825,6 +844,34 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.AssetFaceV2; const upsertType = SyncEntityType.AssetFaceV2;
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, personId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: syncAssetFaceV3ToV2(data, personId) });
}
}
private async syncAssetFacesV3(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.AssetFaceDeleteV1;
const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AssetFaceV3;
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, personId: _, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncFaceClusterV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.FaceClusterDeleteV1;
const deletes = this.syncRepository.faceCluster.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.FaceClusterV1;
const upserts = this.syncRepository.faceCluster.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) { for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data }); send(response, { type: upsertType, ids: [updateId], data });
} }
+14 -2
View File
@@ -190,7 +190,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
} }
case Permission.AlbumUpdate: { case Permission.AlbumUpdate: {
return await access.album.checkOwnerAccess(auth.user.id, ids); const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.album.checkSharedAlbumAccess(
auth.user.id,
setDifference(ids, isOwner),
AlbumUserRole.Editor,
);
return setUnion(isOwner, isShared);
} }
case Permission.AlbumDelete: { case Permission.AlbumDelete: {
@@ -198,7 +204,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
} }
case Permission.AlbumShare: { case Permission.AlbumShare: {
return await access.album.checkOwnerAccess(auth.user.id, ids); const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.album.checkSharedAlbumAccess(
auth.user.id,
setDifference(ids, isOwner),
AlbumUserRole.Editor,
);
return setUnion(isOwner, isShared);
} }
case Permission.AlbumDownload: { case Permission.AlbumDownload: {
+9 -3
View File
@@ -144,7 +144,12 @@ export function withFacesAndPeople(
.selectFrom('asset_face') .selectFrom('asset_face')
.leftJoinLateral( .leftJoinLateral(
(eb) => (eb) =>
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'), eb
.selectFrom('face_cluster')
.where('face_cluster.id', '=', 'asset_face.faceClusterId')
.innerJoin('person', 'person.faceClusterId', 'face_cluster.id')
.selectAll('person')
.as('person'),
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.selectAll('asset_face') .selectAll('asset_face')
@@ -161,11 +166,12 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds:
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.select('assetId') .select('assetId')
.where('personId', '=', anyUuid(personIds!)) .innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('person.id', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('isVisible', 'is', true) .where('isVisible', 'is', true)
.groupBy('assetId') .groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length)
.as('has_people'), .as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'asset.id'), (join) => join.onRef('has_people.assetId', '=', 'asset.id'),
); );
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "2.6.1", "version": "2.6.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -72,10 +72,10 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0", "@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.10.0", "@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.27.1", "@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.2.4", "@sveltejs/vite-plugin-svelte": "7.0.0",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8", "@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
@@ -103,10 +103,10 @@
"svelte": "5.53.13", "svelte": "5.53.13",
"svelte-check": "^4.1.5", "svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3", "svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.2.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
"vite": "^7.1.2", "vite": "^8.0.0",
"vitest": "^4.0.0" "vitest": "^4.0.0"
}, },
"volta": { "volta": {
+4 -1
View File
@@ -28,7 +28,10 @@
let { onClose }: Props = $props(); let { onClose }: Props = $props();
onMount(async () => { onMount(async () => {
albums = await getAllAlbums({}); // TODO the server should *really* just return all albums (paginated ideally)
const ownedAlbums = await getAllAlbums({ shared: false });
ownedAlbums.push.apply(ownedAlbums, await getAllAlbums({ shared: true }));
albums = ownedAlbums;
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3); recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
loading = false; loading = false;
}); });
+6 -12
View File
@@ -8,17 +8,16 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store'; import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types'; import type { AssetControlContext } from '$lib/types';
import { getSharedLink, sleep } from '$lib/utils'; import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils'; import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import { import {
AssetJobName, AssetJobName,
AssetMediaSize,
AssetTypeEnum, AssetTypeEnum,
AssetVisibility, AssetVisibility,
getAssetInfo, getAssetInfo,
getBaseUrl,
runAssetJobs, runAssetJobs,
updateAsset, updateAsset,
type AssetJobsDto, type AssetJobsDto,
@@ -308,6 +307,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
{ {
filename: asset.originalFileName, filename: asset.originalFileName,
id: asset.id, id: asset.id,
cacheKey: asset.thumbhash,
}, },
]; ];
@@ -321,13 +321,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
assets.push({ assets.push({
filename: motionAsset.originalFileName, filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId, id: asset.livePhotoVideoId,
cacheKey: motionAsset.thumbhash,
}); });
} }
} }
const queryParams = asQueryString(authManager.params); for (const [i, { filename, id, cacheKey }] of assets.entries()) {
for (const [i, { filename, id }] of assets.entries()) {
if (i !== 0) { if (i !== 0) {
// play nice with Safari // play nice with Safari
await sleep(500); await sleep(500);
@@ -335,12 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
try { try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } })); toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl( downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
getBaseUrl() +
`/assets/${id}/original` +
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
filename,
);
} catch (error) { } catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } })); handleError(error, $t('errors.error_downloading', { values: { filename } }));
} }
+28 -1
View File
@@ -80,7 +80,34 @@ function createUploadStore() {
}; };
const removeItem = (id: string) => { const removeItem = (id: string) => {
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id)); uploadAssets.update((uploadingAsset) => {
const assetToRemove = uploadingAsset.find((a) => a.id === id);
if (assetToRemove) {
stats.update((stats) => {
switch (assetToRemove.state) {
case UploadState.DONE: {
stats.success--;
break;
}
case UploadState.DUPLICATED: {
stats.duplicates--;
break;
}
case UploadState.ERROR: {
stats.errors--;
break;
}
}
stats.total--;
return stats;
});
}
return uploadingAsset.filter((a) => a.id != id);
});
}; };
const dismissErrors = () => const dismissErrors = () =>
@@ -476,13 +476,6 @@
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeDescription menuItem /> <ChangeDescription menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
{#if assetInteraction.selectedAssets.length === 1}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
<ArchiveAction <ArchiveAction
menuItem menuItem
unarchive={assetInteraction.isAllArchived} unarchive={assetInteraction.isAllArchived}
@@ -490,6 +483,13 @@
/> />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} /> <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if} {/if}
{#if assetInteraction.selectedAssets.length === 1}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem /> <TagAction menuItem />
@@ -178,19 +178,7 @@
const handleFirst = () => navigateToIndex(0); const handleFirst = () => navigateToIndex(0);
const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0)); const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0));
const handlePreviousShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handlePrevious();
};
const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1)); const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1));
const handleNextShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handleNext();
};
const handleLast = () => navigateToIndex(duplicates.length - 1); const handleLast = () => navigateToIndex(duplicates.length - 1);
const navigateToIndex = async (index: number) => const navigateToIndex = async (index: number) =>
@@ -198,10 +186,12 @@
</script> </script>
<svelte:document <svelte:document
use:shortcuts={[ use:shortcuts={$showAssetViewer
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut }, ? []
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut }, : [
]} { shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
]}
/> />
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}> <UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>