Compare commits

..

28 Commits

Author SHA1 Message Date
Jonathan Jogenfors d21c89c0ed fix: don't delete offline library files from disk 2026-03-24 23:39:54 +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
Daniel Dietzler 5a8fd40dc5 fix: svelte reactivity issues (#27109) 2026-03-20 15:56:16 -04:00
Jason Rasmussen 813d684aaa fix: shared link add to album (#27063) 2026-03-20 13:14:07 -05:00
Luis Nachtigall 644f705be1 fix(mobile): correct maximumSizeBytes setter to use maximumSizeBytes property (#27098)
fix: correct maximumSizeBytes setter to use maximumSizeBytes property
2026-03-20 12:22:59 -04:00
Thomas f3e4bcc733 fix(server): queue version check job when config changed (#27094)
Nothing happens when the version checks are enabled, which means the
server may return very stale versions until the next job runs.

Refs: #26935
2026-03-20 10:07:40 -05:00
Michel Heusschen 9a0c17fdb8 fix(web): preserve album scroll when adding to other albums (#27078) 2026-03-20 09:42:19 -05:00
Michel Heusschen b7c4497dfd fix(web): allow showing combobox items outside modals (#27075)
fix(web): allow showing combobox items outside of modals
2026-03-20 09:41:34 -05:00
Yaros 9c227aeaf5 fix(mobile): simplified chinese not available (#27066) 2026-03-20 00:27:50 -05:00
85 changed files with 1510 additions and 746 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',
}),
);
}); });
}); });
@@ -859,6 +859,48 @@ describe('/libraries', () => {
]); ]);
}); });
it('should not delete excluded external files from disk when trash is emptied', async () => {
const relativePath = 'temp/offline-empty-trash/offline.png';
const filePath = `${testAssetDir}/${relativePath}`;
const internalFilePath = `${testAssetDirInternal}/${relativePath}`;
utils.createImageFile(filePath);
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/offline-empty-trash`],
});
await utils.scan(admin.accessToken, library.id);
const { assets } = await utils.searchAssets(admin.accessToken, {
libraryId: library.id,
originalPath: internalFilePath,
});
expect(assets.count).toBe(1);
await utils.updateLibrary(admin.accessToken, library.id, {
exclusionPatterns: ['**/offline-empty-trash/**'],
});
await utils.scan(admin.accessToken, library.id);
const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
expect(trashedAsset.isTrashed).toBe(true);
expect(trashedAsset.isOffline).toBe(true);
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
expect(existsSync(filePath)).toBe(true);
if (existsSync(filePath)) {
utils.removeImageFile(filePath);
}
});
it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => { it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
+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>
+1 -1
View File
@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
'Arabic (ar)': Locale('ar'), 'Arabic (ar)': Locale('ar'),
'Bulgarian (bg)': Locale('bg'), 'Bulgarian (bg)': Locale('bg'),
'Catalan (ca)': Locale('ca'), 'Catalan (ca)': Locale('ca'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'), 'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), 'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
'Croatian (hr)': Locale('hr'), 'Croatian (hr)': Locale('hr'),
'Czech (cs)': Locale('cs'), 'Czech (cs)': Locale('cs'),
@@ -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;
@@ -9,7 +9,11 @@ import 'package:flutter/painting.dart';
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method /// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter { class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
void Function()? _onLastListenerRemoved; void Function()? _onLastListenerRemoved;
ImageStreamListener? _cacheListener; int _listenerCount = 0;
// True once any image or the codec has been provided.
// Until then the image cache holds one listener, so "last real listener gone"
// is _listenerCount == 1, not 0.
bool didProvideImage = false;
AnimatedImageStreamCompleter._({ AnimatedImageStreamCompleter._({
required super.codec, required super.codec,
@@ -34,15 +38,18 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
); );
if (initialImage != null) { if (initialImage != null) {
self.didProvideImage = true;
self.setImage(initialImage); self.setImage(initialImage);
} }
stream.listen( stream.listen(
(item) { (item) {
if (item is ImageInfo) { if (item is ImageInfo) {
self.didProvideImage = true;
self.setImage(item); self.setImage(item);
} else if (item is ui.Codec) { } else if (item is ui.Codec) {
if (!codecCompleter.isCompleted) { if (!codecCompleter.isCompleted) {
self.didProvideImage = true;
codecCompleter.complete(item); codecCompleter.complete(item);
} }
} }
@@ -53,6 +60,8 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
} }
}, },
onDone: () { onDone: () {
// also complete if we are done but no error occurred, and we didn't call complete yet
// could happen on cancellation
if (!codecCompleter.isCompleted) { if (!codecCompleter.isCompleted) {
codecCompleter.completeError(StateError('Stream closed without providing a codec')); codecCompleter.completeError(StateError('Stream closed without providing a codec'));
} }
@@ -64,20 +73,24 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
@override @override
void addListener(ImageStreamListener listener) { void addListener(ImageStreamListener listener) {
_cacheListener ??= listener;
super.addListener(listener); super.addListener(listener);
_listenerCount++;
} }
@override @override
void removeListener(ImageStreamListener listener) { void removeListener(ImageStreamListener listener) {
super.removeListener(listener); super.removeListener(listener);
if (listener != _cacheListener) { _listenerCount--;
_cancel();
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
if (onlyCacheListenerLeft || noListenersAfterCodec) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
} }
} }
void _cancel() {
_onLastListenerRemoved?.call();
_onLastListenerRemoved = null;
}
} }
@@ -10,7 +10,9 @@ import 'package:flutter/painting.dart';
/// An ImageStreamCompleter with support for loading multiple images. /// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onLastListenerRemoved; void Function()? _onLastListenerRemoved;
ImageStreamListener? _cacheListener; int _listenerCount = 0;
// True once setImage() has been called at least once.
bool didProvideImage = false;
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load). /// should be the primary images to display (typically asynchronously as they load).
@@ -21,12 +23,17 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
ImageInfo? initialImage, ImageInfo? initialImage,
InformationCollector? informationCollector, InformationCollector? informationCollector,
void Function()? onLastListenerRemoved, void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved { }) {
if (initialImage != null) { if (initialImage != null) {
didProvideImage = true;
setImage(initialImage); setImage(initialImage);
} }
_onLastListenerRemoved = onLastListenerRemoved;
images.listen( images.listen(
(image) => setImage(image), (image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) { onError: (Object error, StackTrace stack) {
reportError( reportError(
context: ErrorDescription('resolving a single-frame image stream'), context: ErrorDescription('resolving a single-frame image stream'),
@@ -41,20 +48,23 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
@override @override
void addListener(ImageStreamListener listener) { void addListener(ImageStreamListener listener) {
_cacheListener ??= listener;
super.addListener(listener); super.addListener(listener);
_listenerCount = _listenerCount + 1;
} }
@override @override
void removeListener(ImageStreamListener listener) { void removeListener(ImageStreamListener listener) {
super.removeListener(listener); super.removeListener(listener);
if (listener != _cacheListener) { _listenerCount = _listenerCount - 1;
_cancel();
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
} }
} }
void _cancel() {
_onLastListenerRemoved?.call();
_onLastListenerRemoved = null;
}
} }
+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 =>
+1 -1
View File
@@ -20,7 +20,7 @@ final class CustomImageCache implements ImageCache {
set maximumSize(int value) => _small.maximumSize = value; set maximumSize(int value) => _small.maximumSize = value;
@override @override
set maximumSizeBytes(int value) => _small.maximumSize = value; set maximumSizeBytes(int value) => _small.maximumSizeBytes = value;
@override @override
void clear() { void clear() {
@@ -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
+4 -4
View File
@@ -1194,10 +1194,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
thumbhash: thumbhash:
dependency: "direct main" dependency: "direct main"
description: description:
+1 -1
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'
@@ -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', () {
+1 -1
View File
@@ -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": [
+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",
+588 -306
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/
+2 -2
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,
@@ -24,7 +24,7 @@
"typeorm": "typeorm", "typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug", "migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", "migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", "migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run", "migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert", "migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'", "schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
+1 -9
View File
@@ -169,6 +169,7 @@ export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;
userId: string; userId: string;
albumId: string | null;
showExif: boolean; showExif: boolean;
allowUpload: boolean; allowUpload: boolean;
allowDownload: boolean; allowDownload: boolean;
@@ -357,15 +358,6 @@ export const columns = {
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'], authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
authSharedLink: [
'shared_link.id',
'shared_link.userId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
],
user: userColumns, user: userColumns,
userWithPrefix: userWithPrefixColumns, userWithPrefix: userWithPrefixColumns,
userAdmin: [ userAdmin: [
@@ -173,6 +173,7 @@ order by
select select
"shared_link"."id", "shared_link"."id",
"shared_link"."userId", "shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt", "shared_link"."expiresAt",
"shared_link"."showExif", "shared_link"."showExif",
"shared_link"."allowUpload", "shared_link"."allowUpload",
@@ -211,6 +212,7 @@ where
select select
"shared_link"."id", "shared_link"."id",
"shared_link"."userId", "shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt", "shared_link"."expiresAt",
"shared_link"."showExif", "shared_link"."showExif",
"shared_link"."allowUpload", "shared_link"."allowUpload",
@@ -330,6 +330,7 @@ export class AlbumRepository {
await db await db
.insertInto('album_asset') .insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId }))) .values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute(); .execute();
} }
@@ -202,7 +202,14 @@ export class SharedLinkRepository {
.leftJoin('album', 'album.id', 'shared_link.albumId') .leftJoin('album', 'album.id', 'shared_link.albumId')
.where('album.deletedAt', 'is', null) .where('album.deletedAt', 'is', null)
.select((eb) => [ .select((eb) => [
...columns.authSharedLink, 'shared_link.id',
'shared_link.userId',
'shared_link.albumId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
jsonObjectFrom( jsonObjectFrom(
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'), eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
).as('user'), ).as('user'),
+2 -2
View File
@@ -7,8 +7,8 @@ import { DB } from 'src/schema';
export class TrashRepository { export class TrashRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
getDeletedIds(): AsyncIterableIterator<{ id: string }> { getDeletedIds(): AsyncIterableIterator<{ id: string; isOffline: boolean }> {
return this.db.selectFrom('asset').select(['id']).where('status', '=', AssetStatus.Deleted).stream(); return this.db.selectFrom('asset').select(['id', 'isOffline']).where('status', '=', AssetStatus.Deleted).stream();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
DELETE FROM "shared_link_asset"
USING "shared_link"
WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM';
`.execute(db);
}
export async function down(): Promise<void> {
// noop
}
+12
View File
@@ -165,6 +165,12 @@ export class AlbumService extends BaseService {
} }
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const album = await this.findOrFail(id, { withAssets: false }); const album = await this.findOrFail(id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
@@ -195,6 +201,12 @@ export class AlbumService extends BaseService {
} }
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> { async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const results: AlbumsAddAssetsResponseDto = { const results: AlbumsAddAssetsResponseDto = {
success: false, success: false,
error: BulkIdErrorReason.DUPLICATE, error: BulkIdErrorReason.DUPLICATE,
+9 -3
View File
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path'; import { extname } from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database'; import { Asset, AuthSharedLink } from 'src/database';
import { import {
AssetBulkUploadCheckResponseDto, AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto, AssetMediaResponseDto,
@@ -152,7 +152,7 @@ export class AssetMediaService extends BaseService {
const asset = await this.create(auth.user.id, dto, file, sidecarFile); const asset = await this.create(auth.user.id, dto, file, sidecarFile);
if (auth.sharedLink) { if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]); await this.addToSharedLink(auth.sharedLink, asset.id);
} }
await this.userRepository.updateUsage(auth.user.id, file.size); await this.userRepository.updateUsage(auth.user.id, file.size);
@@ -326,6 +326,12 @@ export class AssetMediaService extends BaseService {
}; };
} }
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
await (sharedLink.albumId
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
}
private async handleUploadError( private async handleUploadError(
error: any, error: any,
auth: AuthDto, auth: AuthDto,
@@ -347,7 +353,7 @@ export class AssetMediaService extends BaseService {
} }
if (auth.sharedLink) { if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); await this.addToSharedLink(auth.sharedLink, duplicateId);
} }
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
+33 -2
View File
@@ -4,10 +4,19 @@ import { TrashService } from 'src/services/trash.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: string }> { async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: string; isOffline: boolean }> {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
await Promise.resolve(); await Promise.resolve();
yield { id: `asset-${i + 1}` }; yield { id: `asset-${i + 1}`, isOffline: false };
}
}
async function* makeDeletedAssetStream(
assets: Array<{ id: string; isOffline: boolean }>,
): AsyncIterableIterator<{ id: string; isOffline: boolean }> {
for (const asset of assets) {
await Promise.resolve();
yield asset;
} }
} }
@@ -99,5 +108,27 @@ describe(TrashService.name, () => {
}, },
]); ]);
}); });
it('should not delete offline assets on disk', async () => {
mocks.trash.getDeletedIds.mockReturnValue(
makeDeletedAssetStream([
{ id: 'asset-1', isOffline: false },
{ id: 'asset-2', isOffline: true },
]),
);
await expect(sut.handleEmptyTrash()).resolves.toEqual(JobStatus.Success);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetDelete,
data: { id: 'asset-1', deleteOnDisk: true },
},
{
name: JobName.AssetDelete,
data: { id: 'asset-2', deleteOnDisk: false },
},
]);
});
}); });
}); });
+8 -8
View File
@@ -50,9 +50,9 @@ export class TrashService extends BaseService {
const assets = this.trashRepository.getDeletedIds(); const assets = this.trashRepository.getDeletedIds();
let count = 0; let count = 0;
const batch: string[] = []; const batch: Array<{ id: string; isOffline: boolean }> = [];
for await (const { id } of assets) { for await (const asset of assets) {
batch.push(id); batch.push(asset);
if (batch.length === JOBS_ASSET_PAGINATION_SIZE) { if (batch.length === JOBS_ASSET_PAGINATION_SIZE) {
await this.handleBatch(batch); await this.handleBatch(batch);
@@ -70,14 +70,14 @@ export class TrashService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
private async handleBatch(ids: string[]) { private async handleBatch(assets: Array<{ id: string; isOffline: boolean }>) {
this.logger.debug(`Queueing ${ids.length} asset(s) for deletion from the trash`); this.logger.debug(`Queueing ${assets.length} asset(s) for deletion from the trash`);
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
ids.map((assetId) => ({ assets.map(({ id, isOffline }) => ({
name: JobName.AssetDelete, name: JobName.AssetDelete,
data: { data: {
id: assetId, id,
deleteOnDisk: true, deleteOnDisk: !isOffline,
}, },
})), })),
); );
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service'; import { VersionService } from 'src/services/version.service';
@@ -130,6 +131,32 @@ describe(VersionService.name, () => {
}); });
}); });
describe('onConfigUpdate', () => {
it('should queue a version check job when newVersionCheck is enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
});
it('should not queue a version check job when newVersionCheck is disabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
});
describe('onWebsocketConnection', () => { describe('onWebsocketConnection', () => {
it('should send on_server_version client event', async () => { it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' }); await sut.onWebsocketConnection({ userId: '42' });
+7
View File
@@ -55,6 +55,13 @@ export class VersionService extends BaseService {
return this.versionRepository.getAll(); return this.versionRepository.getAll();
} }
@OnEvent({ name: 'ConfigUpdate' })
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) {
if (!oldConfig.newVersionCheck.enabled && newConfig.newVersionCheck.enabled) {
await this.handleQueueVersionCheck();
}
}
async handleQueueVersionCheck() { async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} }); await this.jobRepository.queue({ name: JobName.VersionCheck, 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: {
+1
View File
@@ -48,6 +48,7 @@ export const authStub = {
showExif: true, showExif: true,
allowDownload: true, allowDownload: true,
allowUpload: true, allowUpload: true,
albumId: null,
expiresAt: null, expiresAt: null,
password: null, password: null,
userId: '42', userId: '42',
+2 -2
View File
@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { result }; return { result };
} }
async newAlbum(dto: Insertable<AlbumTable>) { async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
const album = mediumFactory.albumInsert(dto); const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, [], []); const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
return { album, result }; return { album, result };
} }
@@ -1,12 +1,15 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto'; import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetMediaSize } from 'src/dtos/asset-media.dto';
import { AssetFileType } from 'src/enum'; import { AssetFileType, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository'; import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
@@ -22,7 +25,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetMediaService, { return newMediumService(AssetMediaService, {
database: db || defaultDatabase, database: db || defaultDatabase,
real: [AccessRepository, AssetRepository, UserRepository], real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
}); });
}; };
@@ -44,7 +47,6 @@ describe(AssetService.name, () => {
const { asset } = await ctx.newAsset({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } }); const auth = factory.auth({ user: { id: user.id } });
const file = mediumFactory.uploadFile();
await expect( await expect(
sut.uploadAsset( sut.uploadAsset(
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
fileCreatedAt: new Date(), fileCreatedAt: new Date(),
assetData: Buffer.from('some data'), assetData: Buffer.from('some data'),
}, },
file, mediumFactory.uploadFile(),
), ),
).resolves.toEqual({ ).resolves.toEqual({
id: expect.any(String), id: expect.any(String),
@@ -99,6 +101,168 @@ describe(AssetService.name, () => {
status: AssetMediaStatus.CREATED, status: AssetMediaStatus.CREATED,
}); });
}); });
it('should add to a shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const file = mediumFactory.uploadFile();
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, file);
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should handle adding a duplicate asset to a shared link', async () => {
const { sut, ctx } = setup();
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
assetIds: [asset.id],
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should add to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile());
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
it('should handle adding a duplicate asset to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
// await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
}); });
describe('viewThumbnail', () => { describe('viewThumbnail', () => {
+11 -1
View File
@@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
expiresAt = null, expiresAt = null,
userId = newUuid(), userId = newUuid(),
showExif = true, showExif = true,
albumId = null,
allowUpload = false, allowUpload = false,
allowDownload = true, allowDownload = true,
password = null, password = null,
} = sharedLink; } = sharedLink;
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password }; return {
id,
albumId,
expiresAt,
userId,
showExif,
allowUpload,
allowDownload,
password,
};
}; };
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({ const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
+7 -7
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",
@@ -100,13 +100,13 @@
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0", "rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.7", "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": {
@@ -212,12 +212,12 @@
bottom: `${rootHeight - top}px`, bottom: `${rootHeight - top}px`,
left: `${left}px`, left: `${left}px`,
width: `${boundary.width}px`, width: `${boundary.width}px`,
maxHeight: maxHeight(top - dropdownOffset), maxHeight: maxHeight(boundary.top - dropdownOffset),
}; };
} }
const viewportHeight = visualViewport?.height || rootHeight; const viewportHeight = visualViewport?.height || window.innerHeight;
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom; const availableHeight = viewportHeight - boundary.bottom;
return { return {
top: `${bottom}px`, top: `${bottom}px`,
left: `${left}px`, left: `${left}px`,
+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 = () =>
+1 -1
View File
@@ -216,7 +216,7 @@ async function fileUploader({
uploadAssetsStore.track('success'); uploadAssetsStore.track('success');
} }
if (albumId) { if (albumId && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbums([albumId], [responseData.id], { notify: false }); await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
@@ -287,7 +287,11 @@
} }
}; };
const onAlbumAddAssets = async () => { const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[] }) => {
if (!albumIds.includes(album.id)) {
return;
}
await refreshAlbum(); await refreshAlbum();
timelineInteraction.clearMultiselect(); timelineInteraction.clearMultiselect();
await setModeToView(); await setModeToView();
@@ -472,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}
@@ -486,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}>