mirror of
https://github.com/immich-app/immich.git
synced 2026-06-17 04:12:16 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92642636e0 | |||
| f97d9a5f22 | |||
| 472715dfa7 | |||
| 6fa51f59ec | |||
| 450c9c6e16 | |||
| 89b387c67f |
@@ -15,7 +15,7 @@ jobs:
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@a8c7f0e5649d20d623edb5b38446d3ab3d82d43c # v0.0.53
|
||||
uses: oasdiff/oasdiff-action/breaking@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # v0.0.48
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -50,14 +50,14 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: true
|
||||
|
||||
@@ -10,13 +10,9 @@ on:
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
- premajor
|
||||
- preminor
|
||||
- prepatch
|
||||
- prerelease
|
||||
- release
|
||||
mobileBump:
|
||||
description: 'Bump mobile build number'
|
||||
required: false
|
||||
@@ -59,7 +55,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
persist-credentials: true
|
||||
@@ -72,13 +68,13 @@ jobs:
|
||||
|
||||
# TODO move to mise
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Bump version
|
||||
env:
|
||||
SERVER_BUMP: ${{ inputs.serverBump }}
|
||||
MOBILE_BUMP: ${{ inputs.mobileBump }}
|
||||
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||
|
||||
- id: output
|
||||
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -129,7 +125,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
+16
-48
@@ -28,10 +28,6 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
root:
|
||||
- 'misc/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'mise.toml'
|
||||
i18n:
|
||||
- 'i18n/**'
|
||||
- 'mise.toml'
|
||||
@@ -66,34 +62,6 @@ jobs:
|
||||
- '.github/workflows/test.yml'
|
||||
force-events: 'workflow_dispatch'
|
||||
|
||||
root-unit-tests:
|
||||
name: Test the root workspace
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).root == true }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
server-unit-tests:
|
||||
name: Test & Lint Server
|
||||
needs: pre-job
|
||||
@@ -109,7 +77,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -140,7 +108,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -171,7 +139,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -215,7 +183,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -253,7 +221,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -281,7 +249,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -331,7 +299,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -363,7 +331,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -399,7 +367,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -476,7 +444,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -583,7 +551,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -621,7 +589,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -652,7 +620,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -681,7 +649,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -703,7 +671,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -761,7 +729,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
Vendored
-1
@@ -60,7 +60,6 @@
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"*.js": "${capture}.spec.js,${capture}.mock.js",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"search.exclude": {
|
||||
|
||||
@@ -492,20 +492,6 @@ describe('/asset', () => {
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should set the negative rating', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ rating: -1 });
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
rating: -1,
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should return tagged people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
|
||||
@@ -2248,7 +2248,6 @@
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"smart_album": "Smart album",
|
||||
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
"sort_created": "Date created",
|
||||
"sort_items": "Number of items",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
#! /usr/bin/env node
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
|
||||
const asVersion = (item) => {
|
||||
const { label, url } = item;
|
||||
const [version] = label.substring(1).split('-');
|
||||
const [major, minor, patch] = version.split('.').map(Number);
|
||||
const [major, minor, patch] = label.substring(1).split('.').map(Number);
|
||||
return { major, minor, patch, label, url };
|
||||
};
|
||||
|
||||
@@ -32,7 +31,7 @@ for (const item of versions) {
|
||||
) {
|
||||
versions = versions.filter((item) => item.label !== version.label);
|
||||
console.log(
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`,
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -42,5 +41,5 @@ for (const item of versions) {
|
||||
|
||||
writeFileSync(
|
||||
filename,
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n',
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n'
|
||||
);
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
#
|
||||
# Pump one or both of the server/mobile versions in appropriate files
|
||||
#
|
||||
# usage: './scripts/pump-version.sh -s <minor|patch|premajor|preminor|prepatch|prerelease> <-m> <true|false>
|
||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
|
||||
#
|
||||
# examples:
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -s premajor # 1.0.0+50 => 2.0.0-rc.0+50
|
||||
# ./scripts/pump-version.sh -s prerelease # 2.0.0-rc.0+50 => 2.0.0-rc.1+50
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
#
|
||||
|
||||
SERVER_PUMP="false"
|
||||
@@ -27,15 +25,31 @@ while getopts 's:m:' flag; do
|
||||
esac
|
||||
done
|
||||
|
||||
CURRENT_SERVER=$(jq -r '.version' package.json)
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
|
||||
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
|
||||
CURRENT_SERVER=$(jq -r '.version' server/package.json)
|
||||
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
|
||||
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
|
||||
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
|
||||
|
||||
if [[ $SERVER_PUMP == "major" ]]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "minor" ]]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "patch" ]]; then
|
||||
PATCH=$((PATCH + 1))
|
||||
elif [[ $SERVER_PUMP == "false" ]]; then
|
||||
echo 'Skipping Server Pump'
|
||||
else
|
||||
echo 'Expected <major|minor|patch|false> for the server argument'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
|
||||
|
||||
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
|
||||
NEXT_MOBILE=$CURRENT_MOBILE
|
||||
|
||||
if [[ $MOBILE_PUMP == "true" ]]; then
|
||||
set $((NEXT_MOBILE++))
|
||||
elif [[ $MOBILE_PUMP == "false" ]]; then
|
||||
@@ -45,17 +59,15 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/sdk
|
||||
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { pump } from './pump.js';
|
||||
|
||||
const [versionRaw, type] = process.argv.slice(2);
|
||||
const { message, exitCode } = pump(versionRaw, type);
|
||||
|
||||
console.log(message);
|
||||
process.exit(exitCode);
|
||||
@@ -1,105 +0,0 @@
|
||||
import semver, { SemVer } from 'semver';
|
||||
|
||||
const printUsage = () => {
|
||||
return {
|
||||
message:
|
||||
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
exitCode: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
|
||||
/**
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
return version;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} versionRaw
|
||||
* @param {string} type
|
||||
*/
|
||||
export const pump = (versionRaw, type) => {
|
||||
if (!versionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
versionRaw = normalize(versionRaw);
|
||||
|
||||
const version = semver.parse(versionRaw);
|
||||
if (!version) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
let newVersionRaw;
|
||||
let valid = true;
|
||||
|
||||
switch (type) {
|
||||
case 'patch':
|
||||
case 'prepatch':
|
||||
case 'minor':
|
||||
case 'preminor':
|
||||
case 'premajor': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while not in a prerelease
|
||||
valid = !isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'prerelease': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while in a prerelease
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'release': {
|
||||
// drop prerelease part
|
||||
newVersionRaw = `${version.major}.${version.minor}.${version.patch}`;
|
||||
// can only use to promote a prerelease to a release (no version change)
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
return printUsage();
|
||||
}
|
||||
}
|
||||
|
||||
if (!newVersionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
newVersionRaw = normalize(newVersionRaw);
|
||||
|
||||
const newVersion = semver.parse(newVersionRaw);
|
||||
if (!newVersion) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
const invalidUpgrade =
|
||||
isPrerelease(version) &&
|
||||
!isPrerelease(newVersion) &&
|
||||
(version.major !== newVersion.major ||
|
||||
version.minor !== newVersion.minor ||
|
||||
version.patch !== newVersion.patch);
|
||||
|
||||
if (!valid || invalidUpgrade) {
|
||||
return {
|
||||
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
|
||||
exitCode: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: newVersionRaw, exitCode: 0 };
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pump } from './pump';
|
||||
|
||||
describe(pump.name, () => {
|
||||
describe('usage', () => {
|
||||
it.each([
|
||||
[],
|
||||
['2.7.5'],
|
||||
['2.7.5', 'invalid'],
|
||||
['invalid', 'patch'],
|
||||
['2.7.5', 'major'],
|
||||
])('should not accept $0, $1 as inputs', (version, type) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Usage: '),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitions', () => {
|
||||
const valid = [
|
||||
{
|
||||
name: 'patch',
|
||||
items: [['patch', '2.7.5', '2.7.6']],
|
||||
},
|
||||
{
|
||||
name: 'prepatch',
|
||||
items: [
|
||||
['prepatch', '2.7.5', '2.7.6-rc.0'],
|
||||
['prerelease', '2.7.6-rc.0', '2.7.6-rc.1'],
|
||||
['release', '2.7.6-rc.1', '2.7.6'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'minor',
|
||||
items: [['minor', '2.7.5', '2.8.0']],
|
||||
},
|
||||
{
|
||||
name: 'preminor',
|
||||
items: [
|
||||
['preminor', '2.7.5', '2.8.0-rc.0'],
|
||||
['prerelease', '2.8.0-rc.0', '2.8.0-rc.1'],
|
||||
['release', '2.8.0-rc.1', '2.8.0'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'premajor',
|
||||
items: [
|
||||
['premajor', '2.7.5', '3.0.0-rc.0'],
|
||||
['prerelease', '3.0.0-rc.0', '3.0.0-rc.1'],
|
||||
['release', '3.0.0-rc.1', '3.0.0'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const group of valid) {
|
||||
describe(group.name, () => {
|
||||
it.each(group.items)(
|
||||
'should allow a $0 from $1 to $2',
|
||||
(type, version, next) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: next,
|
||||
exitCode: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('invalid', () => {
|
||||
it.each([
|
||||
['patch', 'v3.0.0-rc.0'],
|
||||
['prepatch', 'v3.0.0-rc.0'],
|
||||
['minor', 'v3.0.0-rc.0'],
|
||||
['preminor', 'v3.0.0-rc.0'],
|
||||
['premajor', 'v3.0.0-rc.0'],
|
||||
['prerelease', 'v3.0.0'],
|
||||
['release', 'v3.0.0'],
|
||||
])('should not allow a $0 on $1', (type, version) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Invalid pump'),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -137,7 +138,7 @@ class RemoteAlbumService {
|
||||
Future<RemoteAlbum> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
String? description,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diacritic/diacritic.dart' as diacritic;
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
|
||||
}
|
||||
|
||||
String? get nullIfEmpty => isEmpty ? null : this;
|
||||
|
||||
String removeDiacritics() => diacritic.removeDiacritics(this);
|
||||
}
|
||||
|
||||
extension DurationExtension on String {
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -65,9 +64,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
data: (people) {
|
||||
if (_search != null) {
|
||||
people = people.where((person) {
|
||||
return person.name.toLowerCase().removeDiacritics().contains(
|
||||
_search!.toLowerCase().removeDiacritics(),
|
||||
);
|
||||
return person.name.toLowerCase().contains(_search!.toLowerCase());
|
||||
}).toList();
|
||||
}
|
||||
return GridView.builder(
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dar
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
|
||||
|
||||
@@ -247,10 +248,13 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
||||
try {
|
||||
final newTitle = titleController.text.trim();
|
||||
final newDescription = descriptionController.text.trim();
|
||||
final description = newDescription.isEmpty
|
||||
? const Option<String?>.some(null)
|
||||
: Option<String?>.some(newDescription);
|
||||
|
||||
await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: description);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
@@ -16,68 +14,21 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class ArchiveBottomSheet extends ConsumerStatefulWidget {
|
||||
class ArchiveBottomSheet extends ConsumerWidget {
|
||||
const ArchiveBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ArchiveBottomSheet> createState() => _ArchiveBottomSheetState();
|
||||
}
|
||||
|
||||
class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
late final DraggableScrollableController sheetController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
sheetController = DraggableScrollableController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
sheetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.count == 0
|
||||
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
|
||||
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onKeyboardExpand() {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.85,
|
||||
maxChildSize: 0.4,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
@@ -97,10 +48,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
],
|
||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class MapBottomSheet extends StatelessWidget {
|
||||
resizeOnScroll: false,
|
||||
actions: [],
|
||||
backgroundColor: context.themeData.colorScheme.surface,
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: true, child: _ScopedMapTimeline())],
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -132,7 +133,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
// When the AssetViewer is open, the DriftMap route stays alive in the background.
|
||||
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
|
||||
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
|
||||
if (ref.read(isAssetViewerOpenProvider)) {
|
||||
final currentRoute = ref.read(currentRouteNameProvider);
|
||||
if (currentRoute == AssetViewerRoute.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,11 +183,6 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<bool>(isAssetViewerOpenProvider, (previous, current) {
|
||||
if (previous == true && !current) {
|
||||
_debouncer.run(() => setBounds(forceReload: true));
|
||||
}
|
||||
});
|
||||
return Stack(
|
||||
children: [
|
||||
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
@@ -153,7 +154,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
String? description,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
|
||||
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart' hide AlbumUserRole;
|
||||
|
||||
@@ -71,7 +72,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
String albumId,
|
||||
UserDto owner, {
|
||||
String? name,
|
||||
String? description,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
@@ -86,7 +87,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
albumId,
|
||||
UpdateAlbumDto(
|
||||
albumName: name == null ? const Optional.absent() : Optional.present(name),
|
||||
description: description == null ? const Optional.absent() : Optional.present(description),
|
||||
description: description.toOptional(),
|
||||
albumThumbnailAssetId: thumbnailAssetId == null
|
||||
? const Optional.absent()
|
||||
: Optional.present(thumbnailAssetId),
|
||||
|
||||
@@ -24,20 +24,9 @@ class AppNavigationObserver extends AutoRouterObserver {
|
||||
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
||||
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
||||
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
|
||||
if (route.settings.name == AssetViewerRoute.name) {
|
||||
ref.read(isAssetViewerOpenProvider.notifier).state = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
_handleDriftLockedFolderState(previousRoute ?? route, null);
|
||||
if (route.settings.name == AssetViewerRoute.name) {
|
||||
Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false);
|
||||
}
|
||||
}
|
||||
|
||||
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final isFromLockedViewToDetailView =
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/search/people.provider.dart';
|
||||
@@ -45,19 +44,16 @@ class PeoplePicker extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: people.widgetWhen(
|
||||
onData: (people) {
|
||||
final filtered = people
|
||||
.where(
|
||||
(person) => person.name.toLowerCase().removeDiacritics().contains(
|
||||
searchQuery.value.toLowerCase().removeDiacritics(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: filtered.length,
|
||||
itemCount: people
|
||||
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.length,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
final person = filtered[index];
|
||||
final person = people
|
||||
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.toList()[index];
|
||||
final isSelected = selectedPeople.value.contains(person);
|
||||
|
||||
return Padding(
|
||||
|
||||
Generated
+11
-3
@@ -1067,7 +1067,9 @@ class AssetsApi {
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
///
|
||||
/// * [int] xImmichHlsMsn:
|
||||
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, int? xImmichHlsMsn, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}'
|
||||
.replaceAll('{filename}', filename)
|
||||
@@ -1089,6 +1091,10 @@ class AssetsApi {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
if (xImmichHlsMsn != null) {
|
||||
headerParams[r'x-immich-hls-msn'] = parameterToString(xImmichHlsMsn);
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
@@ -1121,8 +1127,10 @@ class AssetsApi {
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, abortTrigger: abortTrigger,);
|
||||
///
|
||||
/// * [int] xImmichHlsMsn:
|
||||
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, int? xImmichHlsMsn, Future<void>? abortTrigger, }) async {
|
||||
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, xImmichHlsMsn: xImmichHlsMsn, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
+2
-2
@@ -95,9 +95,9 @@ class AssetBulkUpdateDto {
|
||||
///
|
||||
Optional<num?> longitude;
|
||||
|
||||
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 5
|
||||
Optional<int?> rating;
|
||||
|
||||
|
||||
+2
-2
@@ -77,9 +77,9 @@ class UpdateAssetDto {
|
||||
///
|
||||
Optional<num?> longitude;
|
||||
|
||||
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 5
|
||||
Optional<int?> rating;
|
||||
|
||||
|
||||
@@ -354,14 +354,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
diacritic:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: diacritic
|
||||
sha256: "12981945ec38931748836cd76f2b38773118d0baef3c68404bdfde9566147876"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.6"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -18,7 +18,6 @@ dependencies:
|
||||
crop_image: ^1.0.17
|
||||
crypto: ^3.0.7
|
||||
device_info_plus: ^12.4.0
|
||||
diacritic: ^0.1.6
|
||||
drift: ^2.32.1
|
||||
drift_sqlite_async: 0.3.1
|
||||
dynamic_color: ^1.8.1
|
||||
|
||||
@@ -18,56 +18,6 @@ void main() {
|
||||
expect("a:b:c".toDuration(), isNull);
|
||||
});
|
||||
});
|
||||
group('Test removeDiacritics', () {
|
||||
test('removes acute accents', () {
|
||||
expect('Amélie'.removeDiacritics(), 'Amelie');
|
||||
});
|
||||
|
||||
test('removes grave accents', () {
|
||||
expect('À la carte'.removeDiacritics(), 'A la carte');
|
||||
});
|
||||
|
||||
test('removes circumflex', () {
|
||||
expect('hôpital'.removeDiacritics(), 'hopital');
|
||||
});
|
||||
|
||||
test('removes tilde', () {
|
||||
expect('São João'.removeDiacritics(), 'Sao Joao');
|
||||
});
|
||||
|
||||
test('removes diaeresis', () => expect('naïve'.removeDiacritics(), 'naive'));
|
||||
|
||||
test('removes cedilla', () => expect('ça va'.removeDiacritics(), 'ca va'));
|
||||
|
||||
test('handles Hungarian exteded characters (ű/ő)', () {
|
||||
expect('árvíztűrő tükörfúrógép'.removeDiacritics(), 'arvizturo tukorfurogep');
|
||||
});
|
||||
|
||||
test('handles Polish characters', () {
|
||||
expect('Jędrzej Łącki'.removeDiacritics(), 'Jedrzej Lacki');
|
||||
});
|
||||
|
||||
test('handles German umlauts', () => expect('Müller'.removeDiacritics(), 'Muller'));
|
||||
|
||||
test('handles Nordic characters', () => expect('Göteborg'.removeDiacritics(), 'Goteborg'));
|
||||
|
||||
test('handles empty string', () => expect(''.removeDiacritics(), ''));
|
||||
|
||||
test('handles string with no diacritics', () {
|
||||
expect('hello world'.removeDiacritics(), 'hello world');
|
||||
});
|
||||
|
||||
test('handles Ñ/ñ', () => expect('Niño'.removeDiacritics(), 'Nino'));
|
||||
|
||||
test('diacritic removal is order-independent', () {
|
||||
const raw = 'Árvíztűrő';
|
||||
expect(
|
||||
raw.toLowerCase().removeDiacritics(),
|
||||
raw.removeDiacritics().toLowerCase(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Test uniqueConsecutive', () {
|
||||
test('empty', () {
|
||||
final a = [];
|
||||
|
||||
@@ -4734,6 +4734,16 @@
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-immich-hls-msn",
|
||||
"required": false,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -16602,9 +16612,9 @@
|
||||
"type": "number"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)",
|
||||
"description": "Rating in range [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 1,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -16616,10 +16626,15 @@
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using 0 as a rating is no longer valid."
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
@@ -26425,9 +26440,9 @@
|
||||
"type": "number"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)",
|
||||
"description": "Rating in range [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 1,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -26439,10 +26454,15 @@
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using 0 as a rating is no longer valid."
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
|
||||
+2
-9
@@ -2,24 +2,17 @@
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.7.5",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check i18n/",
|
||||
"format:fix": "prettier --cache --write --list-different i18n",
|
||||
"test": "vitest",
|
||||
"release": "./misc/release/pump-version.sh",
|
||||
"pump": "node ./misc/release/pump-wrapper.js"
|
||||
"format:fix": "prettier --cache --write --list-different i18n"
|
||||
},
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.4",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-sort-json": "^4.2.0",
|
||||
"semver": "^7.8.1",
|
||||
"vitest": "^4.1.8"
|
||||
"prettier-plugin-sort-json": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,26 +55,6 @@
|
||||
}
|
||||
],
|
||||
"uiHints": ["SmartAlbum"]
|
||||
},
|
||||
{
|
||||
"name": "location-smart-album",
|
||||
"title": "Location-based album",
|
||||
"description": "Automatically add assets taken in a specific location to an album",
|
||||
"trigger": "AssetMetadataExtraction",
|
||||
"steps": [
|
||||
{
|
||||
"method": "immich-plugin-core#assetLocationFilter",
|
||||
"config": { "region": { "city": "Vancouver", "state": "British Columbia", "country": "Canada" } }
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumName": "Vancouver photos & videos",
|
||||
"albumIds": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"uiHints": ["SmartAlbum"]
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
@@ -127,62 +107,6 @@
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetLocationFilter",
|
||||
"title": "Filter assets by geolocation",
|
||||
"description": "Filter assets by where they were taken",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"region": {
|
||||
"type": "object",
|
||||
"title": "Region",
|
||||
"description": "Filter by region name",
|
||||
"properties": {
|
||||
"country": {
|
||||
"type": "string",
|
||||
"title": "Country",
|
||||
"description": "Exact name of the country the asset must be taken in"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"title": "State/province",
|
||||
"description": "Exact name of the state/province the asset must be taken in"
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"title": "City",
|
||||
"description": "Exact name of the city the asset must be taken in"
|
||||
}
|
||||
}
|
||||
},
|
||||
"coordinate": {
|
||||
"type": "object",
|
||||
"title": "Coordinate",
|
||||
"description": "Filter by distance to a coordinate",
|
||||
"properties": {
|
||||
"latitude": {
|
||||
"type": "string",
|
||||
"title": "Latitude",
|
||||
"description": "GPS latitude of a coordinate which the asset must be close to"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "string",
|
||||
"title": "Longitude",
|
||||
"description": "GPS longitude of a coordinate which the asset must be close to"
|
||||
},
|
||||
"radius": {
|
||||
"type": "number",
|
||||
"title": "Maximum distance",
|
||||
"description": "How close in kilometres the asset must be to the given point"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "filterFileType",
|
||||
"title": "Filter by file type",
|
||||
|
||||
Vendored
-1
@@ -13,7 +13,6 @@ declare module 'main' {
|
||||
// filters
|
||||
export function assetFileFilter(): I32;
|
||||
export function assetMissingTimeZoneFilter(): I32;
|
||||
export function assetLocationFilter(): I32;
|
||||
|
||||
// updates
|
||||
export function assetFavorite(): I32;
|
||||
|
||||
@@ -50,51 +50,6 @@ export const assetMissingTimeZoneFilter = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const assetLocationFilter = () => {
|
||||
return wrapper<
|
||||
WorkflowType.AssetV1,
|
||||
{
|
||||
region?: { country?: string; state?: string; city?: string };
|
||||
coordinate?: { latitude?: string; longitude?: string; radius?: number };
|
||||
}
|
||||
>(({ config, data }) => {
|
||||
if (
|
||||
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
|
||||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
|
||||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
|
||||
) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
|
||||
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
|
||||
|
||||
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
|
||||
return { workflow: { continue: true } };
|
||||
}
|
||||
|
||||
const assetLat = data.asset.exifInfo?.latitude;
|
||||
const assetLon = data.asset.exifInfo?.longitude;
|
||||
|
||||
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
const earthDiameter = 12742;
|
||||
const deg = Math.PI / 180;
|
||||
const delta = Math.asin(
|
||||
Math.sqrt(
|
||||
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
|
||||
Math.cos(assetLat * deg) *
|
||||
Math.cos(configLat * deg) *
|
||||
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
|
||||
),
|
||||
);
|
||||
|
||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetFavorite = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
|
||||
@@ -672,7 +672,7 @@ export type AssetBulkUpdateDto = {
|
||||
latitude?: number;
|
||||
/** Longitude coordinate */
|
||||
longitude?: number;
|
||||
/** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */
|
||||
/** Rating in range [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
/** Time zone (IANA timezone) */
|
||||
timeZone?: string;
|
||||
@@ -919,7 +919,7 @@ export type UpdateAssetDto = {
|
||||
livePhotoVideoId?: string | null;
|
||||
/** Longitude coordinate */
|
||||
longitude?: number;
|
||||
/** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */
|
||||
/** Rating in range [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
visibility?: AssetVisibility;
|
||||
};
|
||||
@@ -4366,13 +4366,14 @@ export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex }: {
|
||||
/**
|
||||
* Get HLS segment or init file
|
||||
*/
|
||||
export function getSegment({ filename, id, key, sessionId, slug, variantIndex }: {
|
||||
export function getSegment({ filename, id, key, sessionId, slug, variantIndex, xImmichHlsMsn }: {
|
||||
filename: string;
|
||||
id: string;
|
||||
key?: string;
|
||||
sessionId: string;
|
||||
slug?: string;
|
||||
variantIndex: number;
|
||||
xImmichHlsMsn?: number;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
@@ -4381,7 +4382,10 @@ export function getSegment({ filename, id, key, sessionId, slug, variantIndex }:
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts
|
||||
...opts,
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"x-immich-hls-msn": xImmichHlsMsn
|
||||
})
|
||||
}));
|
||||
}
|
||||
/**
|
||||
|
||||
Generated
+226
-252
File diff suppressed because it is too large
Load Diff
@@ -240,16 +240,7 @@ describe(AssetController.name, () => {
|
||||
for (const [test, errors] of [
|
||||
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
|
||||
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
|
||||
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
|
||||
[
|
||||
{ rating: 0 },
|
||||
[
|
||||
{
|
||||
path: ['rating'],
|
||||
message: 'Rating must be -1 (rejected), 1–5 (starred), or null (unrated); 0 is not valid',
|
||||
},
|
||||
],
|
||||
],
|
||||
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=1' }]],
|
||||
] as const) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Controller, Delete, Get, Header, HttpCode, HttpStatus, Next, Param, Res } from '@nestjs/common';
|
||||
import { Controller, Delete, Get, Header, Headers, HttpCode, HttpStatus, Next, Param, Res } from '@nestjs/common';
|
||||
import { ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { ZodValidationException } from 'nestjs-zod';
|
||||
import { HLS_PLAYLIST_CONTENT_TYPE } from 'src/constants';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { HlsSegmentParamDto, HlsSessionParamDto, HlsVariantParamDto } from 'src/dtos/streaming.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import {
|
||||
HlsSegmentHeaderDto,
|
||||
HlsSegmentParamDto,
|
||||
HlsSessionParamDto,
|
||||
HlsVariantParamDto,
|
||||
} from 'src/dtos/streaming.dto';
|
||||
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { HlsService } from 'src/services/hls.service';
|
||||
@@ -59,10 +65,21 @@ export class VideoStreamController {
|
||||
async getSegment(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id, sessionId, variantIndex, filename }: HlsSegmentParamDto,
|
||||
@Headers() headers: HlsSegmentHeaderDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.getSegment(auth, id, sessionId, variantIndex, filename), this.logger);
|
||||
try {
|
||||
headers = HlsSegmentHeaderDto.create(headers);
|
||||
} catch (error) {
|
||||
throw new ZodValidationException(error);
|
||||
}
|
||||
await sendFile(
|
||||
res,
|
||||
next,
|
||||
() => this.service.getSegment(auth, id, sessionId, variantIndex, filename, headers[ImmichHeader.HlsInitSegment]),
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/video/stream/:sessionId')
|
||||
|
||||
@@ -15,18 +15,16 @@ const UpdateAssetBaseSchema = z
|
||||
longitude: longitudeSchema.optional().describe('Longitude coordinate'),
|
||||
rating: z
|
||||
.int()
|
||||
.min(-1)
|
||||
.min(1)
|
||||
.max(5)
|
||||
.nullish()
|
||||
.refine((v) => v !== 0, {
|
||||
error: 'Rating must be -1 (rejected), 1–5 (starred), or null (unrated); 0 is not valid',
|
||||
})
|
||||
.describe('Rating in range [1-5] (starred), -1 (rejected), or null (unrated)')
|
||||
.describe('Rating in range [1-5], or null for unrated')
|
||||
.meta({
|
||||
...new HistoryBuilder()
|
||||
.added('v1')
|
||||
.stable('v2')
|
||||
.updated('v3', 'Using 0 as a rating is no longer valid.')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
|
||||
.updated('v3', 'Using -1 as a rating is no longer valid.')
|
||||
.getExtensions(),
|
||||
}),
|
||||
description: z.string().optional().describe('Asset description'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import z from 'zod';
|
||||
|
||||
const HlsSessionParamSchema = z.object({
|
||||
@@ -24,3 +25,10 @@ const HlsSegmentParamSchema = z.object({
|
||||
});
|
||||
|
||||
export class HlsSegmentParamDto extends createZodDto(HlsSegmentParamSchema) {}
|
||||
|
||||
const HlsSegmentHeaderSchema = z.object({
|
||||
// Lets the client hint at which segment will be loaded after init.mp4.
|
||||
[ImmichHeader.HlsInitSegment]: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export class HlsSegmentHeaderDto extends createZodDto(HlsSegmentHeaderSchema) {}
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum ImmichHeader {
|
||||
SharedLinkSlug = 'x-immich-share-slug',
|
||||
Checksum = 'x-immich-checksum',
|
||||
CorrelationId = 'X-Correlation-ID',
|
||||
HlsInitSegment = 'x-immich-hls-msn',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { JobsOptions, Queue, Worker } from 'bullmq';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { JobConfig } from 'src/decorators';
|
||||
import { QueueJobResponseDto, QueueJobSearchDto } from 'src/dtos/queue.dto';
|
||||
import { ImmichWorker, JobName, JobStatus, MetadataKey, QueueCleanType, QueueJobStatus, QueueName } from 'src/enum';
|
||||
import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueJobStatus, QueueName } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -19,14 +19,10 @@ type JobMapItem = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
const WORKER_WATCH_INTERVAL_MS = 30_000;
|
||||
|
||||
@Injectable()
|
||||
export class JobRepository {
|
||||
private workers: Partial<Record<QueueName, Worker>> = {};
|
||||
private handlers: Partial<Record<JobName, JobMapItem>> = {};
|
||||
private workerWatcher?: ReturnType<typeof setInterval>;
|
||||
private microservicesPresent = true;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
@@ -94,44 +90,11 @@ export class JobRepository {
|
||||
this.workers[queueName] = new Worker(
|
||||
queueName,
|
||||
(job) => this.eventRepository.emit('JobRun', queueName, job as JobItem),
|
||||
{ ...bull.config, concurrency: 1, name: ImmichWorker.Microservices },
|
||||
{ ...bull.config, concurrency: 1 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
watchWorkers() {
|
||||
this.workerWatcher ??= setInterval(() => void this.checkWorkers(), WORKER_WATCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if (this.workerWatcher) {
|
||||
clearInterval(this.workerWatcher);
|
||||
this.workerWatcher = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkWorkers() {
|
||||
let present: boolean;
|
||||
try {
|
||||
const suffix = `:w:${ImmichWorker.Microservices}`;
|
||||
const workers = await this.getQueue(QueueName.BackgroundTask).getWorkers();
|
||||
present = workers.some((worker) => worker.rawname?.endsWith(suffix));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.microservicesPresent !== present) {
|
||||
if (present) {
|
||||
this.logger.log('Microservices worker connected.');
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'No microservices worker is connected. Background jobs will not be processed until one is running.',
|
||||
);
|
||||
}
|
||||
}
|
||||
this.microservicesPresent = present;
|
||||
}
|
||||
|
||||
async run({ name, data }: JobItem) {
|
||||
const item = this.handlers[name as JobName];
|
||||
if (!item) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export async function up(): Promise<void> {
|
||||
// await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db);
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
|
||||
@@ -256,7 +256,7 @@ describe(HlsService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns lastRequested + 1 for init.mp4 after a segment has been served', async () => {
|
||||
it('returns lastRequested + 1 for init.mp4 without a target segment', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
|
||||
mocks.websocket.serverSend.mockClear();
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
|
||||
@@ -313,6 +313,35 @@ describe(HlsService.name, () => {
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the target segment for init.mp4 when provided', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 7);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the target segment over the lastRequested + 1 fallback', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s'); // fallback would be 6
|
||||
mocks.websocket.serverSend.mockClear();
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 12);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores the target segment for media segment requests (the filename wins)', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s', 99);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('endSession', () => {
|
||||
|
||||
@@ -82,7 +82,14 @@ export class HlsService extends BaseService {
|
||||
return this.generateMediaPlaylist(asset);
|
||||
}
|
||||
|
||||
async getSegment(auth: AuthDto, assetId: string, sessionId: string, variantIndex: number, filename: string) {
|
||||
async getSegment(
|
||||
auth: AuthDto,
|
||||
assetId: string,
|
||||
sessionId: string,
|
||||
variantIndex: number,
|
||||
filename: string,
|
||||
initSegment?: number,
|
||||
) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
|
||||
|
||||
const session = await this.videoStreamRepository.getSession(sessionId);
|
||||
@@ -99,7 +106,7 @@ export class HlsService extends BaseService {
|
||||
});
|
||||
|
||||
const apiSession = this.trackSession(sessionId, variantIndex);
|
||||
const segmentIndex = this.getSegmentIndex(apiSession, filename);
|
||||
const segmentIndex = this.getSegmentIndex(apiSession, filename, initSegment);
|
||||
this.websocketRepository.serverSend('HlsHeartbeat', { sessionId, variantIndex, segmentIndex });
|
||||
|
||||
if (await this.storageRepository.checkFileExists(path, constants.R_OK)) {
|
||||
@@ -172,9 +179,13 @@ export class HlsService extends BaseService {
|
||||
return `${sessionId}:${variantIndex}:${segmentIndex}`;
|
||||
}
|
||||
|
||||
private getSegmentIndex(session: ApiSession, filename: string) {
|
||||
private getSegmentIndex(session: ApiSession, filename: string, initSegment?: number) {
|
||||
if (filename.endsWith('.mp4')) {
|
||||
return (session.lastRequestedSegment ?? -1) + 1;
|
||||
// We need to know where to start transcoding, but the init.mp4 has no segment number in its name.
|
||||
// We can infer this from the last requested segment, but this can be inaccurate given the client
|
||||
// can load cached segments without reaching out to the server. `initSegment` acts as a hint to
|
||||
// remove ambiguity when possible.
|
||||
return initSegment ?? (session.lastRequestedSegment ?? -1) + 1;
|
||||
}
|
||||
const segmentIndex = Number.parseInt(HLS_SEGMENT_FILENAME_REGEX.exec(filename)![1]);
|
||||
session.lastRequestedSegment = segmentIndex;
|
||||
|
||||
@@ -80,16 +80,9 @@ export class QueueService extends BaseService {
|
||||
this.jobRepository.setup(this.services);
|
||||
if (this.worker === ImmichWorker.Microservices) {
|
||||
this.jobRepository.startWorkers();
|
||||
} else if (this.worker === ImmichWorker.Api) {
|
||||
this.jobRepository.watchWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppShutdown' })
|
||||
onShutdown() {
|
||||
this.jobRepository.teardown();
|
||||
}
|
||||
|
||||
private updateConcurrency(config: SystemConfig) {
|
||||
this.logger.debug(`Updating queue concurrency settings`);
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
|
||||
@@ -30,6 +30,7 @@ type Session = {
|
||||
ownerId: string;
|
||||
paused: boolean;
|
||||
process: ChildProcess | null;
|
||||
starting: boolean;
|
||||
startSegment: number | null;
|
||||
variantIndex: number | null;
|
||||
};
|
||||
@@ -75,6 +76,7 @@ export class TranscodingService extends BaseService {
|
||||
ownerId,
|
||||
paused: false,
|
||||
process: null,
|
||||
starting: false,
|
||||
startSegment: null,
|
||||
variantIndex: null,
|
||||
});
|
||||
@@ -145,11 +147,19 @@ export class TranscodingService extends BaseService {
|
||||
} else if (session.process) {
|
||||
this.resumeTranscode(session);
|
||||
return;
|
||||
} else if (session.starting) {
|
||||
this.logger.debug(`Session ${sessionId} is already starting a transcode, skipping duplicate start request`);
|
||||
return;
|
||||
}
|
||||
|
||||
const process = await this.startTranscode(session, variantIndex, segmentIndex);
|
||||
if (process) {
|
||||
session.process = process;
|
||||
session.starting = true;
|
||||
try {
|
||||
const process = await this.startTranscode(session, variantIndex, segmentIndex);
|
||||
if (process) {
|
||||
session.process = process;
|
||||
}
|
||||
} finally {
|
||||
session.starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -332,75 +332,4 @@ describe('core plugin', () => {
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetLocationFilter', () => {
|
||||
it('should favorite an asset within a given radius', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, latitude: 49.273_353_221_145_36, longitude: -123.103_871_440_787_64 });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetMetadataExtraction,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#assetLocationFilter',
|
||||
config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 2 } },
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetFavorite',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
});
|
||||
|
||||
it('should not favorite asset outside a given radius', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, latitude: 49.261_266_052_570_35, longitude: -123.248_959_390_781_96 });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetMetadataExtraction,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#assetLocationFilter',
|
||||
config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 10 } },
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetFavorite',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
|
||||
});
|
||||
|
||||
it('should favorite asset by location name', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, city: 'Vancouver' });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetMetadataExtraction,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#assetLocationFilter',
|
||||
config: { region: { city: 'Vancouver' } },
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetFavorite',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ export const newJobRepositoryMock = (): Mocked<RepositoryInterface<JobRepository
|
||||
return {
|
||||
setup: vitest.fn(),
|
||||
startWorkers: vitest.fn(),
|
||||
watchWorkers: vitest.fn(),
|
||||
teardown: vitest.fn(),
|
||||
run: vitest.fn(),
|
||||
setConcurrency: vitest.fn(),
|
||||
empty: vitest.fn(),
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['misc/**/*.spec.js'],
|
||||
},
|
||||
});
|
||||
+2
-2
@@ -40,14 +40,14 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@zoom-image/core": "^0.42.0",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
"custom-media-element": "^1.4.6",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"fabric": "^7.0.0",
|
||||
"geo-coordinates-parser": "^1.7.4",
|
||||
"geojson": "^0.5.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"happy-dom": "^20.0.0",
|
||||
"hls-video-element": "^1.5.11",
|
||||
"hls.js": "^1.6.16",
|
||||
"hls.js": "1.7.0-beta.1.0.canary.11837",
|
||||
"intl-messageformat": "^11.0.0",
|
||||
"justified-layout": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -176,3 +176,9 @@
|
||||
@apply bg-subtle rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
immich-video > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--media-object-fit, contain);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let rating = $derived(asset.exifInfo?.rating ?? null) as Rating;
|
||||
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
|
||||
|
||||
const handleChangeRating = async (rating: Rating) => {
|
||||
const handleChangeRating = async (rating: number | null) => {
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
|
||||
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
|
||||
import { getAssetHlsSessionUrl, getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import '$lib/components/asset-viewer/immich-video-element';
|
||||
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||
import VideoQualityMenu from '$lib/components/asset-viewer/VideoQualityMenu.svelte';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, LoadingSpinner, shortcuts } from '@immich/ui';
|
||||
import {
|
||||
@@ -23,9 +25,6 @@
|
||||
mdiVolumeMedium,
|
||||
mdiVolumeMute,
|
||||
} from '@mdi/js';
|
||||
import 'hls-video-element';
|
||||
import type HlsVideoElement from 'hls-video-element';
|
||||
import Hls, { AbrController, Events, type FragLoadedData, type FragLoadingData, type HlsConfig } from 'hls.js';
|
||||
import 'media-chrome/media-control-bar';
|
||||
import 'media-chrome/media-controller';
|
||||
import 'media-chrome/media-fullscreen-button';
|
||||
@@ -35,11 +34,10 @@
|
||||
import 'media-chrome/media-time-display';
|
||||
import 'media-chrome/media-volume-range';
|
||||
import 'media-chrome/menu/media-playback-rate-menu';
|
||||
import 'media-chrome/menu/media-rendition-menu';
|
||||
import 'media-chrome/menu/media-settings-menu';
|
||||
import 'media-chrome/menu/media-settings-menu-button';
|
||||
import 'media-chrome/menu/media-settings-menu-item';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -73,7 +71,6 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
let isLoading = $state(true);
|
||||
let assetFileUrl = $derived.by(() => {
|
||||
if (featureFlagsManager.value.realtimeTranscoding) {
|
||||
@@ -88,182 +85,29 @@
|
||||
});
|
||||
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
|
||||
let showVideo = $state(false);
|
||||
let hasFocused = $state(false);
|
||||
let activeSession: { assetId: string; id: string } | undefined;
|
||||
let rebuildCount = 0;
|
||||
let focusedAssetId = $state<string>();
|
||||
|
||||
const MAX_REBUILDS = 1;
|
||||
const SESSION_ID_REGEX = /\/video\/stream\/([0-9a-f-]{36})\//;
|
||||
|
||||
// hls.js can abandon fetching an in-flight fragment if it thinks it'll take too long, in which case
|
||||
// it emergency switches to a different variant. This extends the delay even further due to
|
||||
// cold starting another transcode, so let the fragment finish and have steady ABR decide the next level.
|
||||
//
|
||||
// It can also emergency switch between fragments: while a switch's first segment is still loading,
|
||||
// it can run out of buffer and drop to a lower level for just one segment before continuing at the switched quality.
|
||||
// This can cause multiple redundant transcoding restarts when it occurs.
|
||||
// Hold the committed level until its first fragment lands, then resume normal ABR.
|
||||
class NoAbandonAbrController extends AbrController {
|
||||
private switchTarget = -1;
|
||||
|
||||
protected override onFragLoading(_event: Events.FRAG_LOADING, data: FragLoadingData) {
|
||||
if (data.frag.sn === 'initSegment') {
|
||||
this.switchTarget = data.frag.level;
|
||||
}
|
||||
}
|
||||
|
||||
protected override onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
|
||||
if (data.frag.sn !== 'initSegment') {
|
||||
this.switchTarget = -1;
|
||||
}
|
||||
super.onFragLoaded(event, data);
|
||||
}
|
||||
|
||||
override get nextAutoLevel(): number {
|
||||
const level = super.nextAutoLevel;
|
||||
const target = this.hls.levels[this.switchTarget];
|
||||
// Hold the committed level, but only while hls.js still considers it healthy.
|
||||
if (target && level < this.switchTarget && target.loadError === 0 && target.fragmentError === 0) {
|
||||
return this.switchTarget;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
override set nextAutoLevel(level: number) {
|
||||
super.nextAutoLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
const hlsConfig: Partial<HlsConfig> = {
|
||||
abrController: NoAbandonAbrController,
|
||||
highBufferWatchdogPeriod: 10,
|
||||
detectStallWithCurrentTimeMs: 10_000,
|
||||
maxBufferHole: 0.5,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
fragLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: 30_000,
|
||||
maxLoadTimeMs: 60_000,
|
||||
timeoutRetry: { maxNumRetry: 5, retryDelayMs: 100, maxRetryDelayMs: 0 },
|
||||
errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
|
||||
},
|
||||
},
|
||||
useMediaCapabilities: false,
|
||||
};
|
||||
|
||||
const releaseSession = () => {
|
||||
const session = activeSession;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
activeSession = undefined;
|
||||
const url = getAssetHlsSessionUrl(session.assetId, session.id);
|
||||
void fetch(url, { method: 'DELETE' }).catch(() => console.warn('Failed to release HLS session', session));
|
||||
};
|
||||
|
||||
const isHlsElement = (el: HTMLVideoElement | undefined): el is HlsVideoElement => {
|
||||
return el?.tagName === 'HLS-VIDEO';
|
||||
};
|
||||
|
||||
const wireHlsListeners = (el: HlsVideoElement, assetId: string, resumeTime?: number) => {
|
||||
const api = el.api;
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a hack to make the rendition menu use `api.currentLevel` instead of `api.nextLevel`.
|
||||
// `api.nextLevel` makes the player request the next segment followed by the current segment.
|
||||
// That backward request causes the server to restart transcoding for no reason.
|
||||
Object.defineProperty(api, 'nextLevel', {
|
||||
configurable: true,
|
||||
get: () => api.currentLevel,
|
||||
set: (level: number) => {
|
||||
api.currentLevel = level;
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
api.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||
// Defer hls.js's first fragment load until we filter out suboptimal variants
|
||||
api.stopLoad();
|
||||
const id = api.levels[0]?.url[0]?.match(SESSION_ID_REGEX)?.[1];
|
||||
if (id) {
|
||||
activeSession = { assetId, id };
|
||||
}
|
||||
|
||||
const keep = await mediaCapabilitiesManager.efficientLevels(api.levels);
|
||||
for (let i = api.levels.length - 1; i >= 0; i--) {
|
||||
if (!keep.has(i)) {
|
||||
api.removeLevel(i);
|
||||
}
|
||||
}
|
||||
|
||||
api.startLoad(resumeTime);
|
||||
});
|
||||
|
||||
api.on(Hls.Events.FRAG_LOADED, () => (rebuildCount = 0));
|
||||
|
||||
api.on(Hls.Events.ERROR, (_, data) => {
|
||||
// 404 on a fragment can mean the server-side session has expired. Refetch
|
||||
// master for a new session, but give up if it still 404s.
|
||||
if (
|
||||
!data.fatal ||
|
||||
data.details !== Hls.ErrorDetails.FRAG_LOAD_ERROR ||
|
||||
data.response?.code !== 404 ||
|
||||
rebuildCount++ >= MAX_REBUILDS
|
||||
) {
|
||||
console.error('HLS error', JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
console.warn('Error loading segment, starting new session');
|
||||
activeSession = undefined;
|
||||
resumeTime = el.currentTime;
|
||||
el.load();
|
||||
// wireHlsListeners must run after el.api is repopulated.
|
||||
queueMicrotask(() => wireHlsListeners(el, assetId, resumeTime));
|
||||
});
|
||||
};
|
||||
const controller = $derived(videoSessionManager.get(assetId)); // <immich-video> self-acquires the controller for the asset
|
||||
const videoPlayer = $derived(controller?.element);
|
||||
|
||||
onMount(() => {
|
||||
showVideo = true;
|
||||
});
|
||||
|
||||
// A hover-warmed element is already past `canplay` and won't fire it again, so kick playback ourselves once we adopt it
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (videoPlayer && assetFileUrl) {
|
||||
hasFocused = false;
|
||||
rebuildCount = 0;
|
||||
releaseSession();
|
||||
if (isHlsElement(videoPlayer)) {
|
||||
videoPlayer.config = hlsConfig;
|
||||
videoPlayer.src = assetFileUrl;
|
||||
const el = videoPlayer;
|
||||
queueMicrotask(() => wireHlsListeners(el, assetId));
|
||||
} else {
|
||||
videoPlayer.load();
|
||||
}
|
||||
if (videoPlayer && videoPlayer.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
void handleCanPlay(videoPlayer);
|
||||
}
|
||||
return releaseSession;
|
||||
});
|
||||
|
||||
const onPagehide = (event: PageTransitionEvent) => {
|
||||
if (!event.persisted) {
|
||||
releaseSession();
|
||||
const onPlaying = () => {
|
||||
if (focusedAssetId !== assetId) {
|
||||
videoPlayer?.focus();
|
||||
focusedAssetId = assetId;
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
window.addEventListener('pagehide', onPagehide);
|
||||
return () => window.removeEventListener('pagehide', onPagehide);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (videoPlayer) {
|
||||
videoPlayer.src = '';
|
||||
}
|
||||
});
|
||||
|
||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
if (!video.paused) {
|
||||
@@ -352,53 +196,23 @@
|
||||
class="dark h-full max-w-full"
|
||||
style:aspect-ratio={aspectRatio}
|
||||
defaultduration={asset.duration! / 1000}
|
||||
{...useSwipe(onSwipe)}
|
||||
>
|
||||
{#if featureFlagsManager.value.realtimeTranscoding}
|
||||
<hls-video
|
||||
bind:this={videoPlayer}
|
||||
slot="media"
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
disablePictureInPicture
|
||||
playsinline
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full object-contain"
|
||||
oncanplay={(e: Event) => handleCanPlay(e.currentTarget as HTMLVideoElement)}
|
||||
onended={onVideoEnded}
|
||||
onseeking={onSeeking}
|
||||
onplaying={(e: Event) => {
|
||||
if (!hasFocused) {
|
||||
(e.currentTarget as HTMLElement).focus();
|
||||
hasFocused = true;
|
||||
}
|
||||
}}
|
||||
onclose={onClose}
|
||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
|
||||
></hls-video>
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
slot="media"
|
||||
src={assetFileUrl}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
disablePictureInPicture
|
||||
playsinline
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full object-contain"
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onseeking={onSeeking}
|
||||
onplaying={(e) => {
|
||||
if (!hasFocused) {
|
||||
e.currentTarget.focus();
|
||||
hasFocused = true;
|
||||
}
|
||||
}}
|
||||
onclose={onClose}
|
||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
|
||||
></video>
|
||||
{/if}
|
||||
<immich-video
|
||||
slot="media"
|
||||
asset-id={assetId}
|
||||
cache-key={cacheKey ?? ''}
|
||||
play-original={playOriginalVideo}
|
||||
class="h-full"
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
|
||||
oncanplay={(event: Event) => handleCanPlay(event.currentTarget as HTMLVideoElement)}
|
||||
onended={onVideoEnded}
|
||||
onseeking={onSeeking}
|
||||
onplaying={onPlaying}
|
||||
onclose={onClose}
|
||||
></immich-video>
|
||||
|
||||
{#if extendedControls}
|
||||
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
|
||||
@@ -411,14 +225,11 @@
|
||||
<span slot="title">{$t('media_chrome.playback_rate')}</span>
|
||||
</media-playback-rate-menu>
|
||||
</media-settings-menu-item>
|
||||
{#if featureFlagsManager.value.realtimeTranscoding}
|
||||
{#if featureFlagsManager.value.realtimeTranscoding && controller}
|
||||
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
|
||||
{$t('video_quality')}
|
||||
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
|
||||
<media-rendition-menu slot="submenu" hidden>
|
||||
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
|
||||
<span slot="title">{$t('video_quality')}</span>
|
||||
</media-rendition-menu>
|
||||
<VideoQualityMenu video={controller} slot="submenu" />
|
||||
</media-settings-menu-item>
|
||||
{/if}
|
||||
</media-settings-menu>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { VideoController } from '$lib/utils/video/controller.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronLeft } from '@mdi/js';
|
||||
import 'media-chrome/menu/media-chrome-menu';
|
||||
import 'media-chrome/menu/media-chrome-menu-item';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLElement> {
|
||||
video: VideoController;
|
||||
}
|
||||
|
||||
let { video, ...rest }: Props = $props();
|
||||
|
||||
const options = $derived(
|
||||
video.levels
|
||||
.map((level, idx) => ({ idx, label: Math.min(level.width, level.height) }))
|
||||
.sort((a, b) => b.label - a.label),
|
||||
);
|
||||
|
||||
const autoLevel = $derived(video.selectedLevel === -1 ? options.find(({ idx }) => idx === video.level) : undefined);
|
||||
const autoLabel = $derived(autoLevel ? `${$t('media_chrome.auto')} (${autoLevel.label}p)` : $t('media_chrome.auto'));
|
||||
|
||||
let menu = $state<HTMLElement>();
|
||||
$effect(() => {
|
||||
menu?.dispatchEvent(new CustomEvent('addmenuitem', { detail: autoLabel }));
|
||||
});
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
video.level = Number((event.currentTarget as HTMLElement & { value: string }).value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<media-chrome-menu bind:this={menu} {...rest} hidden onchange={onChange}>
|
||||
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
|
||||
<span slot="title">{$t('video_quality')}</span>
|
||||
<media-chrome-menu-item part="menu-item radio" type="radio" value="-1" checked={video.selectedLevel === -1}>
|
||||
<span>{autoLabel}</span>
|
||||
</media-chrome-menu-item>
|
||||
{#each options as option (option.idx)}
|
||||
<media-chrome-menu-item
|
||||
part="menu-item radio"
|
||||
type="radio"
|
||||
value={`${option.idx}`}
|
||||
checked={video.selectedLevel === option.idx}
|
||||
>
|
||||
<span>{option.label}p</span>
|
||||
</media-chrome-menu-item>
|
||||
{/each}
|
||||
</media-chrome-menu>
|
||||
|
||||
<style>
|
||||
media-chrome-menu-item {
|
||||
padding: 0.4em 0.8em 0.4em 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,6 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
@@ -38,7 +37,7 @@
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
? candidates.filter((person) => normalizeSearchString(person.name).includes(normalizeSearchString(searchTerm)))
|
||||
? candidates.filter((person) => person.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: candidates,
|
||||
);
|
||||
|
||||
@@ -329,9 +328,9 @@
|
||||
|
||||
await assetViewerManager.setAssetId(assetId);
|
||||
faceManager.clear();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CustomVideoElement } from 'custom-media-element';
|
||||
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||
import type { VideoController } from '$lib/utils/video/controller.svelte';
|
||||
|
||||
/**
|
||||
* Video backed by either HLS or a progressive stream based on feature flags and user preferences. Can be managed with
|
||||
* `videoSessionManager.get(assetId)`, this manager being what allows it to reparent the underlying video element.
|
||||
*/
|
||||
class ImmichVideoElement extends CustomVideoElement {
|
||||
static override get observedAttributes() {
|
||||
return [...super.observedAttributes, 'asset-id', 'play-original'];
|
||||
}
|
||||
|
||||
#controller: VideoController | undefined;
|
||||
#mountedAssetId: string | undefined;
|
||||
#remountScheduled = false;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#mount();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.#unmount();
|
||||
}
|
||||
|
||||
override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
||||
super.attributeChangedCallback(name, oldValue, newValue);
|
||||
if (!this.isConnected || !this.#controller || oldValue === newValue) {
|
||||
return;
|
||||
}
|
||||
if (name === 'play-original') {
|
||||
this.#controller.playOriginal = newValue === 'true';
|
||||
} else if (name === 'asset-id' && !this.#remountScheduled) {
|
||||
this.#remountScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.#remountScheduled = false;
|
||||
if (this.isConnected) {
|
||||
this.#unmount();
|
||||
this.#mount();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#mount() {
|
||||
const assetId = this.getAttribute('asset-id');
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
const controller = videoSessionManager.acquire({
|
||||
assetId,
|
||||
cacheKey: this.getAttribute('cache-key') || null,
|
||||
playOriginal: this.getAttribute('play-original') === 'true',
|
||||
});
|
||||
this.#controller = controller;
|
||||
this.#mountedAssetId = assetId;
|
||||
|
||||
const video = controller.element;
|
||||
video.slot = 'media';
|
||||
video.loop = this.loop;
|
||||
video.autoplay = this.autoplay;
|
||||
video.muted = this.muted || this.hasAttribute('muted');
|
||||
video.poster = this.getAttribute('poster') ?? '';
|
||||
controller.mount(this);
|
||||
}
|
||||
|
||||
#unmount() {
|
||||
if (!this.#mountedAssetId) {
|
||||
return;
|
||||
}
|
||||
this.#controller?.unmount(this);
|
||||
videoSessionManager.release(this.#mountedAssetId);
|
||||
this.#controller = undefined;
|
||||
this.#mountedAssetId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (globalThis.customElements && !customElements.get('immich-video')) {
|
||||
customElements.define('immich-video', ImmichVideoElement);
|
||||
}
|
||||
|
||||
export default ImmichVideoElement;
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
@@ -264,7 +264,8 @@
|
||||
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
assetId={asset.id}
|
||||
cacheKey={asset.thumbhash}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? asset.duration / 1000 : 0}
|
||||
@@ -275,7 +276,8 @@
|
||||
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
assetId={asset.livePhotoVideoId}
|
||||
cacheKey={asset.thumbhash}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { cleanClass } from '$lib';
|
||||
import '$lib/components/asset-viewer/immich-video-element';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { Duration } from 'luxon';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
assetId: string;
|
||||
cacheKey: string | null;
|
||||
durationInSeconds?: number;
|
||||
enablePlayback?: boolean;
|
||||
playbackOnIconHover?: boolean;
|
||||
@@ -18,7 +22,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
url,
|
||||
assetId,
|
||||
cacheKey,
|
||||
durationInSeconds = 0,
|
||||
enablePlayback = $bindable(false),
|
||||
playbackOnIconHover = false,
|
||||
@@ -29,26 +34,27 @@
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
let remainingSeconds = $state(durationInSeconds);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
let player: HTMLVideoElement | undefined = $state();
|
||||
const useHls = $derived(featureFlagsManager.value.realtimeTranscoding);
|
||||
|
||||
let active = $state(false);
|
||||
const controller = $derived(videoSessionManager.get(assetId));
|
||||
const remainingSeconds = $derived(controller?.remainingSeconds || durationInSeconds);
|
||||
|
||||
$effect(() => {
|
||||
if (!enablePlayback) {
|
||||
remainingSeconds = durationInSeconds;
|
||||
active = false;
|
||||
return;
|
||||
}
|
||||
if (!player) {
|
||||
if (!useHls) {
|
||||
active = true;
|
||||
return;
|
||||
}
|
||||
const video = player;
|
||||
return () => {
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
// Cold-starting a transcode for every thumbnail the pointer brushes over would hammer the server,
|
||||
// so wait for the hover to settle before opening an HLS session.
|
||||
const timer = setTimeout(() => (active = true), 200);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = true;
|
||||
@@ -62,35 +68,15 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if enablePlayback}
|
||||
<video
|
||||
bind:this={player}
|
||||
class={cleanClass('h-full w-full object-cover', className)}
|
||||
class:rounded-xl={curve}
|
||||
{#if active}
|
||||
<immich-video
|
||||
asset-id={assetId}
|
||||
cache-key={cacheKey ?? ''}
|
||||
muted
|
||||
autoplay
|
||||
loop
|
||||
src={url}
|
||||
onplay={() => {
|
||||
loading = false;
|
||||
error = false;
|
||||
}}
|
||||
onerror={() => {
|
||||
if (!player?.src) {
|
||||
// Do not show error when the URL is empty.
|
||||
return;
|
||||
}
|
||||
error = true;
|
||||
loading = false;
|
||||
}}
|
||||
ontimeupdate={({ currentTarget }) => {
|
||||
const remaining = currentTarget.duration - currentTarget.currentTime;
|
||||
remainingSeconds = Math.min(
|
||||
Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining),
|
||||
durationInSeconds,
|
||||
);
|
||||
}}
|
||||
></video>
|
||||
autoplay
|
||||
class={cleanClass('h-full w-full [--media-object-fit:cover]', className, curve && 'rounded-xl overflow-hidden')}
|
||||
></immich-video>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
@@ -114,10 +100,10 @@
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
>
|
||||
{#if enablePlayback}
|
||||
{#if loading}
|
||||
{#if active}
|
||||
{#if !controller || controller.loading}
|
||||
<LoadingSpinner size="large" />
|
||||
{:else if error}
|
||||
{:else if controller.error}
|
||||
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
||||
{:else}
|
||||
<Icon icon={pauseIcon} size="24" />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { mdiStar, mdiStarOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export type Rating = -1 | 1 | 2 | 3 | 4 | 5 | null;
|
||||
export type Rating = 1 | 2 | 3 | 4 | 5 | null;
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
@@ -33,7 +33,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
ratingSelection = newRating;
|
||||
onRating(newRating);
|
||||
};
|
||||
|
||||
@@ -71,7 +70,7 @@
|
||||
<div class="flex flex-row" data-testid="star-container">
|
||||
{#each { length: count } as _, index (index)}
|
||||
{@const value = index + 1}
|
||||
{@const filled = hoverRating === null ? (ratingSelection ?? 0) >= value : hoverRating >= value}
|
||||
{@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value}
|
||||
{@const starId = `${id}-${value}`}
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
@@ -103,7 +102,14 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
{#if ratingSelection !== null && !readOnly}
|
||||
<button type="button" onclick={() => handleSelect(null)} class="cursor-pointer text-xs text-primary">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
ratingSelection = null;
|
||||
handleSelect(ratingSelection);
|
||||
}}
|
||||
class="cursor-pointer text-xs text-primary"
|
||||
>
|
||||
{$t('rating_clear')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
fromISODateTimeUTCToObject,
|
||||
fromISODateTimeUTC,
|
||||
getTimes,
|
||||
setDifference,
|
||||
type TimelineDateTime,
|
||||
@@ -190,7 +190,7 @@ export class TimelineMonth {
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
localDateTime,
|
||||
createdAt: fromISODateTimeUTCToObject(bucketAssets.createdAt[i]),
|
||||
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
|
||||
fileCreatedAt,
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { VideoController, type VideoControllerOptions } from '$lib/utils/video/controller.svelte';
|
||||
|
||||
interface Session {
|
||||
controller: VideoController;
|
||||
refs: number;
|
||||
timer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of controllers keyed by asset, ref-counted with a grace period. `<immich-video>` acquires and
|
||||
* releases as it connects and disconnects, with controllers kept briefly before being disposed. This enables
|
||||
* reuse of bandwidth estimation, downloaded segments, HLS session, etc. for seamless handoff.
|
||||
*/
|
||||
class VideoSessionManager {
|
||||
#sessions = new SvelteMap<string, Session>();
|
||||
|
||||
acquire(options: VideoControllerOptions): VideoController {
|
||||
const existing = this.#sessions.get(options.assetId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.timer = undefined;
|
||||
existing.refs++;
|
||||
return existing.controller;
|
||||
}
|
||||
const controller = new VideoController(options);
|
||||
this.#sessions.set(options.assetId, { controller, refs: 1, timer: undefined });
|
||||
return controller;
|
||||
}
|
||||
|
||||
release(assetId: string) {
|
||||
const session = this.#sessions.get(assetId);
|
||||
if (!session || --session.refs > 0) {
|
||||
return;
|
||||
}
|
||||
session.timer = setTimeout(() => {
|
||||
session.controller.release();
|
||||
this.#sessions.delete(assetId);
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
get(assetId: string): VideoController | undefined {
|
||||
return this.#sessions.get(assetId)?.controller;
|
||||
}
|
||||
}
|
||||
|
||||
export const videoSessionManager = new VideoSessionManager();
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import type { LatLng } from '$lib/types';
|
||||
import { Alert, ConfirmModal } from '@immich/ui';
|
||||
import { ConfirmModal } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
@@ -10,18 +9,11 @@
|
||||
onClose: (confirm: boolean) => void;
|
||||
};
|
||||
|
||||
const { point, assetCount, onClose }: Props = $props();
|
||||
|
||||
const hasExistingLocations = $derived(
|
||||
assetMultiSelectManager.assets.some((asset) => asset.latitude != null || asset.longitude != null),
|
||||
);
|
||||
let { point, assetCount, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
|
||||
{#snippet prompt()}
|
||||
{#if hasExistingLocations}
|
||||
<Alert color="warning" class="mb-4">{$t('some_assets_already_have_a_location_warning')}</Alert>
|
||||
{/if}
|
||||
<p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
|
||||
<p>- {$t('latitude')}: {point.lat}</p>
|
||||
<p>- {$t('longitude')}: {point.lng}</p>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -25,9 +24,7 @@
|
||||
const filteredPeople = $derived(
|
||||
people
|
||||
.filter((person) => !excludedIds.includes(person.id))
|
||||
.filter(
|
||||
(person) => !searchName || normalizeSearchString(person.name).includes(normalizeSearchString(searchName)),
|
||||
),
|
||||
.filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())),
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { searchNameLocal } from './person';
|
||||
|
||||
const makePerson = (overrides: Partial<PersonResponseDto> = {}): PersonResponseDto => ({
|
||||
id: 'person-1',
|
||||
name: 'Amélie',
|
||||
thumbnailPath: '',
|
||||
isHidden: false,
|
||||
birthDate: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('searchNameLocal with single-word names', () => {
|
||||
it('should find a person by exact name match', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' })];
|
||||
expect(searchNameLocal('Amélie', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should find a person with accent-insensitive search', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' })];
|
||||
expect(searchNameLocal('amelie', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should find a person by prefix match', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' })];
|
||||
expect(searchNameLocal('ame', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should not match partial name where prefix does not match', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' })];
|
||||
expect(searchNameLocal('lie', people, 10)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const people = [makePerson({ id: '1', name: 'AMÉLIE' })];
|
||||
expect(searchNameLocal('amelie', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should handle Hungarian accented characters', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Árvíztűrő' })];
|
||||
expect(searchNameLocal('arvizturo', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should handle Polish accented characters', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Jędrzej' })];
|
||||
expect(searchNameLocal('jedrzej', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should handle no matches', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' })];
|
||||
expect(searchNameLocal('xyz', people, 10)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should respect the slice parameter', () => {
|
||||
const people = [
|
||||
makePerson({ id: '1', name: 'Amélie' }),
|
||||
makePerson({ id: '2', name: 'Amadeus' }),
|
||||
makePerson({ id: '3', name: 'Aminta' }),
|
||||
];
|
||||
expect(searchNameLocal('am', people, 2)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNameLocal with multi-word names', () => {
|
||||
it('should find a person matching the first name', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Jean Amélie' })];
|
||||
expect(searchNameLocal('jean', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should find a person matching the last name with accent insensitivity', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie Dupont' })];
|
||||
expect(searchNameLocal('dupont', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should find a person matching any space-separated word', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Jean Amélie Dupont' })];
|
||||
expect(searchNameLocal('dupont', people, 10)).toEqual([people[0]]);
|
||||
expect(searchNameLocal('jean', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should match prefix of any word in a multi-word name', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Maria João Silva' })];
|
||||
expect(searchNameLocal('joão', people, 10)).toEqual([people[0]]);
|
||||
expect(searchNameLocal('joao', people, 10)).toEqual([people[0]]);
|
||||
expect(searchNameLocal('sil', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should match when search term is a multi-word prefix of the full name', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Jean Amélie Dupont' })];
|
||||
expect(searchNameLocal('jean amélie', people, 10)).toEqual([people[0]]);
|
||||
});
|
||||
|
||||
it('should not match when search term does not prefix the full name', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Jean Amélie' })];
|
||||
expect(searchNameLocal('jean x', people, 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNameLocal with personId exclusion', () => {
|
||||
it('should exclude the person with the given id', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' }), makePerson({ id: '2', name: 'Amélie' })];
|
||||
const result = searchNameLocal('amélie', people, 10, '1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should return empty when only the excluded person matches', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' })];
|
||||
expect(searchNameLocal('amélie', people, 10, '1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should still exclude when search is accent-insensitive', () => {
|
||||
const people = [makePerson({ id: '1', name: 'Amélie' }), makePerson({ id: '2', name: 'Amélie' })];
|
||||
const result = searchNameLocal('amelie', people, 10, '1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { derived } from 'svelte/store';
|
||||
import { normalizeSearchString } from './string-utils';
|
||||
|
||||
export const searchNameLocal = (
|
||||
name: string,
|
||||
@@ -9,22 +8,21 @@ export const searchNameLocal = (
|
||||
slice: number,
|
||||
personId?: string,
|
||||
): PersonResponseDto[] => {
|
||||
const normalizedName = normalizeSearchString(name);
|
||||
return name.includes(' ')
|
||||
? people
|
||||
.filter((person: PersonResponseDto) => {
|
||||
return personId
|
||||
? normalizeSearchString(person.name).startsWith(normalizedName) && person.id !== personId
|
||||
: normalizeSearchString(person.name).startsWith(normalizedName);
|
||||
? person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== personId
|
||||
: person.name.toLowerCase().startsWith(name.toLowerCase());
|
||||
})
|
||||
.slice(0, slice)
|
||||
: people
|
||||
.filter((person: PersonResponseDto) => {
|
||||
const nameParts = person.name.split(' ');
|
||||
return personId
|
||||
? nameParts.some((splitName) => normalizeSearchString(splitName).startsWith(normalizedName)) &&
|
||||
? nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase())) &&
|
||||
person.id !== personId
|
||||
: nameParts.some((splitName) => normalizeSearchString(splitName).startsWith(normalizedName));
|
||||
: nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase()));
|
||||
})
|
||||
.slice(0, slice);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import Hls, { type ErrorData, type Level } from 'hls.js';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { createHls, filterEfficientLevels, getHlsSessionId, releaseHlsSession } from '$lib/utils/video/hls';
|
||||
|
||||
export interface VideoControllerOptions {
|
||||
assetId: string;
|
||||
cacheKey: string | null;
|
||||
playOriginal: boolean;
|
||||
}
|
||||
|
||||
const HLS_MIME = 'application/x-mpegURL';
|
||||
const MAX_REBUILDS = 1;
|
||||
|
||||
/**
|
||||
* Owns a single, long-lived `<video>` for an asset and all of its playback wiring.
|
||||
* Because the controller owns the element, hosts can {@link mount} it and hand it off by re-parenting, enabling
|
||||
* bandwidth estimation, buffer, HLS session, playback position, etc. to survive.
|
||||
*/
|
||||
export class VideoController {
|
||||
readonly element: HTMLVideoElement;
|
||||
|
||||
loading = $state(true);
|
||||
error = $state(false);
|
||||
currentTime = $state(0);
|
||||
duration = $state(0);
|
||||
levels = $state<Level[]>([]);
|
||||
selectedLevel = $state(-1);
|
||||
|
||||
private assetId: string;
|
||||
private cacheKey: string | null;
|
||||
private api: Hls | undefined;
|
||||
private sourceTeardown: (() => void) | undefined;
|
||||
private started = false;
|
||||
private wasPlaying = false;
|
||||
private rebuilds = 0;
|
||||
|
||||
#level = $state(-1);
|
||||
#playOriginal: boolean;
|
||||
|
||||
constructor({ assetId, cacheKey, playOriginal }: VideoControllerOptions) {
|
||||
this.assetId = assetId;
|
||||
this.cacheKey = cacheKey;
|
||||
this.#playOriginal = playOriginal;
|
||||
|
||||
const element = document.createElement('video');
|
||||
element.playsInline = true;
|
||||
element.disablePictureInPicture = true;
|
||||
element.addEventListener('play', () => {
|
||||
this.loading = false;
|
||||
this.error = false;
|
||||
});
|
||||
element.addEventListener('error', () => {
|
||||
this.error = true;
|
||||
this.loading = false;
|
||||
});
|
||||
element.addEventListener('timeupdate', () => {
|
||||
this.currentTime = element.currentTime;
|
||||
this.duration = element.duration;
|
||||
});
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
mount(container: HTMLElement) {
|
||||
container.append(this.element);
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
const useHls = featureFlagsManager.value.realtimeTranscoding && !this.#playOriginal;
|
||||
this.sourceTeardown = useHls ? this.attachHls() : this.attachProgressive();
|
||||
} else if (this.wasPlaying) {
|
||||
void this.element.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
unmount(container: HTMLElement) {
|
||||
if (this.element.parentElement === container) {
|
||||
this.wasPlaying = !this.element.paused;
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
release() {
|
||||
this.sourceTeardown?.();
|
||||
this.sourceTeardown = undefined;
|
||||
this.element.remove();
|
||||
}
|
||||
|
||||
get remainingSeconds() {
|
||||
return Math.max(0, this.duration - this.currentTime);
|
||||
}
|
||||
|
||||
set playOriginal(playOriginal: boolean) {
|
||||
if (this.#playOriginal === playOriginal) {
|
||||
return;
|
||||
}
|
||||
this.#playOriginal = playOriginal;
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
this.sourceTeardown?.();
|
||||
const useHls = featureFlagsManager.value.realtimeTranscoding && !playOriginal;
|
||||
this.sourceTeardown = useHls ? this.attachHls() : this.attachProgressive();
|
||||
}
|
||||
|
||||
get level() {
|
||||
return this.#level;
|
||||
}
|
||||
|
||||
set level(index: number) {
|
||||
if (!this.api) {
|
||||
return;
|
||||
}
|
||||
// -1 re-enables ABR without flushing
|
||||
if (index === -1) {
|
||||
this.api.loadLevel = -1;
|
||||
} else {
|
||||
this.api.currentLevel = index;
|
||||
}
|
||||
this.selectedLevel = index;
|
||||
}
|
||||
|
||||
private attachProgressive() {
|
||||
this.element.src = this.#playOriginal
|
||||
? getAssetMediaUrl({ id: this.assetId, size: AssetMediaSize.Original, cacheKey: this.cacheKey })
|
||||
: getAssetPlaybackUrl({ id: this.assetId, cacheKey: this.cacheKey });
|
||||
return () => this.detachSource();
|
||||
}
|
||||
|
||||
private detachSource() {
|
||||
this.element.pause();
|
||||
this.element.removeAttribute('src');
|
||||
this.element.load();
|
||||
}
|
||||
|
||||
private attachHls(startPosition = -1): () => void {
|
||||
const video = this.element;
|
||||
// Old iOS versions don't support Media Source Extensions
|
||||
if (!Hls.isSupported()) {
|
||||
return this.attachNativeHls();
|
||||
}
|
||||
|
||||
const hls = createHls({ autoStartLoad: false });
|
||||
this.api = hls;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||
sessionId = getHlsSessionId(hls);
|
||||
await filterEfficientLevels(hls);
|
||||
this.levels = hls.levels;
|
||||
hls.attachMedia(video); // Need to attach after filtering for the auto size cap to work
|
||||
hls.startLoad(startPosition);
|
||||
// autoStartLoad defers the first fragment, so the `autoplay` attribute may have already fired and done nothing
|
||||
if (video.autoplay && video.paused) {
|
||||
void video.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
hls.on(Hls.Events.LEVELS_UPDATED, (_, data) => (this.levels = data.levels));
|
||||
hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => (this.#level = data.level));
|
||||
hls.on(Hls.Events.FRAG_LOADED, () => (this.rebuilds = 0));
|
||||
hls.on(Hls.Events.ERROR, (_, data) => this.onHlsError(data));
|
||||
|
||||
const onPageHide = (event: PageTransitionEvent) => {
|
||||
if (!event.persisted && sessionId) {
|
||||
releaseHlsSession(this.assetId, sessionId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
|
||||
hls.loadSource(getAssetHlsUrl(this.assetId));
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
if (sessionId) {
|
||||
releaseHlsSession(this.assetId, sessionId);
|
||||
}
|
||||
hls.destroy();
|
||||
this.api = undefined;
|
||||
this.levels = [];
|
||||
this.#level = -1;
|
||||
this.selectedLevel = -1;
|
||||
};
|
||||
}
|
||||
|
||||
private attachNativeHls() {
|
||||
if (this.element.canPlayType(HLS_MIME)) {
|
||||
this.element.src = getAssetHlsUrl(this.assetId);
|
||||
} else {
|
||||
this.error = true;
|
||||
this.loading = false;
|
||||
}
|
||||
return () => this.detachSource();
|
||||
}
|
||||
|
||||
private onHlsError(data: ErrorData) {
|
||||
// A fragment 404 usually means the server session expired (e.g. after a long pause). Rebuild it
|
||||
// once, resuming where we left off, before giving up.
|
||||
if (
|
||||
data.fatal &&
|
||||
data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR &&
|
||||
data.response?.code === 404 &&
|
||||
this.rebuilds < MAX_REBUILDS
|
||||
) {
|
||||
this.rebuilds++;
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
const resume = this.element.currentTime;
|
||||
this.sourceTeardown?.();
|
||||
this.sourceTeardown = this.attachHls(resume);
|
||||
return;
|
||||
}
|
||||
if (data.fatal) {
|
||||
console.error('Fatal HLS error', data.details, data.response?.code);
|
||||
this.error = true;
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Hls, {
|
||||
AbrController,
|
||||
Events,
|
||||
FetchLoader,
|
||||
type FragLoadedData,
|
||||
type FragLoadingData,
|
||||
type HlsConfig,
|
||||
} from 'hls.js';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
|
||||
import { getAssetHlsSessionUrl } from '$lib/utils';
|
||||
|
||||
const HLS_TARGET_SEGMENT_HEADER = 'x-immich-hls-msn';
|
||||
const RESIZE_FLUSH_DEBOUNCE_MS = 150;
|
||||
const SESSION_ID_REGEX = /\/video\/stream\/([0-9a-f-]{36})\//;
|
||||
|
||||
// hls.js can abandon fetching an in-flight fragment if it thinks it'll take too long, in which case
|
||||
// it emergency switches to a different variant. This extends the delay even further due to
|
||||
// cold starting another transcode, so let the fragment finish and have steady ABR decide the next level.
|
||||
//
|
||||
// It can also emergency switch between fragments: while a switch's first segment is still loading,
|
||||
// it can run out of buffer and drop to a lower level for just one segment before continuing at the switched quality.
|
||||
// This can cause multiple redundant transcoding restarts when it occurs.
|
||||
// Hold the committed level until its first fragment lands, then resume normal ABR.
|
||||
export class NoAbandonAbrController extends AbrController {
|
||||
private switchTarget = -1;
|
||||
|
||||
protected override onFragLoading(_event: Events.FRAG_LOADING, data: FragLoadingData) {
|
||||
if (data.frag.sn === 'initSegment') {
|
||||
this.switchTarget = data.frag.level;
|
||||
}
|
||||
}
|
||||
|
||||
protected override onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
|
||||
if (data.frag.sn !== 'initSegment') {
|
||||
this.switchTarget = -1;
|
||||
}
|
||||
super.onFragLoaded(event, data);
|
||||
}
|
||||
|
||||
override get nextAutoLevel(): number {
|
||||
const level = super.nextAutoLevel;
|
||||
const target = this.hls.levels[this.switchTarget];
|
||||
// Hold the committed level, but only while hls.js still considers it healthy.
|
||||
if (target && level < this.switchTarget && target.loadError === 0 && target.fragmentError === 0) {
|
||||
return this.switchTarget;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
override set nextAutoLevel(level: number) {
|
||||
super.nextAutoLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
// hls.js flushes the forward buffer on a level switch so the new variant surfaces quickly, but requests can happen
|
||||
// out of order and cause unnecessary transcodes since it leaves the loader running during the flush.
|
||||
// This version stops the loader until the buffer is flushed so segment requests stay monotonic.
|
||||
class FlushAheadStreamController extends Hls.DefaultConfig.streamController {
|
||||
#flushPending = false;
|
||||
#flushAhead = debounce(() => this.#switchAhead(), RESIZE_FLUSH_DEBOUNCE_MS);
|
||||
|
||||
override nextLevelSwitch() {
|
||||
this.#flushAhead();
|
||||
}
|
||||
|
||||
#switchAhead() {
|
||||
const { media, hls, levels, playlistType } = this;
|
||||
if (!media?.readyState || !levels || this.#flushPending) {
|
||||
return;
|
||||
}
|
||||
const bufferInfo = this.getFwdBufferInfo(this.getBufferOutput(), playlistType);
|
||||
const nextLevel = levels[hls.nextLoadLevel];
|
||||
if (!bufferInfo || !nextLevel) {
|
||||
return;
|
||||
}
|
||||
const { fetchdelay, okToFlushForwardBuffer } = this.calculateOptimalSwitchPoint(nextLevel, bufferInfo);
|
||||
const flushFrom = this.playhead + fetchdelay;
|
||||
if (!okToFlushForwardBuffer || bufferInfo.end <= flushFrom) {
|
||||
return;
|
||||
}
|
||||
this.#flushPending = true;
|
||||
hls.stopLoad();
|
||||
hls.once(Events.BUFFER_FLUSHED, () => {
|
||||
this.#flushPending = false;
|
||||
hls.startLoad();
|
||||
});
|
||||
hls.trigger(Events.BUFFER_FLUSHING, {
|
||||
startOffset: flushFrom,
|
||||
endOffset: Number.POSITIVE_INFINITY,
|
||||
type: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const createHls = (overrides?: Partial<HlsConfig>): Hls => {
|
||||
const hls = new Hls({
|
||||
abrController: NoAbandonAbrController,
|
||||
loader: FetchLoader,
|
||||
capLevelToPlayerSize: true,
|
||||
streamController: FlushAheadStreamController,
|
||||
testBandwidth: false,
|
||||
highBufferWatchdogPeriod: 10,
|
||||
detectStallWithCurrentTimeMs: 10_000,
|
||||
maxBufferHole: 0.5,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
fragLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: 30_000,
|
||||
maxLoadTimeMs: 60_000,
|
||||
timeoutRetry: { maxNumRetry: 5, retryDelayMs: 100, maxRetryDelayMs: 0 },
|
||||
errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
|
||||
},
|
||||
},
|
||||
useMediaCapabilities: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// init.mp4 carries no segment number, but the server needs to know which segment an init.mp4
|
||||
// is for so it can start the transcode there. It sometimes can't infer this since segments can
|
||||
// be loaded from browser cache and the server might not know where the client really is as a result.
|
||||
// Let the client hint the target segment it's switching to with a custom header.
|
||||
hls.config.fetchSetup = (context, initParams) => {
|
||||
const frag = (context as { frag?: { sn: number | 'initSegment' } }).frag;
|
||||
if (frag?.sn === 'initSegment') {
|
||||
const sn = hls.inFlightFragments.main.frag?.sn;
|
||||
if (typeof sn === 'number') {
|
||||
(initParams.headers as Headers).set(HLS_TARGET_SEGMENT_HEADER, String(sn));
|
||||
}
|
||||
}
|
||||
return new Request(context.url, initParams);
|
||||
};
|
||||
|
||||
return hls;
|
||||
};
|
||||
|
||||
export const getHlsSessionId = (api: Hls): string | undefined => {
|
||||
return api.levels[0]?.url[0]?.match(SESSION_ID_REGEX)?.[1];
|
||||
};
|
||||
|
||||
export const releaseHlsSession = (assetId: string, sessionId: string) => {
|
||||
const url = getAssetHlsSessionUrl(assetId, sessionId);
|
||||
void fetch(url, { method: 'DELETE' }).catch(() =>
|
||||
console.warn('Failed to release HLS session', { assetId, sessionId }),
|
||||
);
|
||||
};
|
||||
|
||||
/** Drop every variant the browser can't hardware-decode efficiently, keeping one per resolution. */
|
||||
export const filterEfficientLevels = async (api: Hls) => {
|
||||
const keep = await mediaCapabilitiesManager.efficientLevels(api.levels);
|
||||
for (let i = api.levels.length - 1; i >= 0; i--) {
|
||||
if (!keep.has(i)) {
|
||||
api.removeLevel(i);
|
||||
}
|
||||
}
|
||||
};
|
||||
+20
-13
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import '$lib/components/asset-viewer/immich-video-element';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { autoPlayVideo } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import 'media-chrome/media-controller';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -15,29 +17,34 @@
|
||||
|
||||
let { asset, videoPlayer = $bindable() }: Props = $props();
|
||||
|
||||
let showVideo: boolean = $state(false);
|
||||
|
||||
let showVideo = $state(false);
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
});
|
||||
|
||||
const controller = $derived(videoSessionManager.get(asset.id)); // <immich-video> self-acquires the controller for the asset
|
||||
$effect(() => {
|
||||
videoPlayer = controller?.element;
|
||||
return () => {
|
||||
videoPlayer = undefined;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div class="bg-pink-9000 size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
|
||||
<div class="size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<media-controller id="memory-video" nohotkeys class="size-full rounded-2xl object-contain transition-all">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
<immich-video
|
||||
slot="media"
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
asset-id={asset.id}
|
||||
cache-key={asset.thumbhash ?? ''}
|
||||
class="size-full"
|
||||
src={getAssetPlaybackUrl({ id: asset.id })}
|
||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
></video>
|
||||
autoplay={$autoPlayVideo}
|
||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
||||
></immich-video>
|
||||
</media-controller>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { clearQueryParam } from '$lib/utils/navigation';
|
||||
@@ -238,8 +237,8 @@
|
||||
potentialMergePeople = people
|
||||
.filter(
|
||||
(person: PersonResponseDto) =>
|
||||
normalizeSearchString(personMerge2?.name ?? '') === normalizeSearchString(person.name) &&
|
||||
person.id !== personMerge2?.id &&
|
||||
personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
|
||||
person.id !== personMerge2.id &&
|
||||
person.id !== personMerge1?.id &&
|
||||
!person.isHidden,
|
||||
)
|
||||
@@ -270,9 +269,8 @@
|
||||
|
||||
const findPeopleWithSimilarName = async (name: string, personId: string) => {
|
||||
const searchResult = await searchPerson({ name, withHidden: true });
|
||||
const normalizedName = normalizeSearchString(name);
|
||||
return searchResult.find(
|
||||
(person) => normalizeSearchString(person.name) === normalizedName && person.id !== personId && person.name,
|
||||
(person) => person.name.toLowerCase() === name.toLowerCase() && person.id !== personId && person.name,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+3
-6
@@ -36,7 +36,6 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isExternalUrl } from '$lib/utils/navigation';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
ActionButton,
|
||||
@@ -237,10 +236,8 @@
|
||||
|
||||
const result = await searchPerson({ name: personName, withHidden: true });
|
||||
|
||||
const normalizedPersonName = normalizeSearchString(personName);
|
||||
const existingPerson = result.find(
|
||||
({ name, id }: PersonResponseDto) =>
|
||||
normalizeSearchString(name) === normalizedPersonName && id !== person.id && name,
|
||||
({ name, id }: PersonResponseDto) => name.toLowerCase() === personName.toLowerCase() && id !== person.id && name,
|
||||
);
|
||||
if (existingPerson) {
|
||||
personMerge2 = existingPerson;
|
||||
@@ -248,8 +245,8 @@
|
||||
potentialMergePeople = result
|
||||
.filter(
|
||||
(person: PersonResponseDto) =>
|
||||
normalizeSearchString(personMerge2?.name ?? '') === normalizeSearchString(person.name) &&
|
||||
person.id !== personMerge2?.id &&
|
||||
personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
|
||||
person.id !== personMerge2.id &&
|
||||
person.id !== personMerge1?.id &&
|
||||
!person.isHidden,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user