mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 20:55:25 -04:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a69374aa17 | |||
| 15c15bd543 | |||
| 10e754e1aa | |||
| b9282b27e5 | |||
| 146a076324 | |||
| 4c70afc8f4 | |||
| 80db413d69 | |||
| d8d532e7ca | |||
| a9666d2cef | |||
| 4af9edc20b | |||
| c975fe5bc7 | |||
| 12a4d8e2ee |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+12
-3
@@ -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));
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -18144,7 +18154,6 @@
|
||||
"AssetDetectFaces",
|
||||
"AssetDetectDuplicatesQueueAll",
|
||||
"AssetDetectDuplicates",
|
||||
"AssetEditThumbnailGeneration",
|
||||
"AssetEncodeVideoQueueAll",
|
||||
"AssetEncodeVideo",
|
||||
"AssetEmptyTrash",
|
||||
@@ -18153,6 +18162,7 @@
|
||||
"AssetFileMigration",
|
||||
"AssetGenerateThumbnailsQueueAll",
|
||||
"AssetGenerateThumbnails",
|
||||
"AssetProcessEdit",
|
||||
"AuditLogCleanup",
|
||||
"AuditTableCleanup",
|
||||
"DatabaseBackup",
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -346,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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
AssetThumbnailOptionsDto,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -242,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') &&
|
||||
|
||||
@@ -238,8 +238,8 @@ export const getAssetMediaUrl = (options: AssetUrlOptions) => {
|
||||
};
|
||||
|
||||
export const getAssetPlaybackUrl = (options: AssetUrlOptions) => {
|
||||
const { id, cacheKey: c } = options;
|
||||
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c });
|
||||
const { id, cacheKey: c, edited = true } = options;
|
||||
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c, edited });
|
||||
};
|
||||
|
||||
export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||
|
||||
Reference in New Issue
Block a user