Compare commits

..

36 Commits

Author SHA1 Message Date
bwees a69374aa17 chore: more cleanup 2026-03-29 20:49:46 -05:00
bwees 15c15bd543 chore: update openapi 2026-03-29 20:42:59 -05:00
bwees 10e754e1aa chore: cleanup 2026-03-29 20:40:51 -05:00
bwees b9282b27e5 fix: await both live photo and regular asset when applying edits 2026-03-29 20:26:21 -05:00
bwees 146a076324 chore: new behavior tests 2026-03-24 23:49:33 -05:00
bwees 4c70afc8f4 chore: resolve tests 2026-03-24 23:39:11 -05:00
bwees 80db413d69 chore: more wip 2026-03-24 16:35:44 -05:00
bwees d8d532e7ca feat: wip 2026-03-24 16:35:43 -05:00
Mert a9666d2cef fix(mobile): remove upload timeout (#27237)
remove timeout
2026-03-24 14:40:48 -04: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
104 changed files with 2364 additions and 922 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 }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
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:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -79,7 +79,7 @@ jobs:
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -103,7 +103,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
~/.gradle/caches
@@ -114,7 +114,7 @@ jobs:
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -153,14 +153,14 @@ jobs:
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
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'
with:
path: |
@@ -185,13 +185,13 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: '3.3'
bundler-cache: true
@@ -291,7 +291,7 @@ jobs:
security delete-keychain build.keychain || true
- name: Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- 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:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/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
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -42,7 +42,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -71,7 +71,7 @@ jobs:
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -83,13 +83,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- 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
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- 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 }}
with:
registry: ghcr.io
@@ -104,7 +104,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
flavor: |
latest=false
@@ -115,7 +115,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
image: ghcr.io/immich-app/mdq:main@sha256:df7188ba88abb0800d73cc97d3633280f0c0c3d4c441d678225067bf154150fb
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
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:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
languages: ${{ matrix.language }}
# 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).
# If this step fails, then you should remove it and run the build manually (see below)
- 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.
# 📚 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
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
category: '/language:${{matrix.language}}'
+6 -6
View File
@@ -23,14 +23,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
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:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
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:
contents: read
actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
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:
contents: read
actions: read
+5 -5
View File
@@ -21,14 +21,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
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:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -54,7 +54,7 @@ jobs:
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -67,7 +67,7 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -86,7 +86,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docs-build-output
path: docs/build/
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
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 }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- 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
id: parameters
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- 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
env:
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -14,13 +14,13 @@ jobs:
pull-requests: write
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- 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:
token: ${{ steps.token.outputs.token }}
mode: exactly
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+6 -6
View File
@@ -50,7 +50,7 @@ jobs:
steps:
- name: Generate a 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -63,10 +63,10 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -124,7 +124,7 @@ jobs:
steps:
- name: Generate a 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -136,13 +136,13 @@ jobs:
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
+5 -5
View File
@@ -14,12 +14,12 @@ jobs:
pull-requests: write
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
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:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -48,14 +48,14 @@ jobs:
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 }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
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 }}
with:
github-token: ${{ steps.token.outputs.token }}
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
working-directory: ./open-api/typescript-sdk
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,7 +30,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- 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
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+4 -4
View File
@@ -20,14 +20,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
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:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
+38 -38
View File
@@ -17,14 +17,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
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:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -63,7 +63,7 @@ jobs:
working-directory: ./server
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,7 +75,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -108,7 +108,7 @@ jobs:
working-directory: ./cli
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -155,7 +155,7 @@ jobs:
working-directory: ./cli
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -166,7 +166,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -197,7 +197,7 @@ jobs:
working-directory: ./web
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -208,7 +208,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -241,7 +241,7 @@ jobs:
working-directory: ./web
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -252,7 +252,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -279,7 +279,7 @@ jobs:
contents: read
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -290,7 +290,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -327,7 +327,7 @@ jobs:
working-directory: ./e2e
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -338,7 +338,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -373,7 +373,7 @@ jobs:
working-directory: ./server
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -385,7 +385,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -412,7 +412,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -424,7 +424,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -464,7 +464,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
@@ -484,7 +484,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -496,7 +496,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -522,7 +522,7 @@ jobs:
run: pnpm test:web
if: ${{ !cancelled() }}
- 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()
with:
name: e2e-web-test-results-${{ matrix.runner }}
@@ -533,7 +533,7 @@ jobs:
run: pnpm test:web:ui
if: ${{ !cancelled() }}
- 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()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
@@ -544,7 +544,7 @@ jobs:
run: pnpm test:web:maintenance
if: ${{ !cancelled() }}
- 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()
with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
@@ -554,7 +554,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
@@ -578,7 +578,7 @@ jobs:
contents: read
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -588,7 +588,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -610,7 +610,7 @@ jobs:
working-directory: ./machine-learning
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: 3.11
- name: Install dependencies
@@ -650,7 +650,7 @@ jobs:
working-directory: ./.github
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -661,7 +661,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -680,7 +680,7 @@ jobs:
contents: read
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -701,7 +701,7 @@ jobs:
contents: read
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -712,7 +712,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -763,7 +763,7 @@ jobs:
working-directory: ./server
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -774,7 +774,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
+3 -3
View File
@@ -24,14 +24,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
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:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- 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:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.6.1",
"version": "2.6.2",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -35,8 +35,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vite": "^8.0.0",
"vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"
+5 -4
View File
@@ -1,10 +1,12 @@
import { defineConfig, UserConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
resolve: { alias: { src: '/src' } },
resolve: {
alias: { src: '/src' },
tsconfigPaths: true,
},
build: {
rollupOptions: {
rolldownOptions: {
input: 'src/index.ts',
output: {
dir: 'dist',
@@ -16,7 +18,6 @@ export default defineConfig({
// bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/,
},
plugins: [tsconfigPaths()],
test: {
name: 'cli:unit',
globals: true,
+5 -1
View File
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
## 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.
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:
@@ -19,6 +19,10 @@ Hardware and software requirements for Immich:
If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 6GB, recommended 8GB.
- **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.
- 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",
"url": "https://docs.v2.6.1.archive.immich.app"
"label": "v2.6.2",
"url": "https://docs.v2.6.2.archive.immich.app"
},
{
"label": "v2.5.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.1",
"version": "2.6.2",
"description": "",
"main": "index.js",
"type": "module",
+8 -3
View File
@@ -524,14 +524,19 @@ describe('/albums', () => {
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)
.patch(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ albumName: 'New album name' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
id: user1Albums[0].id,
albumName: 'New album name',
}),
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.6.1",
"version": "2.6.2",
"private": true,
"scripts": {
"format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.6.1"
version = "2.6.2"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.6.1"
version = "2.6.2"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
@@ -23,10 +23,18 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
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.CookieHandler
import java.net.PasswordAuthentication
@@ -277,10 +285,13 @@ object HttpClientManager {
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
suspend fun rebuildCronetEngine(): Result<Long> {
return runCatching {
cronetEngine?.shutdown()
val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
cronetEngine = buildCronetEngine()
deletionResult
}
}
val cronetStoragePath: File get() = cronetStorageDir
@@ -301,7 +312,7 @@ object HttpClientManager {
}
}
private fun buildCronetEngine(): CronetEngine {
fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
@@ -312,6 +323,27 @@ object HttpClientManager {
.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 {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -21,11 +21,6 @@ import java.io.EOFException
import java.io.File
import java.io.IOException
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
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -205,18 +200,15 @@ private class CronetImageFetcher : ImageFetcher {
private fun onDrained() {
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
val onCacheCleared = this.onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
} ?: return
CoroutineScope(Dispatchers.IO).launch {
val result = HttpClientManager.rebuildCronetEngine()
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(
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3039,
"android.injected.version.name" => "2.6.1",
"android.injected.version.code" => 3040,
"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')
@@ -150,7 +150,6 @@ class URLSessionManager: NSObject {
config.httpCookieStorage = cookieStorage
config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.6.1</string>
<string>2.6.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+1 -1
View File
@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
'Arabic (ar)': Locale('ar'),
'Bulgarian (bg)': Locale('bg'),
'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'),
'Croatian (hr)': Locale('hr'),
'Czech (cs)': Locale('cs'),
@@ -79,6 +79,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
final person = people[index];
return Column(
key: ValueKey(person.id),
children: [
GestureDetector(
onTap: () {
@@ -88,6 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
key: ValueKey('avatar-${person.id}'),
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
@@ -69,6 +69,7 @@ class DriftSearchPage extends HookConsumerWidget {
);
final previousFilter = useState<SearchFilter?>(null);
final hasRequestedSearch = useState<bool>(false);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
@@ -91,9 +92,11 @@ class DriftSearchPage extends HookConsumerWidget {
if (filter.isEmpty) {
previousFilter.value = null;
hasRequestedSearch.value = false;
return;
}
hasRequestedSearch.value = true;
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter;
}
@@ -107,6 +110,8 @@ class DriftSearchPage extends HookConsumerWidget {
searchPreFilter() {
if (preFilter != null) {
Future.delayed(Duration.zero, () {
filter.value = preFilter;
textSearchController.clear();
searchFilter(preFilter);
if (preFilter.location.city != null) {
@@ -719,7 +724,7 @@ class DriftSearchPage extends HookConsumerWidget {
),
),
),
if (filter.value.isEmpty)
if (!hasRequestedSearch.value)
const _SearchSuggestions()
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
@@ -24,20 +24,22 @@ class SimilarPhotosActionButton extends ConsumerWidget {
}
ref.invalidate(assetViewerProvider);
ref
.read(searchPreFilterProvider.notifier)
.setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
ref.invalidate(paginatedSearchProvider);
ref.read(searchPreFilterProvider.notifier)
..clear()
..setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
unawaited(context.navigateTo(const DriftSearchRoute()));
}
@@ -39,6 +39,16 @@ class _RatingBarState extends State<RatingBar> {
_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}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;
@@ -9,7 +9,11 @@ import 'package:flutter/painting.dart';
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
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._({
required super.codec,
@@ -34,15 +38,18 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
);
if (initialImage != null) {
self.didProvideImage = true;
self.setImage(initialImage);
}
stream.listen(
(item) {
if (item is ImageInfo) {
self.didProvideImage = true;
self.setImage(item);
} else if (item is ui.Codec) {
if (!codecCompleter.isCompleted) {
self.didProvideImage = true;
codecCompleter.complete(item);
}
}
@@ -53,6 +60,8 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
}
},
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) {
codecCompleter.completeError(StateError('Stream closed without providing a codec'));
}
@@ -64,20 +73,24 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
@override
void addListener(ImageStreamListener listener) {
_cacheListener ??= listener;
super.addListener(listener);
_listenerCount++;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
if (listener != _cacheListener) {
_cancel();
_listenerCount--;
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.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
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]
/// should be the primary images to display (typically asynchronously as they load).
@@ -21,12 +23,17 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
ImageInfo? initialImage,
InformationCollector? informationCollector,
void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved {
}) {
if (initialImage != null) {
didProvideImage = true;
setImage(initialImage);
}
_onLastListenerRemoved = onLastListenerRemoved;
images.listen(
(image) => setImage(image),
(image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving a single-frame image stream'),
@@ -41,20 +48,23 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
@override
void addListener(ImageStreamListener listener) {
_cacheListener ??= listener;
super.addListener(listener);
_listenerCount = _listenerCount + 1;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
if (listener != _cacheListener) {
_cancel();
_listenerCount = _listenerCount - 1;
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;
try {
final urls = ApiService.getServerUrls();
urls.add(url);
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), urls);
final uri = Uri.parse('$url/users/me');
final response = await NetworkRepository.client.get(uri);
if (response.statusCode == 200) {
+1 -2
View File
@@ -143,8 +143,7 @@ enum ActionButtonType {
!context.isInLockedView && //
context.currentAlbum != null,
ActionButtonType.setAlbumCover =>
context.isOwner && //
!context.isInLockedView && //
!context.isInLockedView && //
context.currentAlbum != null && //
context.selectedCount == 1,
ActionButtonType.unstack =>
+1 -1
View File
@@ -20,7 +20,7 @@ final class CustomImageCache implements ImageCache {
set maximumSize(int value) => _small.maximumSize = value;
@override
set maximumSizeBytes(int value) => _small.maximumSize = value;
set maximumSizeBytes(int value) => _small.maximumSizeBytes = value;
@override
void clear() {
@@ -16,9 +16,15 @@ class SearchDropdown<T> extends StatelessWidget {
final Widget? label;
final Widget? leadingIcon;
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
);
@override
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>(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
),
@@ -26,11 +32,26 @@ class SearchDropdown<T> extends StatelessWidget {
return LayoutBuilder(
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(
controller: controller,
leadingIcon: leadingIcon,
width: constraints.maxWidth,
dropdownMenuEntries: dropdownMenuEntries,
menuHeight: maxMenuHeight,
dropdownMenuEntries: styledEntries,
label: label,
menuStyle: menuStyle,
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:
- API version: 2.6.1
- API version: 2.6.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+12 -3
View File
@@ -1004,10 +1004,13 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
/// Return edited asset if available
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> playAssetVideoWithHttpInfo(String id, { String? key, String? slug, }) async {
Future<Response> playAssetVideoWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/playback'
.replaceAll('{id}', id);
@@ -1019,6 +1022,9 @@ class AssetsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (edited != null) {
queryParams.addAll(_queryParams('', 'edited', edited));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
@@ -1048,11 +1054,14 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
/// Return edited asset if available
///
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> playAssetVideo(String id, { String? key, String? slug, }) async {
final response = await playAssetVideoWithHttpInfo(id, key: key, slug: slug, );
Future<MultipartFile?> playAssetVideo(String id, { bool? edited, String? key, String? slug, }) async {
final response = await playAssetVideoWithHttpInfo(id, edited: edited, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+3 -3
View File
@@ -29,7 +29,6 @@ class JobName {
static const assetDetectFaces = JobName._(r'AssetDetectFaces');
static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll');
static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates');
static const assetEditThumbnailGeneration = JobName._(r'AssetEditThumbnailGeneration');
static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll');
static const assetEncodeVideo = JobName._(r'AssetEncodeVideo');
static const assetEmptyTrash = JobName._(r'AssetEmptyTrash');
@@ -38,6 +37,7 @@ class JobName {
static const assetFileMigration = JobName._(r'AssetFileMigration');
static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll');
static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails');
static const assetProcessEdit = JobName._(r'AssetProcessEdit');
static const auditLogCleanup = JobName._(r'AuditLogCleanup');
static const auditTableCleanup = JobName._(r'AuditTableCleanup');
static const databaseBackup = JobName._(r'DatabaseBackup');
@@ -88,7 +88,6 @@ class JobName {
assetDetectFaces,
assetDetectDuplicatesQueueAll,
assetDetectDuplicates,
assetEditThumbnailGeneration,
assetEncodeVideoQueueAll,
assetEncodeVideo,
assetEmptyTrash,
@@ -97,6 +96,7 @@ class JobName {
assetFileMigration,
assetGenerateThumbnailsQueueAll,
assetGenerateThumbnails,
assetProcessEdit,
auditLogCleanup,
auditTableCleanup,
databaseBackup,
@@ -182,7 +182,6 @@ class JobNameTypeTransformer {
case r'AssetDetectFaces': return JobName.assetDetectFaces;
case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll;
case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates;
case r'AssetEditThumbnailGeneration': return JobName.assetEditThumbnailGeneration;
case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll;
case r'AssetEncodeVideo': return JobName.assetEncodeVideo;
case r'AssetEmptyTrash': return JobName.assetEmptyTrash;
@@ -191,6 +190,7 @@ class JobNameTypeTransformer {
case r'AssetFileMigration': return JobName.assetFileMigration;
case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll;
case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails;
case r'AssetProcessEdit': return JobName.assetProcessEdit;
case r'AuditLogCleanup': return JobName.auditLogCleanup;
case r'AuditTableCleanup': return JobName.auditTableCleanup;
case r'DatabaseBackup': return JobName.databaseBackup;
+4 -4
View File
@@ -1194,10 +1194,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.6.1+3039
version: 2.6.2+3040
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -727,7 +727,7 @@ void main() {
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
});
test('should not show when not owner', () {
test('should show when not owner', () {
final album = createRemoteAlbum();
final context = ActionButtonContext(
asset: mergedAsset,
@@ -742,7 +742,7 @@ void main() {
selectedCount: 1,
);
expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse);
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
});
test('should not show when in locked view', () {
+12 -2
View File
@@ -4402,6 +4402,16 @@
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
"operationId": "playAssetVideo",
"parameters": [
{
"name": "edited",
"required": false,
"in": "query",
"description": "Return edited asset if available",
"schema": {
"default": false,
"type": "boolean"
}
},
{
"name": "id",
"required": true,
@@ -15166,7 +15176,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.6.1",
"version": "2.6.2",
"contact": {}
},
"tags": [
@@ -18144,7 +18154,6 @@
"AssetDetectFaces",
"AssetDetectDuplicatesQueueAll",
"AssetDetectDuplicates",
"AssetEditThumbnailGeneration",
"AssetEncodeVideoQueueAll",
"AssetEncodeVideo",
"AssetEmptyTrash",
@@ -18153,6 +18162,7 @@
"AssetFileMigration",
"AssetGenerateThumbnailsQueueAll",
"AssetGenerateThumbnails",
"AssetProcessEdit",
"AuditLogCleanup",
"AuditTableCleanup",
"DatabaseBackup",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.6.1",
"version": "2.6.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
+5 -3
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 2.6.1
* 2.6.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -4316,7 +4316,8 @@ export function viewAsset({ edited, id, key, size, slug }: {
/**
* Play asset video
*/
export function playAssetVideo({ id, key, slug }: {
export function playAssetVideo({ edited, id, key, slug }: {
edited?: boolean;
id: string;
key?: string;
slug?: string;
@@ -4325,6 +4326,7 @@ export function playAssetVideo({ id, key, slug }: {
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({
edited,
key,
slug
}))}`, {
@@ -7164,7 +7166,6 @@ export enum JobName {
AssetDetectFaces = "AssetDetectFaces",
AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll",
AssetDetectDuplicates = "AssetDetectDuplicates",
AssetEditThumbnailGeneration = "AssetEditThumbnailGeneration",
AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll",
AssetEncodeVideo = "AssetEncodeVideo",
AssetEmptyTrash = "AssetEmptyTrash",
@@ -7173,6 +7174,7 @@ export enum JobName {
AssetFileMigration = "AssetFileMigration",
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
AssetGenerateThumbnails = "AssetGenerateThumbnails",
AssetProcessEdit = "AssetProcessEdit",
AuditLogCleanup = "AuditLogCleanup",
AuditTableCleanup = "AuditTableCleanup",
DatabaseBackup = "DatabaseBackup",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.6.1",
"version": "2.6.2",
"description": "Monorepo for Immich",
"private": true,
"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
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
COPY ./plugins/mise.toml ./plugins/
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.6.1",
"version": "2.6.2",
"description": "",
"author": "",
"private": true,
@@ -24,7 +24,7 @@
"typeorm": "typeorm",
"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: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: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;'",
@@ -30,6 +30,7 @@ import {
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
AssetThumbnailOptionsDto,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
@@ -154,7 +155,7 @@ export class AssetMediaController {
async viewAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AssetMediaOptionsDto,
@Query() dto: AssetThumbnailOptionsDto,
@Req() req: Request,
@Res() res: Response,
@Next() next: NextFunction,
@@ -197,9 +198,10 @@ export class AssetMediaController {
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Res() res: Response,
@Query() dto: AssetMediaOptionsDto,
@Next() next: NextFunction,
) {
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
await sendFile(res, next, () => this.service.playbackVideo(auth, id, dto), this.logger);
}
@Post('exist')
+2 -2
View File
@@ -120,8 +120,8 @@ export class StorageCore {
);
}
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
static getEncodedVideoPath(asset: ThumbnailPathEntity, isEdited: boolean = false) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}${isEdited ? '_edited' : ''}.mp4`);
}
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
+2 -11
View File
@@ -169,6 +169,7 @@ export type AuthSharedLink = {
id: string;
expiresAt: Date | null;
userId: string;
albumId: string | null;
showExif: boolean;
allowUpload: boolean;
allowDownload: boolean;
@@ -345,8 +346,7 @@ export const columns = {
'asset.width',
'asset.height',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
assetFiles: [
'asset_file.id',
'asset_file.path',
'asset_file.type',
@@ -357,15 +357,6 @@ export const columns = {
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'],
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,
userWithPrefix: userWithPrefixColumns,
userAdmin: [
+5 -3
View File
@@ -18,13 +18,15 @@ export enum AssetMediaSize {
}
export class AssetMediaOptionsDto {
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
size?: AssetMediaSize;
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
edited?: boolean;
}
export class AssetThumbnailOptionsDto extends AssetMediaOptionsDto {
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
size?: AssetMediaSize;
}
export enum UploadFieldName {
ASSET_DATA = 'assetData',
SIDECAR_DATA = 'sidecarData',
+1 -1
View File
@@ -588,7 +588,6 @@ export enum JobName {
AssetDetectFaces = 'AssetDetectFaces',
AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll',
AssetDetectDuplicates = 'AssetDetectDuplicates',
AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration',
AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll',
AssetEncodeVideo = 'AssetEncodeVideo',
AssetEmptyTrash = 'AssetEmptyTrash',
@@ -597,6 +596,7 @@ export enum JobName {
AssetFileMigration = 'AssetFileMigration',
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
AssetProcessEdit = 'AssetProcessEdit',
AuditLogCleanup = 'AuditLogCleanup',
AuditTableCleanup = 'AuditTableCleanup',
+103 -15
View File
@@ -30,7 +30,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -60,7 +62,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -184,7 +188,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -245,6 +251,55 @@ from
where
"asset"."id" = $4
-- AssetJobRepository.getForAssetEditProcessing
select
"asset"."id",
"asset"."visibility",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."thumbhash",
"asset"."type",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" in ($1, $2, $3, $4)
) as agg
) as "files",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits",
to_json("asset_exif") as "exifInfo"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = $5
-- AssetJobRepository.getForMetadataExtraction
select
"asset"."id",
@@ -288,7 +343,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -314,7 +371,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -371,7 +430,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -411,7 +472,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -436,11 +499,12 @@ select
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
and "asset_file"."isEdited" = $2
) as "previewFile"
from
"asset"
where
"asset"."id" = $2
"asset"."id" = $3
-- AssetJobRepository.getForSyncAssets
select
@@ -474,7 +538,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -515,7 +581,8 @@ where
-- AssetJobRepository.streamForVideoConversion
select
"asset"."id"
"asset"."id",
"asset"."isEdited"
from
"asset"
where
@@ -546,17 +613,34 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
) as "files",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits"
from
"asset"
where
"asset"."id" = $1
"asset"."id" = $2
and "asset"."type" = 'VIDEO'
-- AssetJobRepository.streamForMetadataExtraction
@@ -598,7 +682,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -640,7 +726,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
+6 -3
View File
@@ -285,7 +285,9 @@ select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
@@ -638,12 +640,13 @@ select
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
and "asset_file"."isEdited" = $2
) as "encodedVideoPath"
from
"asset"
where
"asset"."id" = $2
and "asset"."type" = $3
"asset"."id" = $3
and "asset"."type" = $4
-- AssetRepository.getForOcr
select
@@ -173,6 +173,7 @@ order by
select
"shared_link"."id",
"shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt",
"shared_link"."showExif",
"shared_link"."allowUpload",
@@ -211,6 +212,7 @@ where
select
"shared_link"."id",
"shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt",
"shared_link"."showExif",
"shared_link"."allowUpload",
@@ -330,6 +330,7 @@ export class AlbumRepository {
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute();
}
@@ -112,6 +112,26 @@ export class AssetJobRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getForGenerateThumbnailJob(id: string) {
return this.db
.selectFrom('asset')
.select([
'asset.id',
'asset.visibility',
'asset.originalFileName',
'asset.originalPath',
'asset.ownerId',
'asset.thumbhash',
'asset.type',
])
.select((eb) => withFiles(eb, [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]))
.select(withEdits)
.$call(withExifInner)
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForAssetEditProcessing(id: string) {
return this.db
.selectFrom('asset')
.select([
@@ -124,13 +144,12 @@ export class AssetJobRepository {
'asset.type',
])
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('asset_file')
.select(columns.assetFilesForThumbnail)
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
).as('files'),
withFiles(eb, [
AssetFileType.Thumbnail,
AssetFileType.Preview,
AssetFileType.FullSize,
AssetFileType.EncodedVideo,
]),
)
.select(withEdits)
.$call(withExifInner)
@@ -308,7 +327,7 @@ export class AssetJobRepository {
streamForVideoConversion(force?: boolean) {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.select(['asset.id', 'asset.isEdited'])
.where('asset.type', '=', sql.lit(AssetType.Video))
.$if(!force, (qb) =>
qb
@@ -334,7 +353,8 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles)
.select((eb) => withFiles(eb, AssetFileType.EncodedVideo))
.select(withEdits)
.where('asset.id', '=', id)
.where('asset.type', '=', sql.lit(AssetType.Video))
.executeTakeFirst();
+3 -3
View File
@@ -1149,12 +1149,12 @@ export class AssetRepository {
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForVideo(id: string) {
@GenerateSql({ params: [DummyValue.UUID, true] })
async getForVideo(id: string, isEdited: boolean) {
return this.db
.selectFrom('asset')
.select(['asset.originalPath'])
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo, isEdited).as('encodedVideoPath'))
.where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video)
.executeTakeFirst();
@@ -202,7 +202,14 @@ export class SharedLinkRepository {
.leftJoin('album', 'album.id', 'shared_link.albumId')
.where('album.deletedAt', 'is', null)
.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(
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
).as('user'),
@@ -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[]> {
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 });
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> {
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 = {
success: false,
error: BulkIdErrorReason.DUPLICATE,
@@ -695,7 +695,9 @@ describe(AssetMediaService.name, () => {
describe('playbackVideo', () => {
it('should require asset.view permissions', async () => {
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.playbackVideo(authStub.admin, 'id', { edited: true })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
@@ -706,7 +708,9 @@ describe(AssetMediaService.name, () => {
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
await expect(sut.playbackVideo(authStub.admin, asset.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('should return the encoded video path if available', async () => {
@@ -719,7 +723,7 @@ describe(AssetMediaService.name, () => {
encodedVideoPath: asset.files[0].path,
});
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual(
new ImmichFileResponse({
path: '/path/to/encoded/video.mp4',
cacheControl: CacheControl.PrivateWithCache,
@@ -736,7 +740,7 @@ describe(AssetMediaService.name, () => {
encodedVideoPath: null,
});
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual(
new ImmichFileResponse({
path: asset.originalPath,
cacheControl: CacheControl.PrivateWithCache,
+13 -6
View File
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import { Asset, AuthSharedLink } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -17,6 +17,7 @@ import {
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
AssetThumbnailOptionsDto,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
@@ -152,7 +153,7 @@ export class AssetMediaService extends BaseService {
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
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);
@@ -222,7 +223,7 @@ export class AssetMediaService extends BaseService {
async viewThumbnail(
auth: AuthDto,
id: string,
dto: AssetMediaOptionsDto,
dto: AssetThumbnailOptionsDto,
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
@@ -266,10 +267,10 @@ export class AssetMediaService extends BaseService {
});
}
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
async playbackVideo(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
const asset = await this.assetRepository.getForVideo(id);
const asset = await this.assetRepository.getForVideo(id, dto.edited ?? false);
if (!asset) {
throw new NotFoundException('Asset not found or asset is not a video');
@@ -326,6 +327,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(
error: any,
auth: AuthDto,
@@ -347,7 +354,7 @@ export class AssetMediaService extends BaseService {
}
if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
+32 -6
View File
@@ -47,6 +47,7 @@ import {
} from 'src/utils/asset.util';
import { updateLockedColumns } from 'src/utils/database';
import { extractTimeZone } from 'src/utils/date';
import { scaleEdits } from 'src/utils/editor';
import { transformOcrBoundingBox } from 'src/utils/transform';
@Injectable()
@@ -565,10 +566,6 @@ export class AssetService extends BaseService {
throw new BadRequestException('Only images can be edited');
}
if (asset.livePhotoVideoId) {
throw new BadRequestException('Editing live photos is not supported');
}
if (isPanorama(asset)) {
throw new BadRequestException('Editing panorama images is not supported');
}
@@ -609,7 +606,28 @@ export class AssetService extends BaseService {
}
const newEdits = await this.assetEditRepository.replaceAll(id, edits);
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } });
if (asset.livePhotoVideoId) {
const liveAsset = await this.assetRepository.getForEdit(asset.livePhotoVideoId);
if (!liveAsset) {
throw new BadRequestException('Live photo video not found');
}
const { width: liveWidth, height: liveHeight } = getDimensions(liveAsset);
const scaledEdits = scaleEdits(
edits,
{ width: liveWidth, height: liveHeight },
{ width: assetWidth, height: assetHeight },
);
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, scaledEdits);
await this.jobRepository.queue({
name: JobName.AssetProcessEdit,
data: { id: asset.livePhotoVideoId },
});
}
// Return the asset and its applied edits
return {
@@ -627,6 +645,14 @@ export class AssetService extends BaseService {
}
await this.assetEditRepository.replaceAll(id, []);
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } });
if (asset.livePhotoVideoId) {
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, []);
await this.jobRepository.queue({
name: JobName.AssetProcessEdit,
data: { id: asset.livePhotoVideoId },
});
}
}
}
+1 -2
View File
@@ -95,8 +95,7 @@ export class JobService extends BaseService {
}
break;
}
case JobName.AssetEditThumbnailGeneration: {
case JobName.AssetProcessEdit: {
const asset = await this.assetRepository.getById(item.data.id);
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
+323 -29
View File
@@ -221,7 +221,7 @@ describe(MediaService.name, () => {
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetEditThumbnailGeneration,
name: JobName.AssetProcessEdit,
data: { id: asset.id },
},
]);
@@ -273,7 +273,7 @@ describe(MediaService.name, () => {
data: { id: asset.id },
},
{
name: JobName.AssetEditThumbnailGeneration,
name: JobName.AssetProcessEdit,
data: { id: asset.id },
},
]);
@@ -1321,9 +1321,101 @@ describe(MediaService.name, () => {
expect.stringContaining('fullsize.jpeg'),
);
});
it('should generate edited video thumbnails when asset has edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbnails({ id: asset.id });
// should generate both original and edited thumbnails (2 original + 2 edited transcodes)
expect(mocks.media.transcode).toHaveBeenCalledTimes(4);
// should upsert files for both original and edited
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ type: AssetFileType.Preview, isEdited: false }),
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: false }),
expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
]),
);
});
it('should not generate edited video thumbnails when asset has no edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.generateThumbhash.mockResolvedValue(Buffer.from('thumbhash'));
await sut.handleGenerateThumbnails({ id: asset.id });
// should only generate original thumbnails (2 transcodes for preview + thumbnail)
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.not.arrayContaining([expect.objectContaining({ isEdited: true })]),
);
});
it('should use edited thumbhash when asset has edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop })
.build();
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const originalThumbhash = Buffer.from('original thumbhash');
const editedThumbhash = Buffer.from('edited thumbhash');
mocks.media.generateThumbhash.mockResolvedValueOnce(originalThumbhash).mockResolvedValueOnce(editedThumbhash);
await sut.handleGenerateThumbnails({ id: asset.id });
// should use the edited thumbhash (second call) for the asset update
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: editedThumbhash }));
});
it('should generate edited image thumbnails with edits applied', async () => {
const asset = AssetFactory.from()
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 100, y: 100 } })
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbnails({ id: asset.id });
// should generate original (2) + edited (3 with fullsize) thumbnails
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
edits: [
expect.objectContaining({
action: 'crop',
parameters: { height: 500, width: 500, x: 100, y: 100 },
}),
],
}),
expect.stringContaining('edited'),
);
// should upsert both original and edited files
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isEdited: false }),
expect.objectContaining({ isEdited: true }),
]),
);
});
});
describe('handleAssetEditThumbnailGeneration', () => {
describe('handleAssetEditProcessing', () => {
let rawInfo: RawImageInfo;
beforeEach(() => {
@@ -1340,14 +1432,6 @@ describe(MediaService.name, () => {
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
});
it('should skip videos', async () => {
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should upsert 3 edited files for edit jobs', async () => {
const asset = AssetFactory.from()
.exif()
@@ -1359,13 +1443,13 @@ describe(MediaService.name, () => {
])
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
@@ -1381,11 +1465,11 @@ describe(MediaService.name, () => {
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } })
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
@@ -1409,9 +1493,9 @@ describe(MediaService.name, () => {
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
])
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
const status = await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
@@ -1427,11 +1511,11 @@ describe(MediaService.name, () => {
it('should generate all 3 edited files if an asset has edits', async () => {
const asset = AssetFactory.from().exif().edit().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
@@ -1453,26 +1537,147 @@ describe(MediaService.name, () => {
it('should generate the original thumbhash if no edits exist', async () => {
const asset = AssetFactory.from().exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' });
await sut.handleAssetEditProcessing({ id: asset.id, source: 'upload' });
expect(mocks.media.generateThumbhash).toHaveBeenCalled();
});
it('should apply thumbhash if job source is edit and edits exist', async () => {
const asset = AssetFactory.from().exif().edit().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = factory.buffer();
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
});
it('should return failed if asset not found', async () => {
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(undefined as never);
const status = await sut.handleAssetEditProcessing({ id: 'non-existent' });
expect(status).toBe(JobStatus.Failed);
});
it('should transcode edited video and generate thumbnails', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.files([
{ type: AssetFileType.Preview, isEdited: false },
{ type: AssetFileType.EncodedVideo, isEdited: true },
{ type: AssetFileType.Preview, isEdited: true },
{ type: AssetFileType.Thumbnail, isEdited: true },
])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleAssetEditProcessing({ id: asset.id });
// should transcode the video with hw accel disabled
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/video.mp4',
expect.stringContaining('edited'),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
}),
);
// should generate edited thumbnails (preview + thumbnail via transcode)
expect(mocks.media.transcode).toHaveBeenCalledTimes(3); // 1 video + 2 thumbnails
// should update thumbhash
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
});
it('should clean up edited video files when asset has no edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.files([
{ type: AssetFileType.EncodedVideo, path: 'edited_video.mp4', isEdited: true },
{ type: AssetFileType.Preview, path: 'edited_preview.jpg', isEdited: true },
{ type: AssetFileType.Thumbnail, path: 'edited_thumbnail.webp', isEdited: true },
])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditProcessing({ id: asset.id });
// should not transcode since there are no edits
expect(mocks.media.transcode).not.toHaveBeenCalled();
// should delete old edited files
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: {
files: expect.arrayContaining(['edited_video.mp4']),
},
});
});
it('should skip thumbnail generation for hidden video assets (live photo video portions)', async () => {
const asset = AssetFactory.from({
type: AssetType.Video,
originalPath: '/original/video.mp4',
visibility: AssetVisibility.Hidden,
})
.exif()
.edit({ action: AssetEditAction.Crop })
.files([{ type: AssetFileType.Preview, isEdited: false }])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
expect(mocks.media.generateThumbhash).not.toHaveBeenCalled();
});
it('should use original thumbhash when video has no edits but is visible', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.files([{ type: AssetFileType.Preview, path: '/thumbs/preview.jpg', isEdited: false }])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = factory.buffer();
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.media.generateThumbhash).toHaveBeenCalledWith('/thumbs/preview.jpg', expect.any(Object));
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ id: asset.id, thumbhash: thumbhashBuffer }),
);
});
it('should update dimensions from transcoded video edit', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 800, x: 100, y: 100 } })
.files([{ type: AssetFileType.Preview, isEdited: false }])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditProcessing({ id: asset.id });
// should update asset dimensions
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ id: asset.id, width: 800, height: 500 }),
);
});
});
describe('handleGeneratePersonThumbnail', () => {
@@ -1974,7 +2179,7 @@ describe(MediaService.name, () => {
mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -2228,7 +2433,7 @@ describe(MediaService.name, () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -2626,14 +2831,14 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 },
});
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
it('should fail if hwaccel option is invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -2920,7 +3125,7 @@ describe(MediaService.name, () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -3330,7 +3535,7 @@ describe(MediaService.name, () => {
sut.videoInterfaces = { dri: [], mali: true };
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -3605,6 +3810,95 @@ describe(MediaService.name, () => {
}),
);
});
it('should also transcode edited version when asset has edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
// should be called for both original and edited
expect(mocks.media.probe).toHaveBeenCalledTimes(2);
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.stringContaining('edited'),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
}),
);
});
it('should not transcode edited version when asset has no edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
// probe is called for both original and edit attempt, but only original is transcoded
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ isEdited: true })]),
);
});
it('should disable hardware acceleration for edited video transcoding', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, transcode: TranscodePolicy.All },
});
await sut.handleVideoConversion({ id: asset.id });
// the edited transcode call should NOT have hw accel options
const transcodeCalls = mocks.media.transcode.mock.calls;
const editedCall = transcodeCalls.find((call) => (call[1] as string).includes('edited'));
expect(editedCall).toBeDefined();
// hw accel typically adds device-specific input options; for edited, should be software only
expect(editedCall![2].inputOptions).not.toEqual(expect.arrayContaining([expect.stringContaining('qsv')]));
});
it('should upsert both original and edited encoded video files', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: false }),
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: true }),
]),
);
});
it('should clean up edited encoded video when edits are removed', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/edited_video.mp4', isEdited: true })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: {
files: expect.arrayContaining(['/encoded/edited_video.mp4']),
},
});
});
});
describe('isSRGB', () => {
+241 -72
View File
@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AssetFileType,
@@ -39,7 +39,7 @@ import {
VideoInterfaces,
VideoStreamInfo,
} from 'src/types';
import { getAssetFile, getDimensions } from 'src/utils/asset.util';
import { getDimensions } from 'src/utils/asset.util';
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
@@ -56,6 +56,13 @@ interface UpsertFileOptions {
}
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
type VideoConversionAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForVideoConversion']>>>;
type ThumbnailGenerationResult = {
files: UpsertFileOptions[];
thumbhash: Buffer;
fullsizeDimensions: ImageDimensions;
};
@Injectable()
export class MediaService extends BaseService {
@@ -84,7 +91,7 @@ export class MediaService extends BaseService {
}
if (asset.isEdited) {
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } });
}
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
@@ -168,9 +175,9 @@ export class MediaService extends BaseService {
return JobStatus.Success;
}
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
@OnJob({ name: JobName.AssetProcessEdit, queue: QueueName.Editor })
async handleAssetEditProcessing({ id }: JobOf<JobName.AssetProcessEdit>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForAssetEditProcessing(id);
const config = await this.getConfig({ withCache: true });
if (!asset) {
@@ -178,7 +185,25 @@ export class MediaService extends BaseService {
return JobStatus.Failed;
}
const generated = await this.generateEditedThumbnails(asset, config);
switch (asset.type) {
case AssetType.Image: {
await this.handleImageEdit(asset, config);
break;
}
case AssetType.Video: {
await this.handleVideoEdit(asset, config);
break;
}
default: {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
}
}
return JobStatus.Success;
}
private async handleImageEdit(asset: ThumbnailAsset, config: SystemConfig) {
const generated = await this.generateEditedImageThumbnails(asset, config);
await this.syncFiles(
asset.files.filter((file) => file.isEdited),
generated?.files ?? [],
@@ -203,8 +228,51 @@ export class MediaService extends BaseService {
const fullsizeDimensions = generated?.fullsizeDimensions ?? getDimensions(asset.exifInfo!);
await this.assetRepository.update({ id: asset.id, ...fullsizeDimensions });
}
return JobStatus.Success;
private async handleVideoEdit(asset: ThumbnailAsset, config: SystemConfig) {
// transcode edited video
const generatedVideo = asset.edits.length > 0 ? await this.transcodeVideo(asset, config.ffmpeg, true) : undefined;
await this.syncFiles(
asset.files.filter((file) => file.isEdited && file.type === AssetFileType.EncodedVideo),
generatedVideo ? [generatedVideo.file] : [],
);
// update asset dimensions
const newDimensions = generatedVideo?.dimensions ?? getDimensions(asset.exifInfo!);
await this.assetRepository.update({ id: asset.id, ...newDimensions });
// if the asset is hidden, we dont need to update the thumbhash or thumbnails
if (asset.visibility === AssetVisibility.Hidden) {
return;
}
const editedThumbnails = await this.generateEditedVideoThumbnails(asset, config);
await this.syncFiles(
asset.files.filter((file) => file.isEdited && file.type !== AssetFileType.EncodedVideo),
editedThumbnails?.files ?? [],
);
let thumbhash: Buffer | undefined = editedThumbnails?.thumbhash;
if (!thumbhash) {
const previewFile = asset.files.find((file) => file.type === AssetFileType.Preview && !file.isEdited);
if (!previewFile) {
this.logger.warn(`Failed to generate thumbhash for asset ${asset.id}: missing preview file`);
return;
}
thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
colorspace: config.image.colorspace,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
});
}
// update asset table info
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash });
}
}
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
@@ -217,31 +285,34 @@ export class MediaService extends BaseService {
return JobStatus.Failed;
}
let generated: ThumbnailGenerationResult;
let generatedEdited: ThumbnailGenerationResult | undefined;
if (asset.visibility === AssetVisibility.Hidden) {
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
return JobStatus.Skipped;
}
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
generated = await this.generateVideoThumbnails(asset, config);
generatedEdited = await this.generateEditedVideoThumbnails(asset, config);
} else if (asset.type === AssetType.Image) {
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
generated = await this.generateImageThumbnails(asset, config);
generatedEdited = await this.generateEditedImageThumbnails(asset, config);
} else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.Skipped;
}
const editedGenerated = await this.generateEditedThumbnails(asset, config);
if (editedGenerated) {
generated.files.push(...editedGenerated.files);
if (generatedEdited) {
generated.files.push(...generatedEdited.files);
}
await this.syncFiles(asset.files, generated.files);
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
const thumbhash = generatedEdited?.thumbhash || generated.thumbhash;
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash });
}
@@ -507,20 +578,21 @@ export class MediaService extends BaseService {
}
private async generateVideoThumbnails(
asset: ThumbnailPathEntity & { originalPath: string },
asset: ThumbnailPathEntity & { originalPath: string; edits: AssetEditActionItem[] },
{ ffmpeg, image }: SystemConfig,
useEdits: boolean = false,
) {
const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview,
format: image.preview.format,
isEdited: false,
isEdited: useEdits,
isProgressive: false,
isTransparent: false,
});
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: false,
isEdited: useEdits,
isProgressive: false,
isTransparent: false,
});
@@ -533,14 +605,27 @@ export class MediaService extends BaseService {
}
const mainAudioStream = this.getMainStream(audioStreams);
let edits: AssetEditActionItem[] | undefined;
if (useEdits) {
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
edits = asset.edits;
}
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, mainVideoStream, mainAudioStream, format);
const previewOptions = previewConfig.getCommand(
TranscodeTarget.Video,
mainVideoStream,
mainAudioStream,
format,
edits,
);
const thumbnailOptions = thumbnailConfig.getCommand(
TranscodeTarget.Video,
mainVideoStream,
mainAudioStream,
format,
edits,
);
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
@@ -551,73 +636,69 @@ export class MediaService extends BaseService {
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
});
let fullsizeDimensions = { width: mainVideoStream.width, height: mainVideoStream.height };
if (useEdits) {
fullsizeDimensions = getOutputDimensions(asset.edits, fullsizeDimensions);
}
return {
files: [previewFile, thumbnailFile],
thumbhash,
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
fullsizeDimensions,
};
}
@OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
const { force } = job;
let queue: { name: JobName.AssetEncodeVideo; data: { id: string } }[] = [];
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
queue.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(queue);
queue = [];
}
}
await this.jobRepository.queueAll(queue);
return JobStatus.Success;
}
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForVideoConversion(id);
if (!asset) {
return JobStatus.Failed;
}
private async transcodeVideo(
asset: VideoConversionAsset,
ffmpeg: SystemConfigFFmpegDto,
useEdits: boolean = false,
): Promise<{ file: UpsertFileOptions; dimensions: { width: number; height: number } } | undefined> {
const input = asset.originalPath;
const output = StorageCore.getEncodedVideoPath(asset);
const output = StorageCore.getEncodedVideoPath(asset, useEdits);
this.storageCore.ensureFolders(output);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs
countFrames: this.logger.isLevelEnabled(LogLevel.Debug),
});
const videoStream = this.getMainStream(videoStreams);
const audioStream = this.getMainStream(audioStreams);
if (!videoStream || !format.formatName) {
return JobStatus.Failed;
return undefined;
}
if (!videoStream.height || !videoStream.width) {
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
return JobStatus.Failed;
return undefined;
}
let { ffmpeg } = await this.getConfig({ withCache: true });
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
if (encodedVideo) {
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } });
await this.assetRepository.deleteFiles([encodedVideo]);
} else {
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
let target: TranscodeTarget;
let edits: AssetEditActionItem[] | undefined;
if (useEdits) {
if (asset.edits.length === 0) {
this.logger.verbose(`Asset ${asset.id} has no edits, skipping edited version transcoding`);
return undefined;
}
return JobStatus.Skipped;
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
target = TranscodeTarget.All;
edits = asset.edits;
} else {
target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
return undefined;
}
}
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
target,
videoStream,
audioStream,
useEdits ? undefined : format,
edits,
);
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
} else {
@@ -631,7 +712,7 @@ export class MediaService extends BaseService {
} catch (error: any) {
this.logger.error(`Error occurred during transcoding: ${error.message}`);
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
return JobStatus.Failed;
throw error;
}
let partialFallbackSuccess = false;
@@ -639,7 +720,13 @@ export class MediaService extends BaseService {
try {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
ffmpeg = { ...ffmpeg, accelDecode: false };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
target,
videoStream,
audioStream,
format,
edits,
);
await this.mediaRepository.transcode(input, output, command);
partialFallbackSuccess = true;
} catch (error: any) {
@@ -650,19 +737,87 @@ export class MediaService extends BaseService {
if (!partialFallbackSuccess) {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
target,
videoStream,
audioStream,
format,
edits,
);
await this.mediaRepository.transcode(input, output, command);
}
}
this.logger.log(`Successfully encoded ${asset.id}`);
await this.assetRepository.upsertFile({
assetId: asset.id,
type: AssetFileType.EncodedVideo,
path: output,
isEdited: false,
});
let finalDimensions = { width: videoStream.width, height: videoStream.height };
if (useEdits) {
finalDimensions = getOutputDimensions(asset.edits, finalDimensions);
}
return {
dimensions: finalDimensions,
file: {
assetId: asset.id,
type: AssetFileType.EncodedVideo,
path: output,
isEdited: useEdits,
isProgressive: false,
isTransparent: false,
},
};
}
@OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
const { force } = job;
let jobs: JobItem[] = [];
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
if (force || !asset.isEdited) {
jobs.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
}
if (asset.isEdited) {
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } });
}
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(jobs);
jobs = [];
}
}
await this.jobRepository.queueAll(jobs);
return JobStatus.Success;
}
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForVideoConversion(id);
if (!asset) {
return JobStatus.Failed;
}
const { ffmpeg } = await this.getConfig({ withCache: true });
const files: UpsertFileOptions[] = [];
try {
const generated = await this.transcodeVideo(asset, ffmpeg);
if (generated?.file) {
files.push(generated.file);
}
const editedGenerated = await this.transcodeVideo(asset, ffmpeg, true);
if (editedGenerated) {
files.push(editedGenerated.file);
}
} catch {
return JobStatus.Failed;
}
await this.syncFiles(asset.files, files);
return JobStatus.Success;
}
@@ -874,13 +1029,29 @@ export class MediaService extends BaseService {
}
}
private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
private async generateEditedImageThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
return;
}
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
await this.updateMLVisibilities(asset);
return generated;
}
private async generateEditedVideoThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
if (asset.type !== AssetType.Video || (asset.files.length === 0 && asset.edits.length === 0)) {
return;
}
const generated = asset.edits.length > 0 ? await this.generateVideoThumbnails(asset, config, true) : undefined;
await this.updateMLVisibilities(asset);
return generated;
}
private async updateMLVisibilities(asset: ThumbnailAsset) {
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
const cropBox = crop
? {
@@ -900,8 +1071,6 @@ export class MediaService extends BaseService {
const ocrStatuses = checkOcrVisibility(ocrData, originalDimensions, cropBox);
await this.ocrRepository.updateOcrVisibilities(asset.id, ocrStatuses.visible, ocrStatuses.hidden);
return generated;
}
private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) {
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
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', () => {
it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' });
+7
View File
@@ -55,6 +55,13 @@ export class VersionService extends BaseService {
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() {
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} });
}
+3 -1
View File
@@ -130,6 +130,7 @@ export interface TranscodeCommand {
progress: {
frameCount: number;
percentInterval: number;
callback: (percent: number, frame: number) => void;
};
}
@@ -151,6 +152,7 @@ export interface VideoCodecSWConfig {
videoStream: VideoStreamInfo,
audioStream: AudioStreamInfo,
format?: VideoFormat,
edits?: AssetEditActionItem[],
): TranscodeCommand;
}
@@ -389,7 +391,7 @@ export type JobItem =
| { name: JobName.WorkflowRun; data: IWorkflowJob }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
| { name: JobName.AssetProcessEdit; data: IEntityJob };
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
+14 -2
View File
@@ -190,7 +190,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
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: {
@@ -198,7 +204,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
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: {
+6 -4
View File
@@ -116,22 +116,24 @@ export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withHidden?: boole
).as('faces');
}
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType | AssetFileType[]) {
return jsonArrayFrom(
eb
.selectFrom('asset_file')
.select(columns.assetFiles)
.whereRef('asset_file.assetId', '=', 'asset.id')
.$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)),
.$if(!!type && typeof type === 'string', (qb) => qb.where('asset_file.type', '=', type!))
.$if(!!type && Array.isArray(type), (qb) => qb.where('asset_file.type', 'in', type as AssetFileType[])),
).as('files');
}
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType, isEdited = false) {
return eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', type);
.where('asset_file.type', '=', type)
.where('asset_file.isEdited', '=', isEdited);
}
export function withFacesAndPeople(
+27
View File
@@ -1,4 +1,5 @@
import { AssetFace } from 'src/database';
import { AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageDimensions } from 'src/types';
@@ -31,6 +32,15 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
};
};
const scaleCrop = (crop: CropParameters, target: ImageDimensions, source: ImageDimensions) => {
return {
width: Math.round((crop.width / source.width) * target.width),
height: Math.round((crop.height / source.height) * target.height),
x: Math.round((crop.x / source.width) * target.width),
y: Math.round((crop.y / source.height) * target.height),
};
};
export const checkFaceVisibility = (
faces: AssetFace[],
originalAssetDimensions: ImageDimensions,
@@ -105,3 +115,20 @@ export const checkOcrVisibility = (
hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr),
};
};
export const scaleEdits = (
edits: AssetEditActionItem[],
target: ImageDimensions,
source: ImageDimensions,
): AssetEditActionItem[] => {
return edits.map((edit) => {
if (edit.action === 'crop') {
return {
...edit,
parameters: scaleCrop(edit.parameters as CropParameters, target, source),
} as AssetEditActionItem;
}
return edit;
});
};
+95 -12
View File
@@ -1,4 +1,12 @@
import { AUDIO_ENCODER } from 'src/constants';
import {
AssetEditAction,
AssetEditActionItem,
CropParameters,
MirrorAxis,
MirrorParameters,
RotateParameters,
} from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
import {
@@ -88,15 +96,26 @@ export class BaseConfig implements VideoCodecSWConfig {
videoStream: VideoStreamInfo,
audioStream?: AudioStreamInfo,
format?: VideoFormat,
edits: AssetEditActionItem[] = [],
) {
const inputOptions = this.getBaseInputOptions(videoStream, format);
if (edits.length > 0) {
// turns out MOV files can have cropping metadata that ffmpeg automatically applies when decoding
// this means that the video streams dimensions can just be wrong once it hits the filter pipeline
// https://github.com/FFmpeg/FFmpeg/blob/f40fcf802472227851e0b8eeba40b9e6b3b8a3a1/libavutil/frame.h#L1021
inputOptions.push('-apply_cropping 0');
}
const options = {
inputOptions: this.getBaseInputOptions(videoStream, format),
inputOptions,
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(),
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
} as TranscodeCommand;
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
const filters = this.getFilterOptions(videoStream);
const filters = this.getFilterOptions(videoStream, edits);
if (filters.length > 0) {
options.outputOptions.push(`-vf ${filters.join(',')}`);
}
@@ -156,10 +175,46 @@ export class BaseConfig implements VideoCodecSWConfig {
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
getEditOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[]) {
const options = [];
if (this.shouldScale(videoStream)) {
options.push(`scale=${this.getScaling(videoStream)}`);
let currentDimensions = { width: videoStream.width, height: videoStream.height };
// Apply CPU edit operations before hwupload
for (const edit of edits) {
switch (edit.action) {
case AssetEditAction.Crop: {
options.push(this.getCropOperation(edit.parameters));
currentDimensions = { width: edit.parameters.width, height: edit.parameters.height };
break;
}
case AssetEditAction.Rotate: {
const rotateFilter = this.getRotateOperation(edit.parameters);
if (rotateFilter) {
options.push(rotateFilter);
if (Math.abs(edit.parameters.angle) === 90 || Math.abs(edit.parameters.angle) === 270) {
currentDimensions = { width: currentDimensions.height, height: currentDimensions.width };
}
}
break;
}
case AssetEditAction.Mirror: {
options.push(this.getMirrorOperation(edit.parameters));
break;
}
}
}
return { options, currentDimensions };
}
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []) {
const options = [];
const { options: editOptions, currentDimensions } = this.getEditOptions(videoStream, edits);
options.push(...editOptions);
// Apply scaling based on current dimensions after edits
if (this.shouldScale(videoStream, currentDimensions)) {
options.push(`scale=${this.getScaling(videoStream, 2, currentDimensions)}`);
}
const tonemapOptions = this.getToneMapping(videoStream);
@@ -238,9 +293,10 @@ export class BaseConfig implements VideoCodecSWConfig {
return target;
}
shouldScale(videoStream: VideoStreamInfo) {
const oddDimensions = videoStream.height % 2 !== 0 || videoStream.width % 2 !== 0;
const largerThanTarget = Math.min(videoStream.height, videoStream.width) > this.getTargetResolution(videoStream);
shouldScale(videoStream: VideoStreamInfo, currentDimensions?: { width: number; height: number }) {
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
const oddDimensions = dims.height % 2 !== 0 || dims.width % 2 !== 0;
const largerThanTarget = Math.min(dims.height, dims.width) > this.getTargetResolution(videoStream);
return oddDimensions || largerThanTarget;
}
@@ -248,9 +304,11 @@ export class BaseConfig implements VideoCodecSWConfig {
return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled;
}
getScaling(videoStream: VideoStreamInfo, mult = 2) {
getScaling(videoStream: VideoStreamInfo, mult = 2, currentDimensions?: { width: number; height: number }) {
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
const targetResolution = this.getTargetResolution(videoStream);
return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
const isVertical = dims.height > dims.width || this.isVideoRotated(videoStream);
return isVertical ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
}
getSize(videoStream: VideoStreamInfo) {
@@ -329,6 +387,31 @@ export class BaseConfig implements VideoCodecSWConfig {
useCQP() {
return this.config.cqMode === CQMode.Cqp;
}
// Edit operations (software filters)
getCropOperation({ x, y, width, height }: CropParameters): string {
return `crop=${width}:${height}:${x}:${y}`;
}
getRotateOperation({ angle }: RotateParameters): string {
switch (angle) {
case 90: {
return 'transpose=1'; // 90° clockwise
}
case 180: {
return 'hflip,vflip'; // 180°
}
case 270: {
return 'transpose=2'; // 90° counter-clockwise (270° clockwise)
}
}
return '';
}
getMirrorOperation({ axis }: MirrorParameters): string {
return axis === MirrorAxis.Horizontal ? 'hflip' : 'vflip';
}
}
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
@@ -423,14 +506,14 @@ export class ThumbnailConfig extends BaseConfig {
return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
}
getFilterOptions(videoStream: VideoStreamInfo): string[] {
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []): string[] {
return [
'fps=12:start_time=0:eof_action=pass:round=down',
'thumbnail=12',
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
'trim=end_frame=2',
'reverse',
...super.getFilterOptions(videoStream),
...super.getFilterOptions(videoStream, edits),
];
}
+1
View File
@@ -48,6 +48,7 @@ export const authStub = {
showExif: true,
allowDownload: true,
allowUpload: true,
albumId: null,
expiresAt: null,
password: null,
userId: '42',
+2 -2
View File
@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { result };
}
async newAlbum(dto: Insertable<AlbumTable>) {
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
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 };
}
@@ -1,12 +1,15 @@
import { Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.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 { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
@@ -22,7 +25,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetMediaService, {
database: db || defaultDatabase,
real: [AccessRepository, AssetRepository, UserRepository],
real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
});
};
@@ -44,7 +47,6 @@ describe(AssetService.name, () => {
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } });
const file = mediumFactory.uploadFile();
await expect(
sut.uploadAsset(
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
},
file,
mediumFactory.uploadFile(),
),
).resolves.toEqual({
id: expect.any(String),
@@ -99,6 +101,168 @@ describe(AssetService.name, () => {
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', () => {
+11 -1
View File
@@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
expiresAt = null,
userId = newUuid(),
showExif = true,
albumId = null,
allowUpload = false,
allowDownload = true,
password = null,
} = sharedLink;
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
return {
id,
albumId,
expiresAt,
userId,
showExif,
allowUpload,
allowDownload,
password,
};
};
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.6.1",
"version": "2.6.2",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -72,10 +72,10 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.10.0",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.1.7",
"@sveltejs/vite-plugin-svelte": "7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2",
@@ -100,13 +100,13 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.7",
"svelte": "5.53.13",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
"tailwindcss": "^4.2.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.2",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"volta": {
@@ -212,12 +212,12 @@
bottom: `${rootHeight - top}px`,
left: `${left}px`,
width: `${boundary.width}px`,
maxHeight: maxHeight(top - dropdownOffset),
maxHeight: maxHeight(boundary.top - dropdownOffset),
};
}
const viewportHeight = visualViewport?.height || rootHeight;
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom;
const viewportHeight = visualViewport?.height || window.innerHeight;
const availableHeight = viewportHeight - boundary.bottom;
return {
top: `${bottom}px`,
left: `${left}px`,
@@ -139,6 +139,7 @@ export class AssetViewerManager extends BaseEventManager<Events> {
openEditor() {
this.closeActivityPanel();
this.isPlayingMotionPhoto = false;
this.isShowEditor = true;
}
@@ -127,7 +127,17 @@ export class EditManager {
try {
// Setup the websocket listener before sending the edit request
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
const editEvents = [waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000)];
if (this.currentAsset.livePhotoVideoId) {
editEvents.push(
waitForWebsocketEvent(
'AssetEditReadyV1',
(event) => event.asset.id === this.currentAsset!.livePhotoVideoId,
10_000,
),
);
}
await (edits.length === 0
? removeAssetEdits({ id: assetId })
@@ -138,7 +148,7 @@ export class EditManager {
},
}));
await editCompleted;
await Promise.all(editEvents);
eventManager.emit('AssetEditsApplied', assetId);
+4 -1
View File
@@ -28,7 +28,10 @@
let { onClose }: Props = $props();
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);
loading = false;
});
+6 -13
View File
@@ -8,17 +8,16 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
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 { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
getBaseUrl,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -243,7 +242,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
!sharedLink &&
isOwner &&
asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
!asset.originalPath.toLowerCase().endsWith('.insp') &&
!asset.originalPath.toLowerCase().endsWith('.gif') &&
@@ -308,6 +306,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
{
filename: asset.originalFileName,
id: asset.id,
cacheKey: asset.thumbhash,
},
];
@@ -321,13 +320,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
cacheKey: motionAsset.thumbhash,
});
}
}
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
@@ -335,12 +333,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
filename,
);
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}
+28 -1
View File
@@ -80,7 +80,34 @@ function createUploadStore() {
};
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 = () =>

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