mirror of
https://github.com/immich-app/immich.git
synced 2026-05-13 11:02:15 -04:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb328c63cf | |||
| 87175ee56c | |||
| 13587bf13c | |||
| f09769a2f3 | |||
| bfdff12ee0 | |||
| eb6dca6a31 | |||
| c2e3739a58 | |||
| f6bd514cdc | |||
| d93ab7707e | |||
| 6bb47c802f | |||
| 90a69e2ba6 | |||
| 6580394cfe | |||
| 42ff3b705d | |||
| 0f00053bb1 | |||
| c5c59ed040 | |||
| 576b1eb999 | |||
| 24189702da | |||
| ad0d01005e | |||
| 3e6d053f93 | |||
| 1bb3fd985f | |||
| f72aa54a1f | |||
| dafe9d7966 | |||
| 7acda0572d | |||
| 98bc9f6a6e | |||
| 63a3b405c3 | |||
| 0058df798d | |||
| 97100a4362 | |||
| af39384efb | |||
| 01712cf0a7 | |||
| afc470e8f4 | |||
| b156ae46b6 | |||
| 67b1a9d99e | |||
| d4f7c2ae0a |
@@ -51,14 +51,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -79,9 +79,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0
|
||||
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check out code
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@f8cb9308b42121e793f835bd14c0b8090420430c # v0.0.39
|
||||
uses: oasdiff/oasdiff-action/breaking@37bf9ff785c7315df88216660826e71be4cc03da # v0.0.44
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -71,9 +71,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:557cca601891b8b7d78b940071d35aaf7aaeb9b327d19b22cf282118edbc5272
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:32abe582452b12dff55055e1d6bc24508a8f17164f9d1831db7bb70953c014c6
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -23,14 +23,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -189,6 +189,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -21,14 +21,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -54,9 +54,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -20,9 +20,9 @@ jobs:
|
||||
artifact: ${{ steps.get-artifact.outputs.result }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- if: ${{ github.event.workflow_run.conclusion != 'success' }}
|
||||
@@ -119,9 +119,9 @@ jobs:
|
||||
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -131,7 +131,9 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
||||
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Load parameters
|
||||
id: parameters
|
||||
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -29,7 +29,9 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
||||
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Destroy Docs Subdomain
|
||||
env:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: 'Checkout'
|
||||
@@ -29,10 +29,10 @@ jobs:
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID:
|
||||
PUSH_O_MATIC_APP_CLIENT_ID:
|
||||
required: true
|
||||
PUSH_O_MATIC_APP_KEY:
|
||||
required: true
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Find translation PR
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Require PR to have a changelog label
|
||||
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
PUSH_O_MATIC_APP_CLIENT_ID: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
@@ -63,13 +63,13 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
|
||||
@@ -14,12 +14,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
@@ -32,9 +32,9 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
name: 'preview'
|
||||
})
|
||||
|
||||
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'PRs from forks cannot have preview environments.'
|
||||
|
||||
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
@@ -20,14 +20,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -49,9 +49,9 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
|
||||
+73
-66
@@ -17,14 +17,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -34,13 +34,17 @@ jobs:
|
||||
- 'web/**'
|
||||
- 'i18n/**'
|
||||
- 'open-api/typescript-sdk/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
cli:
|
||||
- 'cli/**'
|
||||
- 'open-api/typescript-sdk/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
e2e:
|
||||
- 'e2e/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
machine-learning:
|
||||
@@ -63,9 +67,9 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -77,7 +81,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -108,9 +112,9 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -121,7 +125,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -155,9 +159,9 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -168,7 +172,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -197,9 +201,9 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -210,7 +214,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -241,9 +245,9 @@ jobs:
|
||||
working-directory: ./web
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -254,7 +258,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -279,9 +283,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -292,7 +296,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -327,9 +331,9 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -340,7 +344,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -373,9 +377,9 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -387,13 +391,15 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
||||
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
- name: Run pnpm install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Run medium tests
|
||||
@@ -414,9 +420,9 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -428,7 +434,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -486,9 +492,9 @@ jobs:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -500,7 +506,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -568,7 +574,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
mobile-unit-tests:
|
||||
@@ -580,9 +586,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -607,39 +613,40 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MISE_CEILING_PATHS: ${{ github.workspace }}
|
||||
MISE_TRUSTED_CONFIG_PATHS: ${{ github.workspace }}/machine-learning/mise.toml
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./machine-learning
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
|
||||
with:
|
||||
python-version: 3.11
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./machine-learning
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra cpu
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
uv run ruff check --output-format=github immich_ml
|
||||
- name: Format with ruff
|
||||
run: |
|
||||
uv run ruff format --check immich_ml
|
||||
- name: Run mypy type checking
|
||||
run: |
|
||||
uv run mypy --strict immich_ml/
|
||||
run: mise run install --extra cpu
|
||||
- name: Lint code
|
||||
run: mise run lint --output-format=github
|
||||
- name: Format code
|
||||
run: mise run format
|
||||
- name: Run type checking
|
||||
run: mise run check
|
||||
- name: Run tests and coverage
|
||||
run: |
|
||||
uv run pytest --cov=immich_ml --cov-report term-missing
|
||||
run: mise run test
|
||||
github-files-formatting:
|
||||
name: .github Files Formatting
|
||||
needs: pre-job
|
||||
@@ -652,9 +659,9 @@ jobs:
|
||||
working-directory: ./.github
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -665,7 +672,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -682,9 +689,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -703,9 +710,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -716,7 +723,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -765,9 +772,9 @@ jobs:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
@@ -778,7 +785,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -24,14 +24,14 @@ jobs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
|
||||
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
@@ -47,9 +47,9 @@ jobs:
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
|
||||
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Bot review status
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
permissions: {}
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@81113db03f6d743efee81e0058c0b43f6cd6f36d # success-check-action-v0.0.6
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
Vendored
+3
@@ -29,6 +29,9 @@
|
||||
"editor.formatOnSave": true,
|
||||
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
|
||||
},
|
||||
"svelte.plugin.svelte.compilerWarnings": {
|
||||
"state_referenced_locally": "ignore"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
terragrunt = "1.0.2"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.2-ubuntu@sha256:78839fe49e1425c02416fa8072591533a72bd9598e563b54a07d78f9e27fb5d3
|
||||
image: grafana/grafana:12.4.3-ubuntu@sha256:ca3f764fdc48cebdf22dd206f33ecb0795a9a7210eacd1b5c02204aebd78b223
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ Some basic examples:
|
||||
- `**/Raw/**` will exclude all files in any directory named `Raw`
|
||||
- `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg`
|
||||
|
||||
Note that `*` is a wildcard matching zero or more characters (i.e., withinin a filename or single directory name). `**` matches zero or more subdirectories, recursively. It also includes any/all files within a subdirectory, i.e., when used at the end of a pattern. For example, `**/exclude_me/**` will exclude all files in any directory named `exclude_me`, as well as all files in any subdirectories of `exclude_me`, recursively.
|
||||
|
||||
Special characters such as @ should be escaped, for instance:
|
||||
|
||||
- `**/\@eaDir/**` will exclude all files in any directory named `@eaDir`
|
||||
|
||||
@@ -47,6 +47,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
|
||||
#### ROCm
|
||||
|
||||
- On Linux, The [AMDGPU driver module](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html) needs to be installed on the server and, if secure boot is used, the signing key of DKMS [needs to be enrolled in UEFI BIOS](https://wiki.debian.org/SecureBoot)
|
||||
- The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=<a supported version, e.g. 10.3.0>`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`.
|
||||
- The ROCm image is quite large and requires at least 35GiB of free disk space. However, pulling later updates to the service through Docker will generally only amount to a few hundred megabytes as the rest will be cached.
|
||||
- This backend is new and may experience some issues. For example, GPU power consumption can be higher than usual after running inference, even if the machine learning service is idle. In this case, it will only go back to normal after being idle for 5 minutes (configurable with the [MACHINE_LEARNING_MODEL_TTL](/install/environment-variables) setting).
|
||||
|
||||
@@ -18,6 +18,7 @@ You can search the following types of content:
|
||||
| People | Faces that are recognized in your photos/videos. |
|
||||
| Contextual | Content of the photos and videos. |
|
||||
| File name or extension | Full or partial file's name, or file's extension |
|
||||
| Full path or folder | Full or partial folder names from the original path. |
|
||||
| Description | Description added to assets. |
|
||||
| Optical Character Recognition (OCR) | Text in images |
|
||||
| Locations | Cities, states, and countries from reverse geocoding. |
|
||||
@@ -30,6 +31,12 @@ You can search the following types of content:
|
||||
|
||||
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />
|
||||
|
||||
### Full path or folder
|
||||
|
||||
Use this mode when you know a folder name or part of the original asset path.
|
||||
|
||||
Example: for /John/Projects/3D_Printing/2026-07-01/IMG_0001.jpg, searches like Projects, 3D, Printing, or 2026 match the asset.
|
||||
|
||||
## Configuration
|
||||
|
||||
Navigating to `Administration > Settings > Machine Learning Settings > Smart Search` will show the options available.
|
||||
|
||||
@@ -29,29 +29,31 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## General
|
||||
|
||||
| Variable | Description | Default | Containers | Workers |
|
||||
| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api |
|
||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||
| Variable | Description | Default | Containers | Workers |
|
||||
| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
|
||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`<sup>\*3</sup>. | `false` | server | api |
|
||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
|
||||
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
|
||||
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
|
||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||
|
||||
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
||||
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
||||
|
||||
\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead.
|
||||
|
||||
\*3: The [default configuration](https://helmetjs.github.io/#content-security-policy) sets `upgrade-insecure-requests`, which tells the browser to upgrade all requests to HTTPS. This breaks on HTTP-only deployments. If you cannot use HTTPS, you should use a custom helmet config file with `"upgrade-insecure-requests": null`.
|
||||
|
||||
## Workers
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
|
||||
+35
-1
@@ -1240,6 +1240,7 @@
|
||||
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe.",
|
||||
"free_up_space_settings_subtitle": "Free up device storage",
|
||||
"full_path": "Full path: {path}",
|
||||
"full_path_or_folder": "Full path or folder",
|
||||
"gcast_enabled": "Google Cast",
|
||||
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
||||
"general": "General",
|
||||
@@ -1523,6 +1524,38 @@
|
||||
"marked_all_as_read": "Marked all as read",
|
||||
"matches": "Matches",
|
||||
"matching_assets": "Matching Assets",
|
||||
"media_chrome": {
|
||||
"auto": "Auto",
|
||||
"captions": "Captions",
|
||||
"captions_off": "Off",
|
||||
"closed_captions": "closed captions",
|
||||
"decode_error": "Decode error",
|
||||
"disable_captions": "Disable captions",
|
||||
"enable_captions": "Enable captions",
|
||||
"enter_fullscreen_mode": "Enter fullscreen mode",
|
||||
"exit_fullscreen_mode": "Exit fullscreen mode",
|
||||
"loop": "Loop",
|
||||
"media_error_description": "A media error caused playback to be aborted. The media could be corrupt or your browser does not support this format.",
|
||||
"media_loading": "media loading",
|
||||
"mute": "Mute",
|
||||
"network_error": "Network error",
|
||||
"network_error_description": "A network error caused the media download to fail.",
|
||||
"not_supported_error": "Source Not Supported",
|
||||
"playback_rate": "Playback rate",
|
||||
"playback_rate_current": "current playback rate",
|
||||
"playback_rate_value": "Playback rate {playbackRate}",
|
||||
"playback_time": "playback time",
|
||||
"quality": "Quality",
|
||||
"second": "second",
|
||||
"seconds": "seconds",
|
||||
"time_value_of_total_time": "{currentTime} of {totalTime}",
|
||||
"time_value_remaining": "{time} remaining",
|
||||
"unmute": "Unmute",
|
||||
"unsupported_error_description": "An unsupported error occurred. The server or network failed, or your browser does not support this format.",
|
||||
"video_not_loaded_unknown_time": "video not loaded, unknown time.",
|
||||
"video_player": "video player",
|
||||
"volume": "volume"
|
||||
},
|
||||
"media_type": "Media type",
|
||||
"memories": "Memories",
|
||||
"memories_all_caught_up": "All caught up",
|
||||
@@ -1761,7 +1794,6 @@
|
||||
"play_original_video": "Play original video",
|
||||
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
|
||||
"play_transcoded_video": "Play transcoded video",
|
||||
"playback_speed": "Playback speed",
|
||||
"please_auth_to_access": "Please authenticate to access",
|
||||
"port": "Port",
|
||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||
@@ -1943,6 +1975,8 @@
|
||||
"search_by_description_example": "Hiking day in Sapa",
|
||||
"search_by_filename": "Search by file name or extension",
|
||||
"search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
|
||||
"search_by_full_path": "Search by full path or folder",
|
||||
"search_by_full_path_example": "/John/Projects/3D_Printing/2026-07-01 - you can search for Projects, 3D, Printing, 2026 etc.",
|
||||
"search_by_ocr": "Search by OCR",
|
||||
"search_by_ocr_example": "Latte",
|
||||
"search_camera_lens_model": "Search lens model...",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[tools]
|
||||
python = "3.11"
|
||||
uv = "0.8.15"
|
||||
|
||||
[tasks.install]
|
||||
run = "uv sync --locked"
|
||||
|
||||
[tasks.lint]
|
||||
run = "uv run ruff check immich_ml"
|
||||
|
||||
[tasks.test]
|
||||
run = "uv run pytest --cov=immich_ml --cov-report term-missing"
|
||||
|
||||
[tasks.format]
|
||||
run = "uv run ruff format immich_ml"
|
||||
|
||||
[tasks.check]
|
||||
run = "uv run mypy --strict immich_ml/"
|
||||
Generated
+61
-19
@@ -243,14 +243,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -785,17 +785,34 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hf-xet"
|
||||
version = "1.1.7"
|
||||
version = "1.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/0a/a0f56735940fde6dd627602fec9ab3bad23f66a272397560abd65aba416e/hf_xet-1.1.7.tar.gz", hash = "sha256:20cec8db4561338824a3b5f8c19774055b04a8df7fff0cb1ff2cb1a0c1607b80", size = 477719, upload-time = "2025-08-06T00:30:55.741Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/7c/8d7803995caf14e7d19a392a486a040f923e2cfeff824e9b800b92072f76/hf_xet-1.1.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:60dae4b44d520819e54e216a2505685248ec0adbdb2dd4848b17aa85a0375cde", size = 2761743, upload-time = "2025-08-06T00:30:50.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a3/fa5897099454aa287022a34a30e68dbff0e617760f774f8bd1db17f06bd4/hf_xet-1.1.7-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b109f4c11e01c057fc82004c9e51e6cdfe2cb230637644ade40c599739067b2e", size = 2624331, upload-time = "2025-08-06T00:30:49.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/50/2446a132267e60b8a48b2e5835d6e24fd988000d0f5b9b15ebd6d64ef769/hf_xet-1.1.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efaaf1a5a9fc3a501d3e71e88a6bfebc69ee3a716d0e713a931c8b8d920038f", size = 3183844, upload-time = "2025-08-06T00:30:47.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/8f/ccc670616bb9beee867c6bb7139f7eab2b1370fe426503c25f5cbb27b148/hf_xet-1.1.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:751571540f9c1fbad9afcf222a5fb96daf2384bf821317b8bfb0c59d86078513", size = 3074209, upload-time = "2025-08-06T00:30:45.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/0a/4c30e1eb77205565b854f5e4a82cf1f056214e4dc87f2918ebf83d47ae14/hf_xet-1.1.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:18b61bbae92d56ae731b92087c44efcac216071182c603fc535f8e29ec4b09b8", size = 3239602, upload-time = "2025-08-06T00:30:52.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1e/fc7e9baf14152662ef0b35fa52a6e889f770a7ed14ac239de3c829ecb47e/hf_xet-1.1.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:713f2bff61b252f8523739969f247aa354ad8e6d869b8281e174e2ea1bb8d604", size = 3348184, upload-time = "2025-08-06T00:30:54.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/73/e354eae84ceff117ec3560141224724794828927fcc013c5b449bf0b8745/hf_xet-1.1.7-cp37-abi3-win_amd64.whl", hash = "sha256:2e356da7d284479ae0f1dea3cf5a2f74fdf925d6dca84ac4341930d892c7cb34", size = 2820008, upload-time = "2025-08-06T00:30:57.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -857,21 +874,22 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "0.36.2"
|
||||
version = "1.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "fsspec" },
|
||||
{ name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
|
||||
{ name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/ff/ec7ed2eb43bd7ce8bb2233d109cc235c3e807ffe5e469dc09db261fac05e/huggingface_hub-1.13.0.tar.gz", hash = "sha256:f6df2dac5abe82ce2fe05873d10d5ff47bc677d616a2f521f4ee26db9415d9d0", size = 781788, upload-time = "2026-04-30T11:57:33.858Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/db/4b1cdae9460ae1f3ca020cd767f013430ce23eb1d9c890ae3a0609b38d26/huggingface_hub-1.13.0-py3-none-any.whl", hash = "sha256:e942cb50d6a08dd5306688b1ac05bda157fd2fcc88b63dae405f7bd0d3234005", size = 660643, upload-time = "2026-04-30T11:57:31.802Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -985,7 +1003,7 @@ requires-dist = [
|
||||
{ name = "aiocache", specifier = ">=0.12.1,<1.0" },
|
||||
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
|
||||
{ name = "gunicorn", specifier = ">=21.1.0" },
|
||||
{ name = "huggingface-hub", specifier = ">=0.20.1,<1.0" },
|
||||
{ name = "huggingface-hub", specifier = ">=1.0,<2.0" },
|
||||
{ name = "insightface", specifier = ">=0.7.3,<1.0" },
|
||||
{ name = "numpy", specifier = "<2.4.0" },
|
||||
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
|
||||
@@ -996,7 +1014,7 @@ requires-dist = [
|
||||
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" },
|
||||
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
|
||||
{ name = "orjson", specifier = ">=3.9.5" },
|
||||
{ name = "pillow", specifier = ">=12.2,<12.3" },
|
||||
{ name = "pillow", specifier = ">=12.2,<13" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0,<3" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.5.2,<3" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.6,<1.0" },
|
||||
@@ -2769,6 +2787,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/5e9b3ba5c7aa7ebfaf269657e728067d16a7c99401c7973ddf5f0cf121bd/shapely-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8cb8f17c377260452e9d7720eeaf59082c5f8ea48cf104524d953e5d36d4bdb7", size = 1723061, upload-time = "2025-05-19T11:04:40.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple-websocket"
|
||||
version = "1.1.0"
|
||||
@@ -2932,6 +2959,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ad/7d47bbf2cae78ff79f29db0bed5016ec9c56b212a93fca624bb88b551a7c/tqdm-4.66.3-py3-none-any.whl", hash = "sha256:4f41d54107ff9a223dca80b53efe4fb654c67efaba7f47bada3ee9d50e05bd53", size = 78374, upload-time = "2024-05-02T21:44:01.541Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20260408"
|
||||
|
||||
@@ -11,13 +11,14 @@ config_roots = [
|
||||
"web",
|
||||
"docs",
|
||||
".github",
|
||||
"machine-learning",
|
||||
]
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
flutter = "3.41.7"
|
||||
pnpm = "10.33.1"
|
||||
terragrunt = "1.0.2"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
java = "21.0.2"
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3046,
|
||||
"android.injected.version.name" => "2.7.5",
|
||||
"android.injected.version.code" => 3047,
|
||||
"android.injected.version.name" => "3.0.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
+3358
File diff suppressed because it is too large
Load Diff
@@ -78,7 +78,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.7.5</string>
|
||||
<string>3.0.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
|
||||
class AppConfig {
|
||||
final ThemeConfig theme;
|
||||
|
||||
const AppConfig({this.theme = const ThemeConfig()});
|
||||
|
||||
AppConfig copyWith({ThemeConfig? theme}) => .new(theme: theme ?? this.theme);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is AppConfig && other.theme == theme);
|
||||
|
||||
@override
|
||||
int get hashCode => theme.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'AppConfig(theme: $theme)';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
|
||||
const SystemConfig({this.logLevel = .info});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
|
||||
|
||||
@override
|
||||
int get hashCode => logLevel.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel)';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThemeConfig {
|
||||
final ThemeMode mode;
|
||||
|
||||
const ThemeConfig({this.mode = .system});
|
||||
|
||||
ThemeConfig copyWith({ThemeMode? mode}) => .new(mode: mode ?? this.mode);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is ThemeConfig && other.mode == mode);
|
||||
|
||||
@override
|
||||
int get hashCode => mode.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'ThemeConfig(mode: $mode)';
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
systemConfig<SystemConfig>('config.system');
|
||||
|
||||
final String prefix;
|
||||
const MetadataDomain(this.prefix);
|
||||
}
|
||||
|
||||
enum MetadataKey<T extends Object> {
|
||||
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
|
||||
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values));
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
final T defaultValue;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
|
||||
|
||||
String get key => '${domain.prefix}.$name';
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
|
||||
|
||||
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
|
||||
}
|
||||
|
||||
sealed class _MetadataCodec<T extends Object> {
|
||||
const _MetadataCodec();
|
||||
|
||||
String encode(T value);
|
||||
T? decode(String raw);
|
||||
|
||||
static const Map<Type, _MetadataCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
double: _PrimitiveCodec.real,
|
||||
bool: _PrimitiveCodec.boolean,
|
||||
String: _PrimitiveCodec.string,
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
|
||||
final codec = _primitives[sample.runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError(
|
||||
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
|
||||
);
|
||||
}
|
||||
return codec as _MetadataCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const _EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
const _DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
final T? Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T? decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.tryParse);
|
||||
static const real = _PrimitiveCodec<double>._(double.tryParse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String? _identity(String s) => s;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ enum Setting<T> {
|
||||
tilesPerRow<int>(StoreKey.tilesPerRow, 4),
|
||||
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
|
||||
showStorageIndicator<bool>(StoreKey.storageIndicator, true),
|
||||
loadPreview<bool>(StoreKey.loadPreview, true),
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, false),
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
|
||||
autoPlayVideo<bool>(StoreKey.autoPlayVideo, true),
|
||||
|
||||
@@ -22,7 +22,6 @@ enum StoreKey<T> {
|
||||
// user settings from [AppSettingsEnum] below:
|
||||
loadPreview<bool>._(100),
|
||||
loadOriginal<bool>._(101),
|
||||
themeMode<String>._(102),
|
||||
tilesPerRow<int>._(103),
|
||||
dynamicLayout<bool>._(104),
|
||||
groupAssetsBy<int>._(105),
|
||||
@@ -35,7 +34,6 @@ enum StoreKey<T> {
|
||||
albumThumbnailCacheSize<int>._(112),
|
||||
selectedAlbumSortOrder<int>._(113),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
logLevel<int>._(115),
|
||||
preferRemoteImage<bool>._(116),
|
||||
loopVideo<bool>._(117),
|
||||
// map related settings
|
||||
@@ -94,7 +92,11 @@ enum StoreKey<T> {
|
||||
cleanupCutoffDaysAgo<int>._(1011),
|
||||
cleanupDefaultsInitialized<bool>._(1012),
|
||||
|
||||
syncMigrationStatus<String>._(1013);
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyThemeMode<String>._(102),
|
||||
legacyLogLevel<int>._(115);
|
||||
|
||||
const StoreKey._(this.id);
|
||||
final int id;
|
||||
|
||||
@@ -2,20 +2,20 @@ import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Service responsible for handling application logging.
|
||||
///
|
||||
/// It listens to Dart's [Logger.root], buffers logs in memory (optionally),
|
||||
/// writes them to a persistent [ILogRepository], and manages log levels
|
||||
/// via [IStoreRepository]
|
||||
/// writes them to a persistent [LogRepository], and manages log levels via
|
||||
/// [MetadataRepository].
|
||||
class LogService {
|
||||
final LogRepository _logRepository;
|
||||
final DriftStoreRepository _storeRepository;
|
||||
final MetadataRepository _metadataRepository;
|
||||
|
||||
final List<LogMessage> _msgBuffer = [];
|
||||
|
||||
@@ -38,12 +38,12 @@ class LogService {
|
||||
|
||||
static Future<LogService> init({
|
||||
required LogRepository logRepository,
|
||||
required DriftStoreRepository storeRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
_instance ??= await create(
|
||||
logRepository: logRepository,
|
||||
storeRepository: storeRepository,
|
||||
metadataRepository: metadataRepository,
|
||||
shouldBuffer: shouldBuffer,
|
||||
);
|
||||
return _instance!;
|
||||
@@ -51,17 +51,17 @@ class LogService {
|
||||
|
||||
static Future<LogService> create({
|
||||
required LogRepository logRepository,
|
||||
required DriftStoreRepository storeRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, storeRepository, shouldBuffer);
|
||||
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = await instance._storeRepository.tryGet(StoreKey.logLevel) ?? LogLevel.info.index;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO;
|
||||
final level = instance._metadataRepository.systemConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
LogService._(this._logRepository, this._storeRepository, this._shouldBuffer) {
|
||||
LogService._(this._logRepository, this._metadataRepository, this._shouldBuffer) {
|
||||
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class LogService {
|
||||
}
|
||||
|
||||
Future<void> setLogLevel(LogLevel level) async {
|
||||
await _storeRepository.upsert(StoreKey.logLevel, level.index);
|
||||
await _metadataRepository.write(MetadataKey.logLevel, level);
|
||||
Logger.root.level = level.toLevel();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class MetadataEntity extends Table with DriftDefaultsMixin {
|
||||
const MetadataEntity();
|
||||
|
||||
TextColumn get key => text()();
|
||||
|
||||
TextColumn get value => text()();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {key};
|
||||
|
||||
@override
|
||||
String get tableName => "metadata";
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.dart'
|
||||
as i2;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
|
||||
|
||||
typedef $$MetadataEntityTableCreateCompanionBuilder =
|
||||
i1.MetadataEntityCompanion Function({
|
||||
required String key,
|
||||
required String value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
typedef $$MetadataEntityTableUpdateCompanionBuilder =
|
||||
i1.MetadataEntityCompanion Function({
|
||||
i0.Value<String> key,
|
||||
i0.Value<String> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
|
||||
class $$MetadataEntityTableFilterComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
|
||||
$$MetadataEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$MetadataEntityTableOrderingComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
|
||||
$$MetadataEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$MetadataEntityTableAnnotationComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
|
||||
$$MetadataEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get key =>
|
||||
$composableBuilder(column: $table.key, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<String> get value =>
|
||||
$composableBuilder(column: $table.value, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get updatedAt =>
|
||||
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$MetadataEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData,
|
||||
i1.$$MetadataEntityTableFilterComposer,
|
||||
i1.$$MetadataEntityTableOrderingComposer,
|
||||
i1.$$MetadataEntityTableAnnotationComposer,
|
||||
$$MetadataEntityTableCreateCompanionBuilder,
|
||||
$$MetadataEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.MetadataEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData
|
||||
>,
|
||||
),
|
||||
i1.MetadataEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$MetadataEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$MetadataEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$MetadataEntityTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
i1.$$MetadataEntityTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () => i1
|
||||
.$$MetadataEntityTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
i0.Value<String> key = const i0.Value.absent(),
|
||||
i0.Value<String> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.MetadataEntityCompanion(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String key,
|
||||
required String value,
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.MetadataEntityCompanion.insert(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$MetadataEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData,
|
||||
i1.$$MetadataEntityTableFilterComposer,
|
||||
i1.$$MetadataEntityTableOrderingComposer,
|
||||
i1.$$MetadataEntityTableAnnotationComposer,
|
||||
$$MetadataEntityTableCreateCompanionBuilder,
|
||||
$$MetadataEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.MetadataEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$MetadataEntityTable,
|
||||
i1.MetadataEntityData
|
||||
>,
|
||||
),
|
||||
i1.MetadataEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $MetadataEntityTable extends i2.MetadataEntity
|
||||
with i0.TableInfo<$MetadataEntityTable, i1.MetadataEntityData> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$MetadataEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
|
||||
'value',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
|
||||
'updatedAt',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<DateTime> updatedAt =
|
||||
i0.GeneratedColumn<DateTime>(
|
||||
'updated_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: i3.currentDateAndTime,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'metadata';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.MetadataEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('key')) {
|
||||
context.handle(
|
||||
_keyMeta,
|
||||
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_keyMeta);
|
||||
}
|
||||
if (data.containsKey('value')) {
|
||||
context.handle(
|
||||
_valueMeta,
|
||||
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_valueMeta);
|
||||
}
|
||||
if (data.containsKey('updated_at')) {
|
||||
context.handle(
|
||||
_updatedAtMeta,
|
||||
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {key};
|
||||
@override
|
||||
i1.MetadataEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.MetadataEntityData(
|
||||
key: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}key'],
|
||||
)!,
|
||||
value: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}value'],
|
||||
)!,
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}updated_at'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$MetadataEntityTable createAlias(String alias) {
|
||||
return $MetadataEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class MetadataEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.MetadataEntityData> {
|
||||
final String key;
|
||||
final String value;
|
||||
final DateTime updatedAt;
|
||||
const MetadataEntityData({
|
||||
required this.key,
|
||||
required this.value,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['key'] = i0.Variable<String>(key);
|
||||
map['value'] = i0.Variable<String>(value);
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
factory MetadataEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return MetadataEntityData(
|
||||
key: serializer.fromJson<String>(json['key']),
|
||||
value: serializer.fromJson<String>(json['value']),
|
||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'key': serializer.toJson<String>(key),
|
||||
'value': serializer.toJson<String>(value),
|
||||
'updatedAt': serializer.toJson<DateTime>(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
i1.MetadataEntityData copyWith({
|
||||
String? key,
|
||||
String? value,
|
||||
DateTime? updatedAt,
|
||||
}) => i1.MetadataEntityData(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
MetadataEntityData copyWithCompanion(i1.MetadataEntityCompanion data) {
|
||||
return MetadataEntityData(
|
||||
key: data.key.present ? data.key.value : this.key,
|
||||
value: data.value.present ? data.value.value : this.value,
|
||||
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MetadataEntityData(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, value, updatedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.MetadataEntityData &&
|
||||
other.key == this.key &&
|
||||
other.value == this.value &&
|
||||
other.updatedAt == this.updatedAt);
|
||||
}
|
||||
|
||||
class MetadataEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.MetadataEntityData> {
|
||||
final i0.Value<String> key;
|
||||
final i0.Value<String> value;
|
||||
final i0.Value<DateTime> updatedAt;
|
||||
const MetadataEntityCompanion({
|
||||
this.key = const i0.Value.absent(),
|
||||
this.value = const i0.Value.absent(),
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
});
|
||||
MetadataEntityCompanion.insert({
|
||||
required String key,
|
||||
required String value,
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
}) : key = i0.Value(key),
|
||||
value = i0.Value(value);
|
||||
static i0.Insertable<i1.MetadataEntityData> custom({
|
||||
i0.Expression<String>? key,
|
||||
i0.Expression<String>? value,
|
||||
i0.Expression<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (key != null) 'key': key,
|
||||
if (value != null) 'value': value,
|
||||
if (updatedAt != null) 'updated_at': updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
i1.MetadataEntityCompanion copyWith({
|
||||
i0.Value<String>? key,
|
||||
i0.Value<String>? value,
|
||||
i0.Value<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i1.MetadataEntityCompanion(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (key.present) {
|
||||
map['key'] = i0.Variable<String>(key.value);
|
||||
}
|
||||
if (value.present) {
|
||||
map['value'] = i0.Variable<String>(value.value);
|
||||
}
|
||||
if (updatedAt.present) {
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MetadataEntityCompanion(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
|
||||
@@ -53,6 +54,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.da
|
||||
StoreEntity,
|
||||
TrashedLocalAssetEntity,
|
||||
AssetEditEntity,
|
||||
MetadataEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
@@ -84,7 +86,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 24;
|
||||
int get schemaVersion => 25;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -250,6 +252,9 @@ class Drift extends $Drift {
|
||||
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
|
||||
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
|
||||
},
|
||||
from24To25: (m, v25) async {
|
||||
await m.createTable(v25.metadata);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+12
-4
@@ -43,9 +43,11 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity
|
||||
as i20;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
|
||||
as i22;
|
||||
import 'package:drift/internal/modular.dart' as i23;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i23;
|
||||
import 'package:drift/internal/modular.dart' as i24;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -89,9 +91,12 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
.$TrashedLocalAssetEntityTable(this);
|
||||
late final i21.$AssetEditEntityTable assetEditEntity = i21
|
||||
.$AssetEditEntityTable(this);
|
||||
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
|
||||
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
|
||||
this,
|
||||
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
|
||||
);
|
||||
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -129,6 +134,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
metadataEntity,
|
||||
i10.idxPartnerSharedWithId,
|
||||
i11.idxLatLng,
|
||||
i12.idxRemoteAlbumAssetAlbumAsset,
|
||||
@@ -389,4 +395,6 @@ class $DriftManager {
|
||||
);
|
||||
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i22.$$MetadataEntityTableTableManager get metadataEntity =>
|
||||
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
|
||||
}
|
||||
|
||||
@@ -12375,6 +12375,574 @@ class Shape48 extends i0.VersionedTable {
|
||||
columnsByName['order']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
final class Schema25 extends i0.VersionedSchema {
|
||||
Schema25({required super.database}) : super(version: 25);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetLocalDateTimeDay,
|
||||
idxRemoteAssetLocalDateTimeMonth,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
metadata,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape34 remoteAssetEntity = Shape34(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape36 localAssetEntity = Shape36(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
|
||||
'idx_remote_asset_local_date_time_day',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
||||
'idx_remote_asset_local_date_time_month',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_159, _column_177],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_205,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_206,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 metadata = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'metadata',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_211, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape49 extends i0.VersionedTable {
|
||||
Shape49({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get key =>
|
||||
columnsByName['key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get value =>
|
||||
columnsByName['value']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_210(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_211(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -12399,6 +12967,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
||||
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
|
||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -12517,6 +13086,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from23To24(migrator, schema);
|
||||
return 24;
|
||||
case 24:
|
||||
final schema = Schema25(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from24To25(migrator, schema);
|
||||
return 25;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -12547,6 +13121,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
||||
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
|
||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -12572,5 +13147,6 @@ i1.OnUpgrade stepByStep({
|
||||
from21To22: from21To22,
|
||||
from22To23: from22To23,
|
||||
from23To24: from23To24,
|
||||
from24To25: from24To25,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class MetadataRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
final Map<MetadataKey, Object> _cache = {};
|
||||
|
||||
MetadataRepository._(this._db) : super(_db);
|
||||
|
||||
static MetadataRepository? _instance;
|
||||
|
||||
static MetadataRepository get instance {
|
||||
final instance = _instance;
|
||||
if (instance == null) {
|
||||
throw StateError('MetadataRepository not initialized. Call ensureInitialized() first');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
SystemConfig _systemConfig = const .new();
|
||||
SystemConfig get systemConfig => _systemConfig;
|
||||
|
||||
static Future<MetadataRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = MetadataRepository._(db);
|
||||
await instance._hydrate();
|
||||
_instance = instance;
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static Future<void> refresh() async {
|
||||
instance._cache.clear();
|
||||
instance._appConfig = const .new();
|
||||
instance._systemConfig = const .new();
|
||||
await instance._hydrate();
|
||||
}
|
||||
|
||||
Future<void> _hydrate() async => _hydrateCache(await _db.select(_db.metadataEntity).get());
|
||||
|
||||
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
|
||||
|
||||
Future<void> write<T extends Object>(MetadataKey<T> key, T value) async {
|
||||
if (_read(key) == value) return;
|
||||
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
_updateCache(key, value);
|
||||
}
|
||||
|
||||
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
|
||||
_updateCache(key, key.defaultValue);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchAppConfig() => _watchDomain(MetadataDomain.appConfig).distinct();
|
||||
|
||||
Stream<SystemConfig> watchSystemConfig() => _watchDomain(MetadataDomain.systemConfig).distinct();
|
||||
|
||||
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
|
||||
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
|
||||
return query.watch().map((rows) {
|
||||
_hydrateCache(rows);
|
||||
return domain.config(this);
|
||||
});
|
||||
}
|
||||
|
||||
void _hydrateCache(List<MetadataEntityData> rows) {
|
||||
final keyMap = MetadataKey.asKeyMap();
|
||||
for (final row in rows) {
|
||||
final key = keyMap[row.key];
|
||||
if (key == null) continue;
|
||||
_updateCache(key, key.decode(row.value));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCache<T extends Object>(MetadataKey<T> key, T value) {
|
||||
if (_cache[key] == value) return;
|
||||
_cache[key] = value;
|
||||
key.domain.rebuild(this);
|
||||
}
|
||||
}
|
||||
|
||||
extension<T extends Object> on MetadataDomain<T> {
|
||||
T config(MetadataRepository repo) => switch (this) {
|
||||
.appConfig => repo._appConfig as T,
|
||||
.systemConfig => repo._systemConfig as T,
|
||||
};
|
||||
|
||||
void rebuild(MetadataRepository repo) {
|
||||
switch (this) {
|
||||
case .appConfig:
|
||||
repo._appConfig = .new(theme: .new(mode: repo._read(.themeMode)));
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ void main() async {
|
||||
await initApp();
|
||||
// Warm-up isolate pool for worker manager
|
||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
||||
await migrateDatabaseIfNeeded();
|
||||
await migrateDatabaseIfNeeded(drift);
|
||||
|
||||
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
|
||||
} catch (error, stack) {
|
||||
|
||||
@@ -26,6 +26,14 @@ class AssetDetails extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
offset: const Offset(0, -3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
|
||||
+10
-1
@@ -22,6 +22,7 @@ class TechnicalDetails extends ConsumerWidget {
|
||||
final exifInfo = this.exifInfo;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
final lensSubtitle = _getLensInfoSubtitle(exifInfo);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -46,9 +47,16 @@ class TechnicalDetails extends ConsumerWidget {
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitle: lensSubtitle,
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
] else if (lensSubtitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: lensSubtitle,
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -123,6 +131,7 @@ class TechnicalDetails extends ConsumerWidget {
|
||||
if (exifInfo == null) return null;
|
||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
if (fNumber == null && focalLength == null) return null;
|
||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
|
||||
completer.operation.valueOrCancellation().whenComplete(() {
|
||||
cachedStream.removeListener(listener);
|
||||
cachedOperation = null;
|
||||
});
|
||||
cachedOperation = completer.operation;
|
||||
return null;
|
||||
@@ -106,18 +105,33 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
}
|
||||
}
|
||||
|
||||
Stream<ImageInfo> initialImageStream() async* {
|
||||
Stream<ImageInfo> initialImageStream({required bool isFinal}) async* {
|
||||
final cachedOperation = this.cachedOperation;
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
if (cachedOperation == null) {
|
||||
// image resolved synchronously
|
||||
isFinished = isFinal;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final cachedImage = await cachedOperation.valueOrCancellation();
|
||||
if (cachedImage != null && !isCancelled) {
|
||||
yield cachedImage;
|
||||
if (isCancelled || cachedImage == null) {
|
||||
return;
|
||||
}
|
||||
isFinished = isFinal;
|
||||
yield cachedImage;
|
||||
} catch (e, stack) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
if (isFinal) {
|
||||
isFinished = true;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
rethrow;
|
||||
}
|
||||
_log.severe('Error loading initial image', e, stack);
|
||||
} finally {
|
||||
this.cachedOperation = null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
@@ -97,51 +97,55 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
final loadOriginal = AppSetting.get(Setting.loadOriginal);
|
||||
final loadPreview = AppSetting.get(Setting.loadPreview);
|
||||
yield* initialImageStream(isFinal: !loadOriginal && !loadPreview);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final loadOriginal = Store.get(StoreKey.loadOriginal, false);
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
var request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(request, decode, isFinal: !loadOriginal);
|
||||
if (loadPreview) {
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final previewRequest = request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!loadOriginal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
|
||||
|
||||
yield* loadRequest(request, decode, isFinal: true);
|
||||
final originalRequest = request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
|
||||
yield* loadRequest(originalRequest, decode, isFinal: true);
|
||||
}
|
||||
|
||||
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
yield* initialImageStream(isFinal: false);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final previewRequest = request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: false);
|
||||
if (AppSetting.get(Setting.loadPreview)) {
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final previewRequest = request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: false);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// always try original for animated, since previews don't support animation
|
||||
|
||||
@@ -107,31 +107,35 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
final isImage = assetType == AssetType.image;
|
||||
final loadOriginal = isImage && AppSetting.get(Setting.loadOriginal);
|
||||
final loadPreview = isImage && AppSetting.get(Setting.loadPreview);
|
||||
yield* initialImageStream(isFinal: !loadOriginal && !loadPreview);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previewRequest = request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: AssetMediaSize.preview,
|
||||
thumbhash: key.thumbhash,
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
|
||||
if (loadPreview) {
|
||||
final previewRequest = request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: AssetMediaSize.preview,
|
||||
thumbhash: key.thumbhash,
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!loadOriginal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final originalRequest = request = RemoteImageRequest(
|
||||
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
|
||||
);
|
||||
@@ -139,24 +143,26 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
}
|
||||
|
||||
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
yield* initialImageStream(isFinal: false);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previewRequest = request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: AssetMediaSize.preview,
|
||||
thumbhash: key.thumbhash,
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: false);
|
||||
if (AppSetting.get(Setting.loadPreview)) {
|
||||
final previewRequest = request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: AssetMediaSize.preview,
|
||||
thumbhash: key.thumbhash,
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: false);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// always try original for animated, since previews don't support animation
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
|
||||
final metadataProvider = Provider<MetadataRepository>((_) => MetadataRepository.instance);
|
||||
|
||||
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchAppConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.appConfig;
|
||||
});
|
||||
|
||||
final systemConfigProvider = Provider.autoDispose<SystemConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchSystemConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.systemConfig;
|
||||
});
|
||||
@@ -1,27 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
|
||||
final themeMode = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.themeMode);
|
||||
|
||||
dPrint(() => "Current themeMode $themeMode");
|
||||
|
||||
if (themeMode == ThemeMode.light.name) {
|
||||
return ThemeMode.light;
|
||||
} else if (themeMode == ThemeMode.dark.name) {
|
||||
return ThemeMode.dark;
|
||||
} else {
|
||||
return ThemeMode.system;
|
||||
}
|
||||
});
|
||||
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) => ref.watch(appConfigProvider).theme.mode);
|
||||
|
||||
final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
|
||||
final appSettingsProvider = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
enum AppSettingsEnum<T> {
|
||||
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
|
||||
themeMode<String>(StoreKey.themeMode, "themeMode", "system"), // "light","dark","system"
|
||||
primaryColor<String>(StoreKey.primaryColor, "primaryColor", defaultColorPresetName),
|
||||
dynamicTheme<bool>(StoreKey.dynamicTheme, "dynamicTheme", false),
|
||||
colorfulInterface<bool>(StoreKey.colorfulInterface, "colorfulInterface", true),
|
||||
@@ -30,7 +29,6 @@ enum AppSettingsEnum<T> {
|
||||
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
@@ -48,9 +49,11 @@ abstract final class Bootstrap {
|
||||
|
||||
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
|
||||
|
||||
final metadataRepo = await MetadataRepository.ensureInitialized(drift);
|
||||
|
||||
await LogService.init(
|
||||
logRepository: LogRepository(logDb),
|
||||
storeRepository: storeRepo,
|
||||
metadataRepository: metadataRepo,
|
||||
shouldBuffer: shouldBufferLogs,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
ValueNotifier<T> useAppSettingsState<T>(AppSettingsEnum<T> key) {
|
||||
final notifier = useState<T>(Store.get(key.storeKey, key.defaultValue));
|
||||
|
||||
@@ -1,25 +1,76 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
|
||||
const int targetVersion = 25;
|
||||
const int targetVersion = 26;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded() async {
|
||||
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
final int version = Store.get(StoreKey.version, targetVersion);
|
||||
|
||||
if (version < 25) {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
if (accessToken != null && accessToken.isNotEmpty) {
|
||||
final serverUrls = ApiService.getServerUrls();
|
||||
if (serverUrls.isNotEmpty) {
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
|
||||
}
|
||||
}
|
||||
await _migrateTo25();
|
||||
}
|
||||
|
||||
if (version < 26) {
|
||||
await _migrateTo26(drift);
|
||||
}
|
||||
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> _migrateTo25() async {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
if (accessToken == null || accessToken.isEmpty) return;
|
||||
|
||||
final serverUrls = ApiService.getServerUrls();
|
||||
if (serverUrls.isEmpty) return;
|
||||
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
|
||||
}
|
||||
|
||||
Future<void> _migrateTo26(Drift drift) async {
|
||||
final repo = MetadataRepository.instance;
|
||||
final migrated = <int>[];
|
||||
|
||||
final themeMode = await _readLegacyStoreString(drift, StoreKey.legacyThemeMode.id);
|
||||
if (themeMode != null) {
|
||||
final mode = ThemeMode.values.firstWhere((m) => m.name == themeMode, orElse: () => ThemeMode.system);
|
||||
await repo.write(MetadataKey.themeMode, mode);
|
||||
migrated.add(StoreKey.legacyThemeMode.id);
|
||||
}
|
||||
|
||||
final logLevelIndex = await _readLegacyStoreInt(drift, StoreKey.legacyLogLevel.id);
|
||||
if (logLevelIndex != null) {
|
||||
final logLevel = LogLevel.values.elementAtOrNull(logLevelIndex) ?? LogLevel.info;
|
||||
await LogService.I.setLogLevel(logLevel);
|
||||
migrated.add(StoreKey.legacyLogLevel.id);
|
||||
}
|
||||
|
||||
await _deleteLegacyStoreRows(drift, migrated);
|
||||
}
|
||||
|
||||
Future<String?> _readLegacyStoreString(Drift drift, int id) async {
|
||||
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
return row?.stringValue;
|
||||
}
|
||||
|
||||
Future<int?> _readLegacyStoreInt(Drift drift, int id) async {
|
||||
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
return row?.intValue;
|
||||
}
|
||||
|
||||
Future<void> _deleteLegacyStoreRows(Drift drift, List<int> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
await (drift.storeEntity.delete()..where((t) => t.id.isIn(ids))).go();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
@@ -30,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
|
||||
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
|
||||
class ThemeSetting extends HookConsumerWidget {
|
||||
const ThemeSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentThemeString = useAppSettingsState(AppSettingsEnum.themeMode);
|
||||
final currentTheme = useValueNotifier(ref.read(immichThemeModeProvider));
|
||||
final currentTheme = useState(ref.read(immichThemeModeProvider));
|
||||
final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark);
|
||||
final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system);
|
||||
|
||||
final applyThemeToBackgroundSetting = useAppSettingsState(AppSettingsEnum.colorfulInterface);
|
||||
final applyThemeToBackgroundProvider = useValueNotifier(ref.read(colorfulInterfaceSettingProvider));
|
||||
|
||||
useValueChanged(
|
||||
currentThemeString.value,
|
||||
(_, __) => currentTheme.value = switch (currentThemeString.value) {
|
||||
"light" => ThemeMode.light,
|
||||
"dark" => ThemeMode.dark,
|
||||
_ => ThemeMode.system,
|
||||
},
|
||||
);
|
||||
|
||||
useValueChanged(
|
||||
applyThemeToBackgroundSetting.value,
|
||||
(_, __) => applyThemeToBackgroundProvider.value = applyThemeToBackgroundSetting.value,
|
||||
@@ -40,16 +32,17 @@ class ThemeSetting extends HookConsumerWidget {
|
||||
void onThemeChange(bool isDark) {
|
||||
if (isDark) {
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark;
|
||||
currentThemeString.value = "dark";
|
||||
currentTheme.value = ThemeMode.dark;
|
||||
} else {
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
|
||||
currentThemeString.value = "light";
|
||||
currentTheme.value = ThemeMode.light;
|
||||
}
|
||||
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value);
|
||||
}
|
||||
|
||||
void onSystemThemeChange(bool isSystem) {
|
||||
if (isSystem) {
|
||||
currentThemeString.value = "system";
|
||||
currentTheme.value = ThemeMode.system;
|
||||
isSystemTheme.value = true;
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.system;
|
||||
} else {
|
||||
@@ -57,13 +50,14 @@ class ThemeSetting extends HookConsumerWidget {
|
||||
isSystemTheme.value = false;
|
||||
isDarkTheme.value = currentSystemBrightness == Brightness.dark;
|
||||
if (currentSystemBrightness == Brightness.light) {
|
||||
currentThemeString.value = "light";
|
||||
currentTheme.value = ThemeMode.light;
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
|
||||
} else if (currentSystemBrightness == Brightness.dark) {
|
||||
currentThemeString.value = "dark";
|
||||
currentTheme.value = ThemeMode.dark;
|
||||
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark;
|
||||
}
|
||||
}
|
||||
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value);
|
||||
}
|
||||
|
||||
void onSurfaceColorSettingChange(bool useColorfulInterface) {
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.7.5
|
||||
- API version: 3.0.0
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.7.5+3046
|
||||
version: 3.0.0+3047
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -29,21 +29,23 @@ final _kWarnLog = LogMessage(
|
||||
void main() {
|
||||
late LogService sut;
|
||||
late LogRepository mockLogRepo;
|
||||
late DriftStoreRepository mockStoreRepo;
|
||||
late MockMetadataRepository mockMetadataRepository;
|
||||
|
||||
setUp(() async {
|
||||
mockLogRepo = MockLogRepository();
|
||||
mockStoreRepo = MockDriftStoreRepository();
|
||||
mockMetadataRepository = MockMetadataRepository();
|
||||
|
||||
registerFallbackValue(_kInfoLog);
|
||||
registerFallbackValue(LogLevel.info);
|
||||
|
||||
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
|
||||
when(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).thenAnswer((_) async => LogLevel.fine.index);
|
||||
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
|
||||
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
|
||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
|
||||
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo);
|
||||
sut = await LogService.create(logRepository: mockLogRepo, metadataRepository: mockMetadataRepository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -56,21 +58,22 @@ void main() {
|
||||
expect(limit, kLogTruncateLimit);
|
||||
});
|
||||
|
||||
test('Sets log level based on the store setting', () {
|
||||
verify(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).called(1);
|
||||
test('Sets log level based on the metadata repository', () {
|
||||
verify(() => mockMetadataRepository.systemConfig).called(1);
|
||||
expect(Logger.root.level, Level.FINE);
|
||||
});
|
||||
});
|
||||
|
||||
group("Log Service Set Level:", () {
|
||||
setUp(() async {
|
||||
when(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, any())).thenAnswer((_) async => true);
|
||||
await sut.setLogLevel(LogLevel.shout);
|
||||
});
|
||||
|
||||
test('Updates the log level in store', () {
|
||||
final index = verify(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, captureAny())).captured.firstOrNull;
|
||||
expect(index, LogLevel.shout.index);
|
||||
test('Updates the log level via metadata repository', () {
|
||||
final captured = verify(
|
||||
() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, captureAny()),
|
||||
).captured.firstOrNull;
|
||||
expect(captured, LogLevel.shout);
|
||||
});
|
||||
|
||||
test('Sets log level on logger', () {
|
||||
@@ -81,7 +84,11 @@ void main() {
|
||||
group("Log Service Buffer:", () {
|
||||
test('Buffers logs until timer elapses', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
@@ -95,7 +102,11 @@ void main() {
|
||||
|
||||
test('Batch inserts all logs on timer', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
@@ -112,7 +123,11 @@ void main() {
|
||||
|
||||
test('Does not buffer when off', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: false);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: false,
|
||||
);
|
||||
|
||||
final logger = Logger(_kInfoLog.logger!);
|
||||
logger.info(_kInfoLog.message);
|
||||
@@ -142,7 +157,11 @@ void main() {
|
||||
|
||||
test('Combines result from both DB + Buffer', () {
|
||||
TestUtils.fakeAsync((time) async {
|
||||
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
metadataRepository: mockMetadataRepository,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
final logger = Logger(_kWarnLog.logger!);
|
||||
logger.warning(_kWarnLog.message);
|
||||
|
||||
+4
@@ -28,6 +28,7 @@ import 'schema_v21.dart' as v21;
|
||||
import 'schema_v22.dart' as v22;
|
||||
import 'schema_v23.dart' as v23;
|
||||
import 'schema_v24.dart' as v24;
|
||||
import 'schema_v25.dart' as v25;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -81,6 +82,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v23.DatabaseAtV23(db);
|
||||
case 24:
|
||||
return v24.DatabaseAtV24(db);
|
||||
case 25:
|
||||
return v25.DatabaseAtV25(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -111,5 +114,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
];
|
||||
}
|
||||
|
||||
+9345
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
@@ -17,6 +18,8 @@ import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockDriftStoreRepository extends Mock implements DriftStoreRepository {}
|
||||
|
||||
class MockMetadataRepository extends Mock implements MetadataRepository {}
|
||||
|
||||
class MockLogRepository extends Mock implements LogRepository {}
|
||||
|
||||
class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late MetadataRepository sut;
|
||||
|
||||
setUpAll(() async {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = await MetadataRepository.ensureInitialized(ctx.db);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
await MetadataRepository.refresh();
|
||||
});
|
||||
|
||||
group('defaults', () {
|
||||
test('appConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
test('systemConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.systemConfig.logLevel, LogLevel.info);
|
||||
});
|
||||
});
|
||||
|
||||
group('write', () {
|
||||
test('persists a value and reflects it in the composed view', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
test('persists across domains independently', () async {
|
||||
await sut.write(.themeMode, ThemeMode.light);
|
||||
await sut.write(.logLevel, LogLevel.severe);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.light);
|
||||
expect(sut.systemConfig.logLevel, LogLevel.severe);
|
||||
});
|
||||
});
|
||||
|
||||
group('delete', () {
|
||||
test('removes the row and reverts to default', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await sut.delete(.themeMode);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('refresh', () {
|
||||
test('picks up rows that were inserted directly into the DB', () async {
|
||||
await ctx.db
|
||||
.into(ctx.db.metadataEntity)
|
||||
.insert(
|
||||
MetadataEntityCompanion.insert(
|
||||
key: MetadataKey.themeMode.key,
|
||||
value: ThemeMode.dark.name,
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
// Cache hasn't seen this row yet — view still returns the default.
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
test('drops cached values for rows that were deleted out from under the repo', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
// Wipe the row directly. Cache still holds the old value.
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
test('skips rows whose key is unknown to MetadataKey', () async {
|
||||
await ctx.db
|
||||
.into(ctx.db.metadataEntity)
|
||||
.insert(
|
||||
MetadataEntityCompanion.insert(
|
||||
key: 'app-config.unknown.future-key',
|
||||
value: 'whatever',
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
});
|
||||
|
||||
group('watch', () {
|
||||
test('watchAppConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('watchAppConfig does not emit when only system-config rows change', () async {
|
||||
final emissions = <ThemeMode>[];
|
||||
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
|
||||
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
|
||||
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.severe);
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(emissions, isEmpty);
|
||||
});
|
||||
|
||||
test('watchSystemConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.warning);
|
||||
await expectation;
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -109,36 +108,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('logout', () {
|
||||
test('Should logout user', () async {
|
||||
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => backgroundSyncManager.cancel()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
|
||||
test('Should clear local data even on server error', () async {
|
||||
when(() => authApiRepository.logout()).thenThrow(Exception('Server error'));
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => backgroundSyncManager.cancel()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('setOpenApiServiceEndpoint', () {
|
||||
setUp(() {
|
||||
when(() => networkService.getWifiName()).thenAnswer((_) async => 'TestWifi');
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
|
||||
void main() {
|
||||
group('MetadataKey', () {
|
||||
test('every key round-trips its default value losslessly', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
final encoded = key.encode(key.defaultValue);
|
||||
final decoded = key.decode(encoded);
|
||||
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
}
|
||||
});
|
||||
|
||||
test('decode falls back to the default value when the raw input is unparseable', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
expect(
|
||||
key.decode('not a valid encoding for any key'),
|
||||
key.defaultValue,
|
||||
reason: 'fallback failed for ${key.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -14969,7 +14969,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.7.5",
|
||||
"version": "3.0.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.7.5
|
||||
* 3.0.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.7.5",
|
||||
"version": "3.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MoveRepository } from 'src/repositories/move.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { VideoInterfaces } from 'src/types';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
|
||||
@@ -299,6 +300,11 @@ export class StorageCore {
|
||||
return this.storageRepository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
||||
}
|
||||
|
||||
async getVideoInterfaces(): Promise<VideoInterfaces> {
|
||||
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
||||
return { dri, mali };
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.Original: {
|
||||
@@ -330,4 +336,26 @@ export class StorageCore {
|
||||
static getTempPathInDir(dir: string): string {
|
||||
return join(dir, `${randomUUID()}.tmp`);
|
||||
}
|
||||
|
||||
private async getDevices() {
|
||||
try {
|
||||
return await this.storageRepository.readdir('/dev/dri');
|
||||
} catch {
|
||||
this.logger.debug('No devices found in /dev/dri.');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async hasMaliOpenCL() {
|
||||
try {
|
||||
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
||||
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
||||
this.storageRepository.stat('/dev/mali0'),
|
||||
]);
|
||||
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} catch {
|
||||
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
describe('applyEdits (single actions)', () => {
|
||||
it('should apply crop edit correctly', async () => {
|
||||
const result = await sut['applyEdits'](
|
||||
const result = sut['applyEdits'](
|
||||
sharp({
|
||||
create: {
|
||||
width: 1000,
|
||||
@@ -98,7 +98,7 @@ describe(MediaRepository.name, () => {
|
||||
expect(metadata.height).toBe(300);
|
||||
});
|
||||
it('should apply rotate edit correctly', async () => {
|
||||
const result = await sut['applyEdits'](
|
||||
const result = sut['applyEdits'](
|
||||
sharp({
|
||||
create: {
|
||||
width: 500,
|
||||
@@ -123,7 +123,7 @@ describe(MediaRepository.name, () => {
|
||||
});
|
||||
|
||||
it('should apply mirror edit correctly', async () => {
|
||||
const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
const resultHorizontal = sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
@@ -142,7 +142,7 @@ describe(MediaRepository.name, () => {
|
||||
expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 255, g: 255, b: 0 });
|
||||
expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 });
|
||||
|
||||
const resultVertical = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
const resultVertical = sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
@@ -170,7 +170,7 @@ describe(MediaRepository.name, () => {
|
||||
describe('applyEdits (multiple sequential edits)', () => {
|
||||
it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
]);
|
||||
@@ -188,7 +188,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply rotate 90° then horizontal mirror', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
]);
|
||||
@@ -206,7 +206,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply 180° rotation', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
]);
|
||||
|
||||
@@ -223,7 +223,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply 270° rotations', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
]);
|
||||
|
||||
@@ -240,7 +240,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply crop then rotate 90°', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
]);
|
||||
@@ -256,7 +256,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply rotate 90° then crop', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
]);
|
||||
@@ -272,7 +272,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
@@ -291,7 +291,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply crop to single quadrant then mirror', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
]);
|
||||
@@ -309,7 +309,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply all operations: crop, rotate, mirror', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
|
||||
@@ -77,34 +77,20 @@ export class MediaRepository {
|
||||
* @returns ExtractResult if succeeded, or null if failed
|
||||
*/
|
||||
async extract(input: string): Promise<ExtractResult | null> {
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
||||
return { buffer, format: RawExtractedFormat.Jxl };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
||||
return null;
|
||||
for (const { tag, format } of [
|
||||
{ tag: 'JpgFromRaw2', format: RawExtractedFormat.Jpeg },
|
||||
{ tag: 'JpgFromRaw', format: RawExtractedFormat.Jpeg },
|
||||
{ tag: 'PreviewJXL', format: RawExtractedFormat.Jxl },
|
||||
{ tag: 'PreviewImage', format: RawExtractedFormat.Jpeg },
|
||||
]) {
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer(tag, input);
|
||||
return { buffer, format };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract ${tag} buffer from image: ${error}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
|
||||
@@ -162,49 +148,45 @@ export class MediaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
return pipeline.raw().toBuffer({ resolveWithObject: true });
|
||||
decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
|
||||
}
|
||||
|
||||
private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise<sharp.Sharp> {
|
||||
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
|
||||
const matrix = createAffineMatrix(affineEditOperations);
|
||||
|
||||
private applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): sharp.Sharp {
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
const dimensions = await pipeline.metadata();
|
||||
|
||||
if (crop) {
|
||||
pipeline = pipeline.extract({
|
||||
left: crop ? Math.round(crop.parameters.x) : 0,
|
||||
top: crop ? Math.round(crop.parameters.y) : 0,
|
||||
width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0,
|
||||
height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0,
|
||||
left: Math.round(crop.parameters.x),
|
||||
top: Math.round(crop.parameters.y),
|
||||
width: Math.round(crop.parameters.width),
|
||||
height: Math.round(crop.parameters.height),
|
||||
});
|
||||
}
|
||||
|
||||
const { a, b, c, d } = matrix;
|
||||
pipeline = pipeline.affine([
|
||||
[a, b],
|
||||
[c, d],
|
||||
]);
|
||||
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
|
||||
if (affineEditOperations.length > 0) {
|
||||
const { a, b, c, d } = createAffineMatrix(affineEditOperations);
|
||||
pipeline = pipeline.affine([
|
||||
[a, b],
|
||||
[c, d],
|
||||
]);
|
||||
}
|
||||
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
const decoded = pipeline.toFormat(options.format, {
|
||||
quality: options.quality,
|
||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
});
|
||||
|
||||
await decoded.toFile(output);
|
||||
await this.getImageDecodingPipeline(input, options)
|
||||
.toFormat(options.format, {
|
||||
quality: options.quality,
|
||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
})
|
||||
.toFile(output);
|
||||
}
|
||||
|
||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
let pipeline = sharp(input, {
|
||||
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
||||
failOn: options.processInvalidImages ? 'none' : 'error',
|
||||
@@ -228,7 +210,7 @@ export class MediaRepository {
|
||||
}
|
||||
|
||||
if (options.edits && options.edits.length > 0) {
|
||||
pipeline = await this.applyEdits(pipeline, options.edits);
|
||||
pipeline = this.applyEdits(pipeline, options.edits);
|
||||
}
|
||||
|
||||
if (options.size !== undefined) {
|
||||
@@ -238,19 +220,18 @@ export class MediaRepository {
|
||||
}
|
||||
|
||||
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
|
||||
const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([
|
||||
import('thumbhash'),
|
||||
this.getImageDecodingPipeline(input, {
|
||||
colorspace: options.colorspace,
|
||||
processInvalidImages: options.processInvalidImages,
|
||||
raw: options.raw,
|
||||
edits: options.edits,
|
||||
}),
|
||||
]);
|
||||
const { rgbaToThumbHash } = await import('thumbhash');
|
||||
|
||||
const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha();
|
||||
|
||||
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
||||
const { data, info } = await this.getImageDecodingPipeline(input, {
|
||||
colorspace: options.colorspace,
|
||||
processInvalidImages: options.processInvalidImages,
|
||||
raw: options.raw,
|
||||
edits: options.edits,
|
||||
})
|
||||
.resize(100, 100, { fit: 'inside', withoutEnlargement: true })
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return Buffer.from(rgbaToThumbHash(info.width, info.height, data));
|
||||
}
|
||||
@@ -274,23 +255,23 @@ export class MediaRepository {
|
||||
index: stream.index,
|
||||
height,
|
||||
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : (stream.codec_name ?? null),
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined) ?? null,
|
||||
level: this.parseOptionalInt(stream.level),
|
||||
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
|
||||
frameRate: this.parseFrameRate(stream.avg_frame_rate ?? stream.r_frame_rate),
|
||||
timeBase: this.parseRational(stream.time_base)?.den,
|
||||
timeBase: this.parseRational(stream.time_base)?.den ?? null,
|
||||
rotation: this.parseInt(stream.rotation),
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
pixelFormat: stream.pix_fmt || 'yuv420p',
|
||||
colorPrimaries: this.parseEnum(ColorPrimaries, stream.color_primaries) ?? ColorPrimaries.Unknown,
|
||||
colorMatrix: this.parseEnum(ColorMatrix, stream.color_space) ?? ColorMatrix.Unknown,
|
||||
colorTransfer: this.parseEnum(ColorTransfer, stream.color_transfer) ?? ColorTransfer.Unknown,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | undefined,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | null,
|
||||
dvLevel: this.parseOptionalInt(stream.dv_level),
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(stream.dv_bl_signal_compatibility_id) as
|
||||
| DvSignalCompatibility
|
||||
| undefined,
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(
|
||||
stream.dv_bl_signal_compatibility_id,
|
||||
) as DvSignalCompatibility | null,
|
||||
};
|
||||
}),
|
||||
audioStreams: results.streams
|
||||
@@ -298,9 +279,9 @@ export class MediaRepository {
|
||||
.sort((a, b) => this.compareStreams(a, b))
|
||||
.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecName: stream.codec_name,
|
||||
codecName: stream.codec_name ?? null,
|
||||
profile:
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : undefined,
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : null,
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
})),
|
||||
};
|
||||
@@ -449,29 +430,29 @@ export class MediaRepository {
|
||||
return Number.parseFloat(value as string) || 0;
|
||||
}
|
||||
|
||||
private parseOptionalInt(value: string | number | undefined): number | undefined {
|
||||
private parseOptionalInt(value: string | number | undefined): number | null {
|
||||
const parsed = Number.parseInt(value as string);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
private parseEnum<E extends Record<string, number | string>>(enumObj: E, value?: string) {
|
||||
return value ? (enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) : undefined;
|
||||
return value ? ((enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) ?? null) : null;
|
||||
}
|
||||
|
||||
/** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | undefined {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | null {
|
||||
if (value) {
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseFrameRate(value: string | undefined): number | undefined {
|
||||
private parseFrameRate(value: string | undefined): number | null {
|
||||
const r = this.parseRational(value);
|
||||
return r ? r.num / r.den : undefined;
|
||||
return r ? r.num / r.den : null;
|
||||
}
|
||||
|
||||
private getDar(dar: string | undefined): number {
|
||||
@@ -498,6 +479,7 @@ export class MediaRepository {
|
||||
return this.parseEnum(Av1Profile, profile);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotNull, ShallowDehydrateObject } from 'kysely';
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
@@ -1937,7 +1937,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
describe('handleVideoConversion', () => {
|
||||
let asset: ReturnType<typeof AssetFactory.create> & {
|
||||
videoStream: VideoStreamInfo & { timeBase: NotNull };
|
||||
videoStream: VideoStreamInfo & { timeBase: number };
|
||||
audioStream: AudioStreamInfo | null;
|
||||
format: VideoFormat;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ImageFormat,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
@@ -60,10 +61,9 @@ type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForG
|
||||
export class MediaService extends BaseService {
|
||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap' })
|
||||
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
|
||||
async onBootstrap() {
|
||||
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
||||
this.videoInterfaces = { dri, mali };
|
||||
this.videoInterfaces = await this.storageCore.getVideoInterfaces();
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -789,28 +789,6 @@ export class MediaService extends BaseService {
|
||||
return extractedSize >= targetSize;
|
||||
}
|
||||
|
||||
private async getDevices() {
|
||||
try {
|
||||
return await this.storageRepository.readdir('/dev/dri');
|
||||
} catch {
|
||||
this.logger.debug('No devices found in /dev/dri.');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async hasMaliOpenCL() {
|
||||
try {
|
||||
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
||||
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
||||
this.storageRepository.stat('/dev/mali0'),
|
||||
]);
|
||||
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} catch {
|
||||
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles(
|
||||
oldFiles: (AssetFile & { isProgressive: boolean; isTransparent: boolean })[],
|
||||
newFiles: UpsertFileOptions[],
|
||||
|
||||
@@ -672,7 +672,7 @@ describe(MetadataService.name, () => {
|
||||
colorPrimaries: 9,
|
||||
colorTransfer: 16,
|
||||
colorMatrix: 9,
|
||||
dvProfile: undefined,
|
||||
dvProfile: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
+10
-10
@@ -89,26 +89,26 @@ export interface VideoStreamInfo {
|
||||
height: number;
|
||||
width: number;
|
||||
rotation: number;
|
||||
codecName?: string;
|
||||
profile?: H264Profile | HevcProfile | Av1Profile;
|
||||
level?: number;
|
||||
codecName: string | null;
|
||||
profile: H264Profile | HevcProfile | Av1Profile | null;
|
||||
level: number | null;
|
||||
frameCount: number;
|
||||
frameRate?: number;
|
||||
timeBase?: number;
|
||||
frameRate: number | null;
|
||||
timeBase: number | null;
|
||||
bitrate: number;
|
||||
pixelFormat: string;
|
||||
colorPrimaries: ColorPrimaries;
|
||||
colorMatrix: ColorMatrix;
|
||||
colorTransfer: ColorTransfer;
|
||||
dvProfile?: DvProfile;
|
||||
dvLevel?: number;
|
||||
dvBlSignalCompatibilityId?: DvSignalCompatibility;
|
||||
dvProfile: DvProfile | null;
|
||||
dvLevel: number | null;
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility | null;
|
||||
}
|
||||
|
||||
export interface AudioStreamInfo {
|
||||
index: number;
|
||||
codecName?: string;
|
||||
profile?: AacProfile;
|
||||
codecName: string | null;
|
||||
profile: AacProfile | null;
|
||||
bitrate: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AssetFileType, AssetVisibility, DatabaseExtension, ExifOrientation } fr
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoStreamInfo } from 'src/types';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
|
||||
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
|
||||
return {
|
||||
@@ -146,7 +146,7 @@ export function withVideoStream(eb: ExpressionBuilder<DB, 'asset_exif' | 'asset_
|
||||
'asset_video.dvBlSignalCompatibilityId',
|
||||
])
|
||||
.where('asset_video.assetId', 'is not', sql.lit(null)),
|
||||
).$castTo<(VideoStreamInfo & { timeBase: NotNull }) | null>();
|
||||
).$castTo<(VideoStreamInfo & { timeBase: number }) | null>();
|
||||
}
|
||||
|
||||
export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video'>) {
|
||||
@@ -158,6 +158,22 @@ export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video
|
||||
).$castTo<VideoFormat | null>();
|
||||
}
|
||||
|
||||
export function withVideoPackets(eb: ExpressionBuilder<DB, 'asset' | 'asset_keyframe'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom(dummy)
|
||||
.where('asset_keyframe.assetId', 'is not', sql.lit(null))
|
||||
.select([
|
||||
'asset_keyframe.pts as keyframePts',
|
||||
'asset_keyframe.accDuration as keyframeAccDuration',
|
||||
'asset_keyframe.ownDuration as keyframeOwnDuration',
|
||||
'asset_keyframe.totalDuration',
|
||||
'asset_keyframe.packetCount',
|
||||
'asset_keyframe.outputFrames',
|
||||
]),
|
||||
).$castTo<VideoPacketInfo | null>();
|
||||
}
|
||||
|
||||
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
|
||||
Vendored
+220
-12
@@ -1,5 +1,13 @@
|
||||
import { NotNull } from 'kysely';
|
||||
import { ColorMatrix, ColorPrimaries, ColorTransfer, DvProfile, DvSignalCompatibility } from 'src/enum';
|
||||
import {
|
||||
AacProfile,
|
||||
ColorMatrix,
|
||||
ColorPrimaries,
|
||||
ColorTransfer,
|
||||
DvProfile,
|
||||
DvSignalCompatibility,
|
||||
H264Profile,
|
||||
HevcProfile,
|
||||
} from 'src/enum';
|
||||
import { AudioStreamInfo, VideoFormat, VideoInfo, VideoStreamInfo } from 'src/types';
|
||||
|
||||
const probeStubDefaultFormat: VideoFormat = {
|
||||
@@ -22,11 +30,17 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
];
|
||||
|
||||
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ index: 3, codecName: 'mp3', bitrate: 100 }];
|
||||
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ index: 3, codecName: 'mp3', bitrate: 100, profile: null }];
|
||||
|
||||
const probeStubDefault: VideoInfo = {
|
||||
format: probeStubDefaultFormat,
|
||||
@@ -53,7 +67,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
@@ -67,7 +87,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
@@ -81,16 +107,22 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
multipleAudioStreams: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
{ index: 2, codecName: 'mp3', bitrate: 102 },
|
||||
{ index: 1, codecName: 'mp3', bitrate: 101 },
|
||||
{ index: 0, codecName: 'mp3', bitrate: 100 },
|
||||
{ index: 2, codecName: 'mp3', bitrate: 102, profile: null },
|
||||
{ index: 1, codecName: 'mp3', bitrate: 101, profile: null },
|
||||
{ index: 0, codecName: 'mp3', bitrate: 100, profile: null },
|
||||
],
|
||||
}),
|
||||
noHeight: Object.freeze<VideoInfo>({
|
||||
@@ -108,7 +140,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -127,7 +165,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -159,7 +203,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Smpte2084,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -178,7 +228,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -197,7 +253,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -216,7 +278,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -235,7 +303,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -254,27 +328,33 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
audioStreamAac: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100, profile: AacProfile.Lc }],
|
||||
}),
|
||||
audioStreamMp3: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100, profile: null }],
|
||||
}),
|
||||
audioStreamOpus: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100, profile: null }],
|
||||
}),
|
||||
audioStreamUnknown: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
{ index: 0, codecName: 'aac', bitrate: 100 },
|
||||
{ index: 1, codecName: 'unknown', bitrate: 200 },
|
||||
{ index: 0, codecName: 'aac', bitrate: 100, profile: AacProfile.Lc },
|
||||
{ index: 1, codecName: 'unknown', bitrate: 200, profile: null },
|
||||
],
|
||||
}),
|
||||
matroskaContainer: Object.freeze<VideoInfo>({
|
||||
@@ -340,6 +420,9 @@ export const videoInfoStub = {
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
colorTransfer: ColorTransfer.Smpte2084,
|
||||
timeBase: 600,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -393,7 +476,7 @@ export const videoInfoStub = {
|
||||
};
|
||||
|
||||
interface SelectedStreams {
|
||||
videoStream: VideoStreamInfo & { timeBase: NotNull };
|
||||
videoStream: VideoStreamInfo & { timeBase: number };
|
||||
audioStream: AudioStreamInfo | null;
|
||||
format: VideoFormat;
|
||||
}
|
||||
@@ -407,3 +490,128 @@ const toSelectedStreams = (info: VideoInfo) => ({
|
||||
export const probeStub = Object.fromEntries(
|
||||
Object.entries(videoInfoStub).map(([key, info]) => [key, toSelectedStreams(info)]),
|
||||
) as Record<keyof typeof videoInfoStub, SelectedStreams>;
|
||||
|
||||
export const eiffelTower = {
|
||||
originalPath: 'eiffel-tower.mp4',
|
||||
videoStream: {
|
||||
index: 0,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
rotation: 0,
|
||||
codecName: 'h264',
|
||||
profile: H264Profile.High,
|
||||
level: 40,
|
||||
frameCount: 557,
|
||||
frameRate: 24.908_004_845_459_07,
|
||||
timeBase: 90_000,
|
||||
bitrate: 5_128_622,
|
||||
pixelFormat: 'yuv420p',
|
||||
colorPrimaries: ColorPrimaries.Smpte170M,
|
||||
colorTransfer: ColorTransfer.Smpte170M,
|
||||
colorMatrix: ColorMatrix.Smpte170M,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 125_629, index: 1, profile: AacProfile.Lc },
|
||||
packets: {
|
||||
totalDuration: 2_012_441,
|
||||
packetCount: 557,
|
||||
outputFrames: 557,
|
||||
keyframePts: [0, 462_502, 925_004, 1_210_454, 1_387_506, 1_542_878, 1_850_008],
|
||||
keyframeAccDuration: [3613, 466_077, 928_541, 1_213_968, 1_391_005, 1_546_364, 1_853_469],
|
||||
keyframeOwnDuration: [3613, 3613, 3613, 3613, 3613, 3613, 3613],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 22_616,
|
||||
bitrate: 5_128_622,
|
||||
},
|
||||
};
|
||||
|
||||
export const waterfall = {
|
||||
originalPath: 'waterfall.mp4',
|
||||
videoStream: {
|
||||
index: 2,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
rotation: -90,
|
||||
codecName: 'hevc',
|
||||
profile: HevcProfile.Main,
|
||||
level: 156,
|
||||
frameCount: 309,
|
||||
frameRate: 29.829_901_982_867_92,
|
||||
timeBase: 90_000,
|
||||
bitrate: 43_363_499,
|
||||
pixelFormat: 'yuvj420p',
|
||||
colorPrimaries: ColorPrimaries.Bt709,
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 191_878, index: 1, profile: null },
|
||||
packets: {
|
||||
totalDuration: 932_286,
|
||||
packetCount: 309,
|
||||
outputFrames: 309,
|
||||
keyframePts: [0, 89_987, 179_974, 269_961, 359_948, 449_936, 539_923, 629_910, 725_166, 815_273, 905_295],
|
||||
keyframeAccDuration: [
|
||||
2999, 92_987, 182_974, 272_961, 362_948, 452_934, 542_922, 632_909, 728_175, 818_274, 908_296,
|
||||
],
|
||||
keyframeOwnDuration: [2999, 3000, 3000, 3000, 3000, 2998, 2999, 2999, 3009, 3001, 3001],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 10_359,
|
||||
bitrate: 43_363_499,
|
||||
},
|
||||
};
|
||||
|
||||
export const train = {
|
||||
originalPath: 'train.mov',
|
||||
videoStream: {
|
||||
index: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rotation: -90,
|
||||
codecName: 'hevc',
|
||||
profile: HevcProfile.Main10,
|
||||
level: 123,
|
||||
frameCount: 1229,
|
||||
frameRate: 56.536_072_989_342_94,
|
||||
timeBase: 600,
|
||||
bitrate: 12_595_191,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
colorPrimaries: ColorPrimaries.Bt2020,
|
||||
colorTransfer: ColorTransfer.AribStdB67,
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
dvProfile: DvProfile.Dvhe08,
|
||||
dvLevel: 5,
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility.Hlg,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 175_477, index: 1, profile: AacProfile.Lc },
|
||||
packets: {
|
||||
totalDuration: 12_290,
|
||||
packetCount: 1229,
|
||||
outputFrames: 1303,
|
||||
keyframePts: [
|
||||
0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210, 10_811,
|
||||
11_411, 12_062, 12_703,
|
||||
],
|
||||
keyframeAccDuration: [
|
||||
10, 580, 1180, 1780, 2380, 2980, 3580, 4180, 4780, 5380, 5980, 6580, 7180, 7780, 8380, 8980, 9580, 10_180, 10_780,
|
||||
11_380, 11_780, 12_100,
|
||||
],
|
||||
keyframeOwnDuration: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 21_738,
|
||||
bitrate: 12_595_191,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotNull, Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
@@ -156,7 +156,7 @@ export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>
|
||||
files: asset.files.map((file) => getDehydrated(file)),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
|
||||
videoStream: null as (VideoStreamInfo & { timeBase: NotNull }) | null,
|
||||
videoStream: null as (VideoStreamInfo & { timeBase: number }) | null,
|
||||
audioStream: null as AudioStreamInfo | null,
|
||||
format: null as VideoFormat | null,
|
||||
});
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
AacProfile,
|
||||
AssetType,
|
||||
ColorMatrix,
|
||||
ColorPrimaries,
|
||||
ColorTransfer,
|
||||
DvProfile,
|
||||
DvSignalCompatibility,
|
||||
H264Profile,
|
||||
HevcProfile,
|
||||
} from 'src/enum';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { withAudioStream, withVideoFormat, withVideoPackets, withVideoStream } from 'src/utils/database';
|
||||
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
||||
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
@@ -21,122 +13,30 @@ beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
file: 'eiffel-tower.mp4',
|
||||
video: {
|
||||
codecName: 'h264',
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
pixelFormat: 'yuv420p',
|
||||
bitrate: 5_128_622,
|
||||
frameCount: 557,
|
||||
timeBase: 90_000,
|
||||
index: 0,
|
||||
profile: H264Profile.High,
|
||||
level: 40,
|
||||
colorPrimaries: ColorPrimaries.Smpte170M,
|
||||
colorTransfer: ColorTransfer.Smpte170M,
|
||||
colorMatrix: ColorMatrix.Smpte170M,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audio: { codecName: 'aac', bitrate: 125_629, index: 1, profile: AacProfile.Lc },
|
||||
keyframes: {
|
||||
totalDuration: 2_012_441,
|
||||
packetCount: 557,
|
||||
outputFrames: 557,
|
||||
pts: [0, 462_502, 925_004, 1_210_454, 1_387_506, 1_542_878, 1_850_008],
|
||||
accDuration: [3613, 466_077, 928_541, 1_213_968, 1_391_005, 1_546_364, 1_853_469],
|
||||
ownDuration: [3613, 3613, 3613, 3613, 3613, 3613, 3613],
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'waterfall.mp4',
|
||||
video: {
|
||||
codecName: 'hevc',
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
pixelFormat: 'yuvj420p',
|
||||
bitrate: 43_363_499,
|
||||
frameCount: 309,
|
||||
timeBase: 90_000,
|
||||
index: 2,
|
||||
profile: HevcProfile.Main,
|
||||
level: 156,
|
||||
colorPrimaries: ColorPrimaries.Bt709,
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audio: { codecName: 'aac', bitrate: 191_878, index: 1, profile: null },
|
||||
keyframes: {
|
||||
totalDuration: 932_286,
|
||||
packetCount: 309,
|
||||
outputFrames: 309,
|
||||
pts: [0, 89_987, 179_974, 269_961, 359_948, 449_936, 539_923, 629_910, 725_166, 815_273, 905_295],
|
||||
accDuration: [2999, 92_987, 182_974, 272_961, 362_948, 452_934, 542_922, 632_909, 728_175, 818_274, 908_296],
|
||||
ownDuration: [2999, 3000, 3000, 3000, 3000, 2998, 2999, 2999, 3009, 3001, 3001],
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'train.mov',
|
||||
video: {
|
||||
codecName: 'hevc',
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
pixelFormat: 'yuv420p10le',
|
||||
bitrate: 12_595_191,
|
||||
frameCount: 1229,
|
||||
timeBase: 600,
|
||||
index: 0,
|
||||
profile: HevcProfile.Main10,
|
||||
level: 123,
|
||||
colorPrimaries: ColorPrimaries.Bt2020,
|
||||
colorTransfer: ColorTransfer.AribStdB67,
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
dvProfile: DvProfile.Dvhe08,
|
||||
dvLevel: 5,
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility.Hlg,
|
||||
},
|
||||
audio: { codecName: 'aac', bitrate: 175_477, index: 1, profile: AacProfile.Lc },
|
||||
keyframes: {
|
||||
totalDuration: 12_290,
|
||||
packetCount: 1229,
|
||||
outputFrames: 1303,
|
||||
pts: [
|
||||
0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210,
|
||||
10_811, 11_411, 12_062, 12_703,
|
||||
],
|
||||
accDuration: [
|
||||
10, 580, 1180, 1780, 2380, 2980, 3580, 4180, 4780, 5380, 5980, 6580, 7180, 7780, 8380, 8980, 9580, 10_180,
|
||||
10_780, 11_380, 11_780, 12_100,
|
||||
],
|
||||
ownDuration: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const isExpected = <T extends keyof DB>(name: T, id: string, expected: Omit<DB[T], 'assetId'>) => {
|
||||
const { table, ref } = database.dynamic;
|
||||
const res = database.selectFrom(table(name).as('t')).selectAll().where(ref('assetId'), '=', id).executeTakeFirst();
|
||||
return expect(res).resolves.toEqual({ ...expected, assetId: id });
|
||||
};
|
||||
const fixtures = [eiffelTower, waterfall, train];
|
||||
|
||||
describe('video metadata extraction', () => {
|
||||
it.each(fixtures)('$file', async ({ file, video, audio, keyframes }) => {
|
||||
it.each(fixtures)('$originalPath', async ({ originalPath: path, videoStream, audioStream, packets, format }) => {
|
||||
const ctx = new ExifTestContext(database);
|
||||
const { user } = await ctx.newUser();
|
||||
const originalPath = resolve(testAssetsDir, 'videos', file);
|
||||
const originalPath = resolve(testAssetsDir, 'videos', path);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath, type: AssetType.Video });
|
||||
|
||||
await ctx.sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
await isExpected('asset_audio', asset.id, audio);
|
||||
await isExpected('asset_video', asset.id, video);
|
||||
await isExpected('asset_keyframe', asset.id, keyframes);
|
||||
const result = await database
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
|
||||
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
|
||||
.leftJoin('asset_audio', 'asset.id', 'asset_audio.assetId')
|
||||
.where('asset.id', '=', asset.id)
|
||||
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
|
||||
.select((eb) => withAudioStream(eb).as('audioStream'))
|
||||
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
|
||||
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(result).toEqual({ videoStream, audioStream, packets, format });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('AssetViewer', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('updates the top bar favorite action after pressing favorite', async () => {
|
||||
it.skip('updates the top bar favorite action after pressing favorite', async () => {
|
||||
const ownerId = 'owner-id';
|
||||
const user = userAdminFactory.build({ id: ownerId });
|
||||
const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import NextAssetAction from '$lib/components/asset-viewer/actions/NextAssetAction.svelte';
|
||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/PreviousAssetAction.svelte';
|
||||
@@ -246,6 +247,22 @@
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
|
||||
const navigateStack = (direction: 'previous' | 'next') => {
|
||||
if (!stack || !withStacked || assetViewerManager.isShowEditor) {
|
||||
return;
|
||||
}
|
||||
const assets = stack.assets;
|
||||
const currentIndex = assets.findIndex(({ id }) => id === asset.id);
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (nextIndex < 0 || nextIndex >= assets.length) {
|
||||
return;
|
||||
}
|
||||
cursor.current = assets[nextIndex];
|
||||
};
|
||||
|
||||
/**
|
||||
* Slide show mode
|
||||
*/
|
||||
@@ -459,6 +476,10 @@
|
||||
id="immich-asset-viewer"
|
||||
class="fixed inset-s-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
use:focusTrap
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
|
||||
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => navigateStack('next') },
|
||||
]}
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { autoPlayVideo, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
|
||||
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||
@@ -166,6 +166,7 @@
|
||||
<!-- dir=ltr based on https://github.com/videojs/video.js/issues/949 -->
|
||||
<media-controller
|
||||
dir="ltr"
|
||||
lang={$lang}
|
||||
nohotkeys
|
||||
class="dark h-full max-w-full"
|
||||
style:aspect-ratio={aspectRatio}
|
||||
@@ -194,14 +195,14 @@
|
||||
></video>
|
||||
|
||||
{#if extendedControls}
|
||||
<media-settings-menu hidden anchor="auto" class="w-3xs rounded-xl border border-light-300 shadow-sm">
|
||||
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
|
||||
<Icon slot="checked-indicator" icon={mdiCheck} class="m-2" />
|
||||
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
|
||||
{$t('playback_speed')}
|
||||
{$t('media_chrome.playback_rate')}
|
||||
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
|
||||
<media-playback-rate-menu slot="submenu" hidden rates="0.5 1 1.5 2">
|
||||
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
|
||||
<span slot="title">{$t('playback_speed')}</span>
|
||||
<span slot="title">{$t('media_chrome.playback_rate')}</span>
|
||||
</media-playback-rate-menu>
|
||||
</media-settings-menu-item>
|
||||
</media-settings-menu>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
trailing,
|
||||
}: Props = $props();
|
||||
|
||||
let appBarBorder = $state('bg-light border border-transparent');
|
||||
let appBarBorder = $state('border border-subtle');
|
||||
|
||||
const onScroll = () => {
|
||||
if (window.scrollY > 80) {
|
||||
@@ -40,7 +40,7 @@
|
||||
appBarBorder = 'border border-gray-600';
|
||||
}
|
||||
} else {
|
||||
appBarBorder = 'bg-light border border-transparent';
|
||||
appBarBorder = 'border border-subtle';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,9 +66,9 @@
|
||||
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
|
||||
'justify-between lg:grid-cols-[25%_50%_25%]',
|
||||
appBarBorder,
|
||||
'm-2 place-items-center rounded-lg p-2 transition-all max-md:p-0',
|
||||
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
|
||||
tailwindClasses,
|
||||
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-subtle dark:bg-immich-dark-gray',
|
||||
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
|
||||
]}
|
||||
>
|
||||
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
|
||||
|
||||
@@ -88,6 +88,10 @@
|
||||
case 'description': {
|
||||
return { description: term };
|
||||
}
|
||||
case 'fullPath': {
|
||||
const normalizedTerm = term.trim();
|
||||
return normalizedTerm ? { originalPath: normalizedTerm } : {};
|
||||
}
|
||||
case 'ocr': {
|
||||
return { ocr: term };
|
||||
}
|
||||
@@ -198,6 +202,7 @@
|
||||
case 'smart':
|
||||
case 'metadata':
|
||||
case 'description':
|
||||
case 'fullPath':
|
||||
case 'ocr': {
|
||||
currentSearchType = searchType;
|
||||
return searchType;
|
||||
@@ -220,6 +225,9 @@
|
||||
case 'description': {
|
||||
return $t('description');
|
||||
}
|
||||
case 'fullPath': {
|
||||
return $t('full_path_or_folder');
|
||||
}
|
||||
case 'ocr': {
|
||||
return $t('ocr');
|
||||
}
|
||||
@@ -237,6 +245,7 @@
|
||||
{ value: 'smart', label: () => $t('context') },
|
||||
{ value: 'metadata', label: () => $t('filename') },
|
||||
{ value: 'description', label: () => $t('description') },
|
||||
{ value: 'fullPath', label: () => $t('full_path_or_folder') },
|
||||
{ value: 'ocr', label: () => $t('ocr') },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
interface Props {
|
||||
query: string | undefined;
|
||||
queryType?: 'smart' | 'metadata' | 'description' | 'ocr';
|
||||
queryType?: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr';
|
||||
}
|
||||
|
||||
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
|
||||
@@ -33,6 +33,13 @@
|
||||
bind:group={queryType}
|
||||
value="description"
|
||||
/>
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="full-path-radio"
|
||||
label={$t('full_path_or_folder')}
|
||||
bind:group={queryType}
|
||||
value="fullPath"
|
||||
/>
|
||||
{#if featureFlagsManager.value.ocr}
|
||||
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
|
||||
{/if}
|
||||
@@ -51,6 +58,10 @@
|
||||
<Field label={$t('search_by_description')}>
|
||||
<Input type="text" placeholder={$t('search_by_description_example')} bind:value={query} />
|
||||
</Field>
|
||||
{:else if queryType === 'fullPath'}
|
||||
<Field label={$t('search_by_full_path')}>
|
||||
<Input type="text" placeholder={$t('search_by_full_path_example')} bind:value={query} />
|
||||
</Field>
|
||||
{:else if queryType === 'ocr'}
|
||||
<Field label={$t('search_by_ocr')}>
|
||||
<Input type="text" placeholder={$t('search_by_ocr_example')} bind:value={query} />
|
||||
|
||||
@@ -87,10 +87,17 @@ export enum QueryType {
|
||||
SMART = 'smart',
|
||||
METADATA = 'metadata',
|
||||
DESCRIPTION = 'description',
|
||||
FULL_PATH = 'fullPath',
|
||||
OCR = 'ocr',
|
||||
}
|
||||
|
||||
export const validQueryTypes = new Set([QueryType.SMART, QueryType.METADATA, QueryType.DESCRIPTION, QueryType.OCR]);
|
||||
export const validQueryTypes = new Set([
|
||||
QueryType.SMART,
|
||||
QueryType.METADATA,
|
||||
QueryType.DESCRIPTION,
|
||||
QueryType.FULL_PATH,
|
||||
QueryType.OCR,
|
||||
]);
|
||||
|
||||
export const locales = [
|
||||
{ code: 'af-ZA', name: 'Afrikaans (South Africa)' },
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
|
||||
type Props = {
|
||||
album: AlbumResponseDto;
|
||||
readOnly?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { album, onClose }: Props = $props();
|
||||
let { album, readOnly = false, onClose }: Props = $props();
|
||||
|
||||
const handleRoleSelect = async (user: UserResponseDto, role: AlbumUserRole | 'none') => {
|
||||
if (role === 'none') {
|
||||
@@ -72,14 +73,14 @@
|
||||
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
|
||||
/>
|
||||
|
||||
<Modal title={$t('options')} {onClose} size="small">
|
||||
<Modal title={readOnly ? $t('album') : $t('options')} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<Stack gap={6}>
|
||||
<div>
|
||||
<Text size="medium" fontWeight="semi-bold">{$t('settings')}</Text>
|
||||
<div class="mt-2 grid gap-y-3 ps-2">
|
||||
{#if album.order}
|
||||
<Field label={$t('display_order')}>
|
||||
<Field label={$t('display_order')} disabled={readOnly}>
|
||||
<Select
|
||||
value={album.order}
|
||||
options={[
|
||||
@@ -90,7 +91,7 @@
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')}>
|
||||
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')} disabled={readOnly}>
|
||||
<Switch
|
||||
checked={album.isActivityEnabled}
|
||||
onCheckedChange={(checked) => handleUpdateAlbum(album, { isActivityEnabled: checked })}
|
||||
@@ -102,7 +103,9 @@
|
||||
<div>
|
||||
<HStack fullWidth class="mb-2 justify-between">
|
||||
<Text size="medium" fontWeight="semi-bold">{$t('people')}</Text>
|
||||
<HeaderActionButton action={AddUsers} />
|
||||
{#if !readOnly}
|
||||
<HeaderActionButton action={AddUsers} />
|
||||
{/if}
|
||||
</HStack>
|
||||
<div class="ps-2">
|
||||
{#each album.albumUsers as { user, role } (user.id)}
|
||||
@@ -113,7 +116,7 @@
|
||||
</div>
|
||||
<Text size="small">{user.name}</Text>
|
||||
</div>
|
||||
<Field class="w-32" disabled={role === AlbumUserRole.Owner}>
|
||||
<Field class="w-32" disabled={readOnly || role === AlbumUserRole.Owner}>
|
||||
<Select
|
||||
value={role}
|
||||
options={[
|
||||
@@ -129,20 +132,22 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<HStack class="mb-2 justify-between">
|
||||
<Text size="medium" fontWeight="semi-bold">{$t('shared_links')}</Text>
|
||||
<HeaderActionButton action={CreateSharedLink} />
|
||||
</HStack>
|
||||
{#if !readOnly}
|
||||
<div class="mb-4">
|
||||
<HStack class="mb-2 justify-between">
|
||||
<Text size="medium" fontWeight="semi-bold">{$t('shared_links')}</Text>
|
||||
<HeaderActionButton action={CreateSharedLink} />
|
||||
</HStack>
|
||||
|
||||
<div class="ps-2">
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} />
|
||||
{/each}
|
||||
</Stack>
|
||||
<div class="ps-2">
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} />
|
||||
{/each}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
query = searchQuery.originalFileName;
|
||||
}
|
||||
|
||||
if ('originalPath' in searchQuery && searchQuery.originalPath) {
|
||||
query = searchQuery.originalPath;
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
ocr: searchQuery.ocr,
|
||||
@@ -133,6 +137,7 @@
|
||||
ocr: filter.queryType === 'ocr' ? query : undefined,
|
||||
originalFileName: filter.queryType === 'metadata' ? query : undefined,
|
||||
description: filter.queryType === 'description' ? query : undefined,
|
||||
originalPath: filter.queryType === 'fullPath' ? filter.query.trim() || undefined : undefined,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
|
||||
@@ -80,7 +80,7 @@ export type SearchLocationFilter = {
|
||||
export type SearchFilter = {
|
||||
query: string;
|
||||
ocr?: string;
|
||||
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
|
||||
queryType: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr';
|
||||
personIds: SvelteSet<string>;
|
||||
tagIds: SvelteSet<string> | null;
|
||||
location: SearchLocationFilter;
|
||||
|
||||
+33
-30
@@ -37,7 +37,6 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import {
|
||||
getAlbumActions,
|
||||
@@ -66,6 +65,7 @@
|
||||
mdiArrowLeft,
|
||||
mdiCogOutline,
|
||||
mdiDeleteOutline,
|
||||
mdiDotsHorizontal,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiImageOutline,
|
||||
@@ -373,38 +373,41 @@
|
||||
<!-- ALBUM SHARING -->
|
||||
{#if album.albumUsers.length > 1 || (album.hasSharedLink && isOwned)}
|
||||
<div class="my-3 flex gap-x-1">
|
||||
<!-- link -->
|
||||
{#if album.hasSharedLink && isOwned}
|
||||
<IconButton
|
||||
aria-label={$t('create_link_to_share')}
|
||||
color="secondary"
|
||||
size="medium"
|
||||
shape="round"
|
||||
icon={mdiLink}
|
||||
onclick={() => modalManager.show(SharedLinkCreateModal, { albumId: album.id })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- users with write access (collaborators) -->
|
||||
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor || role === AlbumUserRole.Owner) as { user } (user.id)}
|
||||
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
|
||||
<button
|
||||
class="flex gap-x-1"
|
||||
type="button"
|
||||
onclick={() => modalManager.show(AlbumOptionsModal, { album, readOnly: !isOwned })}
|
||||
>
|
||||
<!-- owner & users with write access (collaborators) -->
|
||||
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor || role === AlbumUserRole.Owner) as { user } (user.id)}
|
||||
<UserAvatar {user} size="md" />
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
<!-- display ellipsis if there are readonly users too -->
|
||||
{#if albumHasViewers}
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('view_all_users')}
|
||||
color="secondary"
|
||||
size="medium"
|
||||
icon={mdiDotsVertical}
|
||||
onclick={() => modalManager.show(AlbumOptionsModal, { album })}
|
||||
/>
|
||||
<!-- display ellipsis if there are readonly users too -->
|
||||
{#if albumHasViewers}
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('view_all_users')}
|
||||
color="secondary"
|
||||
size="medium"
|
||||
icon={mdiDotsHorizontal}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if album.hasSharedLink && isOwned}
|
||||
<IconButton
|
||||
aria-label={$t('shared_link_manage_links')}
|
||||
color="secondary"
|
||||
size="medium"
|
||||
shape="round"
|
||||
icon={mdiLink}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isOwned}
|
||||
<ActionButton action={Share} />
|
||||
{/if}
|
||||
|
||||
<ActionButton action={Share} />
|
||||
</div>
|
||||
{/if}
|
||||
<AlbumDescription
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { page } from '$app/stores';
|
||||
import { scrollMemory } from '$lib/actions/scroll-memory';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ManagePeopleVisibility from './ManagePeopleVisibility.svelte';
|
||||
import PeopleCard from './PeopleCard.svelte';
|
||||
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
|
||||
import SearchPeople from '$lib/components/faces-page/PeopleSearch.svelte';
|
||||
@@ -22,8 +21,6 @@
|
||||
import { mdiAccountOff, mdiEyeOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
@@ -32,7 +29,6 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let selectHidden = $state(false);
|
||||
let searchName = $state('');
|
||||
let newName = $state('');
|
||||
let currentPage = $state(1);
|
||||
@@ -331,7 +327,7 @@
|
||||
</div>
|
||||
<Button
|
||||
leadingIcon={mdiEyeOutline}
|
||||
onclick={() => (selectHidden = !selectHidden)}
|
||||
onclick={() => goto('/people/manage')}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary">{$t('show_and_hide_people')}</Button
|
||||
@@ -377,21 +373,3 @@
|
||||
</div>
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
|
||||
{#if selectHidden}
|
||||
<dialog
|
||||
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
|
||||
class="fixed inset-0 size-full max-h-none max-w-none bg-light"
|
||||
aria-labelledby="manage-visibility-title"
|
||||
{@attach (dialog) => dialog.showModal()}
|
||||
>
|
||||
<ManagePeopleVisibility
|
||||
{people}
|
||||
totalPeopleCount={data.people.total}
|
||||
titleId="manage-visibility-title"
|
||||
onClose={() => (selectHidden = false)}
|
||||
onUpdate={(updatedPeople) => (people = updatedPeople.slice())}
|
||||
{loadNextPage}
|
||||
/>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import ManagePeopleVisibility from './ManagePeopleVisibility.svelte';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
}
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<ManagePeopleVisibility {...props} />
|
||||
</TooltipProvider>
|
||||
+3
-6
@@ -62,6 +62,8 @@
|
||||
let { data }: Props = $props();
|
||||
|
||||
let numberOfAssets = $derived(data.statistics.assets);
|
||||
let person = $derived(data.person);
|
||||
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
const options = $derived({ visibility: AssetVisibility.Timeline, personId: data.person.id });
|
||||
@@ -74,7 +76,7 @@
|
||||
let potentialMergePeople: PersonResponseDto[] = $state([]);
|
||||
let isSuggestionSelectedByUser = $state(false);
|
||||
|
||||
let personName = '';
|
||||
let personName = $derived(person.name);
|
||||
let suggestedPeople: PersonResponseDto[] = $state([]);
|
||||
|
||||
/**
|
||||
@@ -187,7 +189,6 @@
|
||||
isEditingName = false;
|
||||
if (person.id !== person2.id) {
|
||||
potentialMergePeople = [];
|
||||
personName = person.name;
|
||||
personMerge1 = person;
|
||||
personMerge2 = person2;
|
||||
isSuggestionSelectedByUser = true;
|
||||
@@ -276,10 +277,6 @@
|
||||
await updateAssetCount();
|
||||
};
|
||||
|
||||
let person = $derived(data.person);
|
||||
|
||||
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
assetMultiSelectManager.clear();
|
||||
|
||||
+37
-35
@@ -1,28 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
|
||||
import PeopleInfiniteScroll from '../PeopleInfiniteScroll.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import { ToggleVisibility } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updatePeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { getAllPeople, updatePeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props();
|
||||
const { data }: Props = $props();
|
||||
|
||||
let people = $derived(data.people.people);
|
||||
const totalPeopleCount = $derived(data.people.total);
|
||||
let nextPage = $state(data.people.hasNextPage ? 2 : null);
|
||||
let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
|
||||
let showLoadingSpinner = $state(false);
|
||||
const overrides = new SvelteMap<string, boolean>();
|
||||
@@ -78,8 +78,7 @@
|
||||
}
|
||||
overrides.clear();
|
||||
|
||||
onUpdate(people);
|
||||
onClose();
|
||||
await goto('/people');
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } }));
|
||||
} finally {
|
||||
@@ -95,6 +94,19 @@
|
||||
overrides.set(person.id, isHidden);
|
||||
};
|
||||
|
||||
const loadNextPage = async () => {
|
||||
if (!nextPage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
|
||||
people = people.concat(newPeople);
|
||||
nextPage = hasNextPage ? nextPage + 1 : null;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_load_people'));
|
||||
}
|
||||
};
|
||||
|
||||
let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
|
||||
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
|
||||
[ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') },
|
||||
@@ -103,28 +115,18 @@
|
||||
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div
|
||||
class="sticky top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 md:p-8 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('close')}
|
||||
icon={mdiClose}
|
||||
onclick={onClose}
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<p id={titleId} class="ms-2">{$t('show_and_hide_people')}</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
|
||||
</div>
|
||||
</div>
|
||||
<UserPageLayout title={$t('show_and_hide_people')} description={`(${totalPeopleCount.toLocaleString($locale)})`}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center md:me-4">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('close')}
|
||||
icon={mdiClose}
|
||||
onclick={() => goto('/people')}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
@@ -144,10 +146,10 @@
|
||||
</div>
|
||||
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8">
|
||||
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
|
||||
<PeopleInfiniteScroll {people} hasNextPage={nextPage !== null} {loadNextPage}>
|
||||
{#snippet children({ person })}
|
||||
{@const hidden = overrides.get(person.id) ?? person.isHidden}
|
||||
<button
|
||||
@@ -175,4 +177,4 @@
|
||||
{/snippet}
|
||||
</PeopleInfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getAllPeople } from '@immich/sdk';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
|
||||
const people = await getAllPeople({ withHidden: true });
|
||||
|
||||
return {
|
||||
people,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user