Compare commits

..

5 Commits

Author SHA1 Message Date
mertalev
f039672a2a android improvements 2025-08-13 22:16:43 -04:00
mertalev
3100702e93 better scrolling 2025-08-13 18:04:50 -04:00
mertalev
c988342de1 platform thumbhash 2025-08-13 18:04:50 -04:00
mertalev
84462560e3 cancel in image stream completer 2025-08-13 00:15:27 -04:00
mertalev
f931060670 image provider improvements 2025-08-13 00:10:55 -04:00
531 changed files with 98972 additions and 42083 deletions

View File

@@ -49,11 +49,10 @@ fix_permissions() {
log "Fixing permissions for ${IMMICH_WORKSPACE}" log "Fixing permissions for ${IMMICH_WORKSPACE}"
run_cmd sudo find "${IMMICH_WORKSPACE}/server/upload" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres/*" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres" -exec chown node {} +
# Change ownership for directories that exist # Change ownership for directories that exist
for dir in "${IMMICH_WORKSPACE}/.vscode" \ for dir in "${IMMICH_WORKSPACE}/.vscode" \
"${IMMICH_WORKSPACE}/server/upload" \
"${IMMICH_WORKSPACE}/.pnpm-store" \
"${IMMICH_WORKSPACE}/.github/node_modules" \
"${IMMICH_WORKSPACE}/cli/node_modules" \ "${IMMICH_WORKSPACE}/cli/node_modules" \
"${IMMICH_WORKSPACE}/e2e/node_modules" \ "${IMMICH_WORKSPACE}/e2e/node_modules" \
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \ "${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \

View File

@@ -8,13 +8,21 @@ services:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/ - IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override volumes: !override
- ..:/workspaces/immich - ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules
- e2e_node_modules:/workspaces/immich/e2e/node_modules
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload - ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
immich-web: immich-web:
env_file: !reset [] env_file: !reset []
immich-machine-learning: immich-machine-learning:
env_file: !reset [] env_file: !reset []
database: database:
env_file: !reset [] env_file: !reset []
environment: !override environment: !override
@@ -25,10 +33,17 @@ services:
POSTGRES_HOST_AUTH_METHOD: md5 POSTGRES_HOST_AUTH_METHOD: md5
volumes: volumes:
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data - ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
redis: redis:
env_file: !reset [] env_file: !reset []
volumes: volumes:
# Node modules for each service to avoid conflicts and ensure consistent dependencies # Node modules for each service to avoid conflicts and ensure consistent dependencies
cli_node_modules:
e2e_node_modules:
open_api_node_modules:
server_node_modules:
web_node_modules:
upload1-devcontainer-volume: upload1-devcontainer-volume:
upload2-devcontainer-volume: upload2-devcontainer-volume:
postgres-devcontainer-volume: postgres-devcontainer-volume:

View File

@@ -3,20 +3,15 @@
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh source /immich-devcontainer/container-common.sh
log "Preparing Immich Nest API Server"
log ""
export CI=1
run_cmd pnpm --filter immich install
log "Starting Nest API Server" log "Starting Nest API Server"
log "" log ""
cd "${IMMICH_WORKSPACE}/server" || ( cd "${IMMICH_WORKSPACE}/server" || (
log "Immich workspace not found"jj log "Immich workspace not found"
exit 1 exit 1
) )
while true; do while true; do
run_cmd pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
log "Nest API Server crashed with exit code $?. Respawning in 3s ..." log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
sleep 3 sleep 3
done done

View File

@@ -3,13 +3,6 @@
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh source /immich-devcontainer/container-common.sh
export CI=1
log "Preparing Immich Web Frontend"
log ""
run_cmd pnpm --filter @immich/sdk install
run_cmd pnpm --filter @immich/sdk build
run_cmd pnpm --filter immich-web install
log "Starting Immich Web Frontend" log "Starting Immich Web Frontend"
log "" log ""
cd "${IMMICH_WORKSPACE}/web" || ( cd "${IMMICH_WORKSPACE}/web" || (
@@ -23,7 +16,7 @@ until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_
done done
while true; do while true; do
run_cmd pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port "${DEV_PORT}" run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
log "Web crashed with exit code $?. Respawning in 3s ..." log "Web crashed with exit code $?. Respawning in 3s ..."
sleep 3 sleep 3
done done

View File

@@ -6,6 +6,9 @@ source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..." log "Setting up Immich dev container..."
fix_permissions fix_permissions
log "Installing npm dependencies (node_modules)..."
install_dependencies
log "Setup complete, please wait while backend and frontend services automatically start" log "Setup complete, please wait while backend and frontend services automatically start"
log log
log "If necessary, the services may be manually started using" log "If necessary, the services may be manually started using"

View File

@@ -64,11 +64,6 @@ body:
- label: Web - label: Web
- label: Mobile - label: Mobile
- type: input
attributes:
label: Device make and model
placeholder: Samsung S25 Android 16
- type: textarea - type: textarea
validations: validations:
required: true required: true

28
.github/package-lock.json generated vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"name": ".github",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"prettier": "^3.5.3"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
}
}
}

View File

@@ -35,7 +35,7 @@ jobs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
runs-on: mich runs-on: mich
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ inputs.ref || github.sha }} ref: ${{ inputs.ref || github.sha }}
persist-credentials: false persist-credentials: false
@@ -79,7 +79,7 @@ jobs:
- name: Restore Gradle Cache - name: Restore Gradle Cache
id: cache-gradle-restore id: cache-gradle-restore
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4 uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -106,7 +106,7 @@ jobs:
run: flutter pub get run: flutter pub get
- name: Generate translation file - name: Generate translation file
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart run: make translation
working-directory: ./mobile working-directory: ./mobile
- name: Generate platform APIs - name: Generate platform APIs
@@ -136,7 +136,7 @@ jobs:
- name: Save Gradle Cache - name: Save Gradle Cache
id: cache-gradle-save id: cache-gradle-save
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4 uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
with: with:
path: | path: |

View File

@@ -19,7 +19,7 @@ jobs:
actions: write actions: write
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false

View File

@@ -29,28 +29,25 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm # Setup .npmrc file to publish to npm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk - name: Prepare SDK
run: pnpm install && pnpm run build run: npm ci --prefix ../open-api/typescript-sdk/
working-directory: ./open-api/typescript-sdk - name: Build SDK
run: npm run build --prefix ../open-api/typescript-sdk/
- run: pnpm install --frozen-lockfile - run: npm ci
- run: pnpm build - run: npm run build
- run: pnpm publish - run: npm publish
if: ${{ github.event_name == 'release' }} if: ${{ github.event_name == 'release' }}
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -65,7 +62,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
@@ -76,7 +73,7 @@ jobs:
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
@@ -91,7 +88,7 @@ jobs:
- name: Generate docker image tags - name: Generate docker image tags
id: metadata id: metadata
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with: with:
flavor: | flavor: |
latest=false latest=false

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: get_body needs: get_body
container: container:
image: yshavit/mdq:0.8.0@sha256:c69224d34224a0043d9a3ee46679ba4a2a25afaac445f293d92afe13cd47fcea image: yshavit/mdq:0.7.2
outputs: outputs:
json: ${{ steps.get_checkbox.outputs.json }} json: ${{ steps.get_checkbox.outputs.json }}
steps: steps:
@@ -51,7 +51,7 @@ jobs:
run: | run: |
gh api graphql \ gh api graphql \
-f issueId="$NODE_ID" \ -f issueId="$NODE_ID" \
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \ -f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
-f query=' -f query='
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) { mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
addComment(input: { addComment(input: {
@@ -77,7 +77,7 @@ jobs:
run: | run: |
gh api graphql \ gh api graphql \
-f discussionId="$NODE_ID" \ -f discussionId="$NODE_ID" \
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \ -f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
-f query=' -f query='
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) { mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
addDiscussionComment(input: { addDiscussionComment(input: {

View File

@@ -44,13 +44,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -63,7 +63,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@@ -24,7 +24,7 @@ jobs:
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -89,7 +89,7 @@ jobs:
suffix: [''] suffix: ['']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}

View File

@@ -21,7 +21,7 @@ jobs:
should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.found_paths.outputs.open-api == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.found_paths.outputs.open-api == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
@@ -51,28 +51,25 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './docs/.nvmrc' node-version-file: './docs/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run install - name: Run npm install
run: pnpm install run: npm ci
- name: Check formatting - name: Check formatting
run: pnpm format run: npm run format
- name: Run build - name: Run build
run: pnpm build run: npm run build
- name: Upload build output - name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

View File

@@ -108,7 +108,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false

View File

@@ -16,13 +16,13 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout' - name: 'Checkout'
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
@@ -32,8 +32,8 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Fix formatting - name: Fix formatting
run: make install-all && make format-all run: make install-all && make format-all

View File

@@ -32,13 +32,13 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true persist-credentials: true
@@ -83,13 +83,13 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false persist-credentials: false

View File

@@ -16,25 +16,22 @@ jobs:
run: run:
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './open-api/typescript-sdk/.nvmrc' node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Install deps - name: Install deps
run: pnpm install --frozen-lockfile run: npm ci
- name: Build - name: Build
run: pnpm build run: npm run build
- name: Publish - name: Publish
run: pnpm publish run: npm publish
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -20,7 +20,7 @@ jobs:
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
@@ -47,7 +47,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
@@ -68,7 +68,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
- name: Generate translation file - name: Generate translation file
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart run: make translation
- name: Run Build Runner - name: Run Build Runner
run: make build run: make build
@@ -116,7 +116,7 @@ jobs:
actions: read actions: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
@@ -129,7 +129,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file - name: Upload SARIF file
uses: github/codeql-action/upload-sarif@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
with: with:
sarif_file: results.sarif sarif_file: results.sarif
category: zizmor category: zizmor

View File

@@ -4,10 +4,13 @@ on:
pull_request: pull_request:
push: push:
branches: [main] branches: [main]
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {} permissions: {}
jobs: jobs:
pre-job: pre-job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -26,9 +29,10 @@ jobs:
should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with: with:
@@ -54,9 +58,11 @@ jobs:
- '.github/workflows/test.yml' - '.github/workflows/test.yml'
.github: .github:
- '.github/**' - '.github/**'
- name: Check if we should force jobs to run - name: Check if we should force jobs to run
id: should_force id: should_force
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
server-unit-tests: server-unit-tests:
name: Test & Lint Server name: Test & Lint Server
needs: pre-job needs: pre-job
@@ -67,33 +73,39 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./server working-directory: ./server
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run package manager install
run: pnpm install - name: Run npm install
run: npm ci
- name: Run linter - name: Run linter
run: pnpm lint run: npm run lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: pnpm format run: npm run format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc - name: Run tsc
run: pnpm check run: npm run check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run small tests & coverage - name: Run small tests & coverage
run: pnpm test run: npm test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
cli-unit-tests: cli-unit-tests:
name: Unit Test CLI name: Unit Test CLI
needs: pre-job needs: pre-job
@@ -104,36 +116,43 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./cli working-directory: ./cli
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk - name: Setup typescript-sdk
run: pnpm install && pnpm run build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Install deps - name: Install deps
run: pnpm install run: npm ci
- name: Run linter - name: Run linter
run: pnpm lint run: npm run lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: pnpm format run: npm run format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc - name: Run tsc
run: pnpm check run: npm run check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: pnpm test run: npm run test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
cli-unit-tests-win: cli-unit-tests-win:
name: Unit Test CLI (Windows) name: Unit Test CLI (Windows)
needs: pre-job needs: pre-job
@@ -144,31 +163,36 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./cli working-directory: ./cli
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk - name: Setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Install deps - name: Install deps
run: pnpm install --frozen-lockfile run: npm ci
# Skip linter & formatter in Windows test. # Skip linter & formatter in Windows test.
- name: Run tsc - name: Run tsc
run: pnpm check run: npm run check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: pnpm test run: npm run test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
web-lint: web-lint:
name: Lint Web name: Lint Web
needs: pre-job needs: pre-job
@@ -179,33 +203,39 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./web working-directory: ./web
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Run pnpm install
run: pnpm rebuild && pnpm install --frozen-lockfile - name: Run npm install
run: npm ci
- name: Run linter - name: Run linter
run: pnpm lint:p run: npm run lint:p
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: pnpm format run: npm run format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run svelte checks - name: Run svelte checks
run: pnpm check:svelte run: npm run check:svelte
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
web-unit-tests: web-unit-tests:
name: Test Web name: Test Web
needs: pre-job needs: pre-job
@@ -216,30 +246,35 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./web working-directory: ./web
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Run npm install - name: Run npm install
run: pnpm install --frozen-lockfile run: npm ci
- name: Run tsc - name: Run tsc
run: pnpm check:typescript run: npm run check:typescript
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: pnpm test run: npm run test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
i18n-tests: i18n-tests:
name: Test i18n name: Test i18n
needs: pre-job needs: pre-job
@@ -249,27 +284,30 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Install dependencies - name: Install dependencies
run: pnpm --filter=immich-web install --frozen-lockfile run: npm --prefix=web ci
- name: Format - name: Format
run: pnpm --filter=immich-web format:i18n run: npm --prefix=web run format:i18n
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files id: verify-changed-files
with: with:
files: | files: |
i18n/** i18n/**
- name: Verify files have not changed - name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env: env:
@@ -278,6 +316,7 @@ jobs:
echo "ERROR: i18n files not up to date!" echo "ERROR: i18n files not up to date!"
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
exit 1 exit 1
e2e-tests-lint: e2e-tests-lint:
name: End-to-End Lint name: End-to-End Lint
needs: pre-job needs: pre-job
@@ -288,35 +327,41 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: npm ci
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run linter - name: Run linter
run: pnpm lint run: npm run lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: pnpm format run: npm run format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc - name: Run tsc
run: pnpm check run: npm run check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
server-medium-tests: server-medium-tests:
name: Medium Tests (Server) name: Medium Tests (Server)
needs: pre-job needs: pre-job
@@ -327,24 +372,27 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./server working-directory: ./server
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile - name: Run npm install
run: npm ci
- name: Run medium tests - name: Run medium tests
run: pnpm test:medium run: npm run test:medium
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
e2e-tests-server-cli: e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI) name: End-to-End Tests (Server & CLI)
needs: pre-job needs: pre-job
@@ -358,41 +406,43 @@ jobs:
strategy: strategy:
matrix: matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm] runner: [ubuntu-latest, ubuntu-24.04-arm]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
working-directory: ./web
if: ${{ !cancelled() }}
- name: Run setup cli - name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build run: npm ci && npm run build
working-directory: ./cli working-directory: ./cli
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: npm ci
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Docker build - name: Docker build
run: docker compose build run: docker compose build
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli) - name: Run e2e tests (api & cli)
run: pnpm test run: npm run test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
e2e-tests-web: e2e-tests-web:
name: End-to-End Tests (Web) name: End-to-End Tests (Web)
needs: pre-job needs: pre-job
@@ -406,36 +456,42 @@ jobs:
strategy: strategy:
matrix: matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm] runner: [ubuntu-latest, ubuntu-24.04-arm]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: npm ci
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: npx playwright install chromium --only-shell run: npx playwright install chromium --only-shell
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Docker build - name: Docker build
run: docker compose build run: docker compose build
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run e2e tests (web) - name: Run e2e tests (web)
run: npx playwright test run: npx playwright test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
success-check-e2e: success-check-e2e:
name: End-to-End Tests Success name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web] needs: [e2e-tests-server-cli, e2e-tests-web]
@@ -446,6 +502,7 @@ jobs:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4 - uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with: with:
needs: ${{ toJSON(needs) }} needs: ${{ toJSON(needs) }}
mobile-unit-tests: mobile-unit-tests:
name: Unit Test Mobile name: Unit Test Mobile
needs: pre-job needs: pre-job
@@ -454,20 +511,24 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0 uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
- name: Generate translation file - name: Generate translation file
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart run: make translation
working-directory: ./mobile working-directory: ./mobile
- name: Run tests - name: Run tests
working-directory: ./mobile working-directory: ./mobile
run: flutter test -j 1 run: flutter test -j 1
ml-unit-tests: ml-unit-tests:
name: Unit Test ML name: Unit Test ML
needs: pre-job needs: pre-job
@@ -479,9 +540,10 @@ jobs:
run: run:
working-directory: ./machine-learning working-directory: ./machine-learning
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
@@ -504,6 +566,7 @@ jobs:
- name: Run tests and coverage - name: Run tests and coverage
run: | run: |
uv run pytest --cov=immich_ml --cov-report term-missing uv run pytest --cov=immich_ml --cov-report term-missing
github-files-formatting: github-files-formatting:
name: .github Files Formatting name: .github Files Formatting
needs: pre-job needs: pre-job
@@ -514,38 +577,45 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./.github working-directory: ./.github
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './.github/.nvmrc' node-version-file: './.github/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Run pnpm install
run: pnpm install --frozen-lockfile - name: Run npm install
run: npm ci
- name: Run formatter - name: Run formatter
run: pnpm format run: npm run format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
shellcheck: shellcheck:
name: ShellCheck name: ShellCheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Run ShellCheck - name: Run ShellCheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
with: with:
ignore_paths: >- ignore_paths: >-
**/open-api/** **/openapi** **/node_modules/** **/open-api/**
**/openapi**
**/node_modules/**
generated-api-up-to-date: generated-api-up-to-date:
name: OpenAPI Clients name: OpenAPI Clients
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -553,23 +623,26 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies - name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile run: npm --prefix=server ci
- name: Build the app - name: Build the app
run: pnpm --filter immich build run: npm --prefix=server run build
- name: Run API generation - name: Run API generation
run: make open-api run: make open-api
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files id: verify-changed-files
@@ -578,6 +651,7 @@ jobs:
mobile/openapi mobile/openapi
open-api/typescript-sdk open-api/typescript-sdk
open-api/immich-openapi-specs.json open-api/immich-openapi-specs.json
- name: Verify files have not changed - name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env: env:
@@ -586,6 +660,7 @@ jobs:
echo "ERROR: Generated files not up to date!" echo "ERROR: Generated files not up to date!"
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
exit 1 exit 1
sql-schema-up-to-date: sql-schema-up-to-date:
name: SQL Schema Checks name: SQL Schema Checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -599,36 +674,45 @@ jobs:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: immich POSTGRES_DB: immich
options: >- options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 --health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
defaults: defaults:
run: run:
working-directory: ./server working-directory: ./server
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'npm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies - name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile run: npm ci
- name: Build the app - name: Build the app
run: pnpm build run: npm run build
- name: Run existing migrations - name: Run existing migrations
run: pnpm migrations:run run: npm run migrations:run
- name: Test npm run schema:reset command works - name: Test npm run schema:reset command works
run: pnpm schema:reset run: npm run schema:reset
- name: Generate new migrations - name: Generate new migrations
continue-on-error: true continue-on-error: true
run: pnpm migrations:generate src/TestMigration run: npm run migrations:generate src/TestMigration
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files id: verify-changed-files
@@ -644,16 +728,19 @@ jobs:
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
cat ./src/*-TestMigration.ts cat ./src/*-TestMigration.ts
exit 1 exit 1
- name: Run SQL generation - name: Run SQL generation
run: pnpm sync:sql run: npm run sync:sql
env: env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-sql-files id: verify-changed-sql-files
with: with:
files: | files: |
server/src/queries server/src/queries
- name: Verify SQL files have not changed - name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true' if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
env: env:
@@ -664,77 +751,77 @@ jobs:
git diff git diff
exit 1 exit 1
# mobile-integration-tests: # mobile-integration-tests:
# name: Run mobile end-to-end integration tests # name: Run mobile end-to-end integration tests
# runs-on: macos-latest # runs-on: macos-latest
# steps: # steps:
# - uses: actions/checkout@v4 # - uses: actions/checkout@v4
# - uses: actions/setup-java@v3 # - uses: actions/setup-java@v3
# with: # with:
# distribution: 'zulu' # distribution: 'zulu'
# java-version: '12.x' # java-version: '12.x'
# cache: 'gradle' # cache: 'gradle'
# - name: Cache android SDK # - name: Cache android SDK
# uses: actions/cache@v3 # uses: actions/cache@v3
# id: android-sdk # id: android-sdk
# with: # with:
# key: android-sdk # key: android-sdk
# path: | # path: |
# /usr/local/lib/android/ # /usr/local/lib/android/
# ~/.android # ~/.android
# - name: Cache Gradle # - name: Cache Gradle
# uses: actions/cache@v3 # uses: actions/cache@v3
# with: # with:
# path: | # path: |
# ./mobile/build/ # ./mobile/build/
# ./mobile/android/.gradle/ # ./mobile/android/.gradle/
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }} # key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
# - name: Setup Android SDK # - name: Setup Android SDK
# if: steps.android-sdk.outputs.cache-hit != 'true' # if: steps.android-sdk.outputs.cache-hit != 'true'
# uses: android-actions/setup-android@v2 # uses: android-actions/setup-android@v2
# - name: AVD cache # - name: AVD cache
# uses: actions/cache@v3 # uses: actions/cache@v3
# id: avd-cache # id: avd-cache
# with: # with:
# path: | # path: |
# ~/.android/avd/* # ~/.android/avd/*
# ~/.android/adb* # ~/.android/adb*
# key: avd-29 # key: avd-29
# - name: create AVD and generate snapshot for caching # - name: create AVD and generate snapshot for caching
# if: steps.avd-cache.outputs.cache-hit != 'true' # if: steps.avd-cache.outputs.cache-hit != 'true'
# uses: reactivecircus/android-emulator-runner@v2.27.0 # uses: reactivecircus/android-emulator-runner@v2.27.0
# with: # with:
# working-directory: ./mobile # working-directory: ./mobile
# cores: 2 # cores: 2
# api-level: 29 # api-level: 29
# arch: x86_64 # arch: x86_64
# profile: pixel # profile: pixel
# target: default # target: default
# force-avd-creation: false # force-avd-creation: false
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none # emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# disable-animations: false # disable-animations: false
# script: echo "Generated AVD snapshot for caching." # script: echo "Generated AVD snapshot for caching."
# - name: Setup Flutter SDK # - name: Setup Flutter SDK
# uses: subosito/flutter-action@v2 # uses: subosito/flutter-action@v2
# with: # with:
# channel: 'stable' # channel: 'stable'
# flutter-version: '3.7.3' # flutter-version: '3.7.3'
# cache: true # cache: true
# - name: Run integration tests # - name: Run integration tests
# uses: Wandalen/wretry.action@master # uses: Wandalen/wretry.action@master
# with: # with:
# action: reactivecircus/android-emulator-runner@v2.27.0 # action: reactivecircus/android-emulator-runner@v2.27.0
# with: | # with: |
# working-directory: ./mobile # working-directory: ./mobile
# cores: 2 # cores: 2
# api-level: 29 # api-level: 29
# arch: x86_64 # arch: x86_64
# profile: pixel # profile: pixel
# target: default # target: default
# force-avd-creation: false # force-avd-creation: false
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none # emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# disable-animations: true # disable-animations: true
# script: | # script: |
# flutter pub get # flutter pub get
# flutter test integration_test # flutter test integration_test
# attempt_limit: 3 # attempt_limit: 3

View File

@@ -15,7 +15,7 @@ jobs:
should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}} should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
persist-credentials: false persist-credentials: false
- id: found_paths - id: found_paths

View File

@@ -1,39 +0,0 @@
module.exports = {
hooks: {
readPackage: (pkg) => {
if (!pkg.name) {
return pkg;
}
switch (pkg.name) {
case "exiftool-vendored":
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
// make exiftool-vendored.pl a regular dependency
pkg.dependencies["exiftool-vendored.pl"] =
pkg.optionalDependencies["exiftool-vendored.pl"];
delete pkg.optionalDependencies["exiftool-vendored.pl"];
}
break;
case "sharp":
const optionalDeps = Object.keys(pkg.optionalDependencies).filter(
(dep) => dep.startsWith("@img")
);
for (const dep of optionalDeps) {
// remove all optionalDependencies from sharp (they will be compiled from source), except:
// include the precompiled musl version of sharp, for web
// include precompiled linux-x64 version of sharp, for server (stage: web-prod)
// include precompiled linux-arm64 version of sharp, for server (stage: web-prod)
if (
dep.includes("musl") ||
dep.includes("linux-x64") ||
dep.includes("linux-arm64")
) {
continue;
}
delete pkg.optionalDependencies[dep];
}
break;
}
return pkg;
},
},
};

View File

@@ -56,8 +56,7 @@
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts", "*.ts": "${capture}.spec.ts,${capture}.mock.ts"
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
}, },
"svelte.enable-ts-plugin": true, "svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative" "typescript.preferences.importModuleSpecifier": "non-relative"

View File

@@ -8,7 +8,7 @@ dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale: dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
dev-docs: dev-docs:
npm --prefix docs run start npm --prefix docs run start
@@ -43,7 +43,7 @@ open-api-typescript:
cd ./open-api && bash ./bin/generate-open-api.sh typescript cd ./open-api && bash ./bin/generate-open-api.sh typescript
sql: sql:
pnpm --filter immich run sync:sql npm --prefix server run sync:sql
attach-server: attach-server:
docker exec -it docker_immich-server_1 sh docker exec -it docker_immich-server_1 sh
@@ -53,40 +53,31 @@ renovate:
MODULES = e2e server web cli sdk docs .github MODULES = e2e server web cli sdk docs .github
# directory to package name mapping function
# cli = @immich/cli
# docs = documentation
# e2e = immich-e2e
# open-api/typescript-sdk = @immich/sdk
# server = immich
# web = immich-web
map-package = $(subst sdk,@immich/sdk,$(subst cli,@immich/cli,$(subst docs,documentation,$(subst e2e,immich-e2e,$(subst server,immich,$(subst web,immich-web,$1))))))
audit-%: audit-%:
pnpm --filter $(call map-package,$*) audit fix npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
install-%: install-%:
pnpm --filter $(call map-package,$*) install $(if $(FROZEN),--frozen-lockfile) $(if $(OFFLINE),--offline) npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
ci-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
build-cli: build-sdk build-cli: build-sdk
build-web: build-sdk build-web: build-sdk
build-%: install-% build-%: install-%
pnpm --filter $(call map-package,$*) run build npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
format-%: format-%:
pnpm --filter $(call map-package,$*) run format:fix npm --prefix $* run format:fix
lint-%: lint-%:
pnpm --filter $(call map-package,$*) run lint:fix npm --prefix $* run lint:fix
lint-web:
pnpm --filter $(call map-package,$*) run lint:p
check-%: check-%:
pnpm --filter $(call map-package,$*) run check npm --prefix $* run check
check-web: check-web:
pnpm --filter immich-web run check:typescript npm --prefix web run check:typescript
pnpm --filter immich-web run check:svelte npm --prefix web run check:svelte
test-%: test-%:
pnpm --filter $(call map-package,$*) run test npm --prefix $* run test
test-e2e: test-e2e:
docker compose -f ./e2e/docker-compose.yml build docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test npm --prefix e2e run test
pnpm --filter immich-e2e run test:web npm --prefix e2e run test:web
test-medium: test-medium:
docker run \ docker run \
--rm \ --rm \
@@ -96,36 +87,25 @@ test-medium:
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \ -v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
-e NODE_ENV=development \ -e NODE_ENV=development \
immich-server:latest \ immich-server:latest \
-c "pnpm test:medium -- --run" -c "npm ci && npm run test:medium -- --run"
test-medium-dev: test-medium-dev:
docker exec -it immich_server /bin/sh -c "pnpm run test:medium" docker exec -it immich_server /bin/sh -c "npm run test:medium"
install-all: build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
pnpm -r --filter '!documentation' install install-all: $(foreach M,$(MODULES),install-$M) ;
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ; check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
check-all: format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/" audit-all: $(foreach M,$(MODULES),audit-$M) ;
lint-all: hygiene-all: lint-all format-all check-all sql audit-all;
pnpm -r --filter '!documentation' run lint:fix test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ;
format-all:
pnpm -r --filter '!documentation' run format:fix
audit-all:
pnpm -r --filter '!documentation' audit fix
hygiene-all: audit-all
pnpm -r --filter '!documentation' run "/(format:fix|check|check:svelte|check:typescript|sql)/"
test-all:
pnpm -r --filter '!documentation' run "/^test/"
clean: clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} + find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "dist" -type d -prune -exec rm -rf '{}' + find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' + find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' + find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true

View File

@@ -1,14 +1,19 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
RUN npm ci
COPY open-api/typescript-sdk/ ./
RUN npm run build
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./
COPY ./cli ./cli/ COPY cli/package.json cli/package-lock.json ./
COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/ RUN npm ci
RUN corepack enable pnpm && \
pnpm install --filter @immich/sdk --filter @immich/cli --frozen-lockfile && \ COPY cli .
pnpm --filter @immich/sdk build && \ RUN npm run build
pnpm --filter @immich/cli build
WORKDIR /import WORKDIR /import
ENTRYPOINT ["node", "/usr/src/app/cli/dist"] ENTRYPOINT ["node", "/usr/src/app/dist"]

842
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.80", "version": "2.2.77",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.17.1", "@types/node": "^22.17.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",

View File

@@ -21,16 +21,17 @@ services:
# extends: # extends:
# file: hwaccel.transcoding.yml # file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
user: '${UID:-1000}:${GID:-1000}'
build: build:
context: ../ context: ../
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
target: dev target: dev
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ..:/usr/src/app - ../server:/usr/src/app/server
- ../open-api:/usr/src/app/open-api
- ${UPLOAD_LOCATION}/photos:/data - ${UPLOAD_LOCATION}/photos:/data
- ${UPLOAD_LOCATION}/photos/upload:/data/upload - ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /usr/src/app/server/node_modules
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
env_file: env_file:
- .env - .env
@@ -57,12 +58,8 @@ services:
- 9231:9231 - 9231:9231
- 2283:2283 - 2283:2283
depends_on: depends_on:
redis: - redis
condition: service_started - database
database:
condition: service_started
init:
condition: service_completed_successfully
healthcheck: healthcheck:
disable: false disable: false
@@ -71,11 +68,9 @@ services:
image: immich-web-dev:latest image: immich-web-dev:latest
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919 # Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0 # user: 0:0
user: '${UID:-1000}:${GID:-1000}'
build: build:
context: ../ context: ../
dockerfile: server/Dockerfile dockerfile: web/Dockerfile
target: dev
command: ['immich-web'] command: ['immich-web']
env_file: env_file:
- .env - .env
@@ -83,17 +78,18 @@ services:
- 3000:3000 - 3000:3000
- 24678:24678 - 24678:24678
volumes: volumes:
- ..:/usr/src/app - ../web:/usr/src/app/web
- ../i18n:/usr/src/app/i18n
- ../open-api/:/usr/src/app/open-api/
# - ../../ui:/usr/ui
- /usr/src/app/web/node_modules
ulimits: ulimits:
nofile: nofile:
soft: 1048576 soft: 1048576
hard: 1048576 hard: 1048576
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
immich-server: - immich-server
condition: service_started
init:
condition: service_completed_successfully
immich-machine-learning: immich-machine-learning:
container_name: immich_machine_learning container_name: immich_machine_learning
@@ -121,7 +117,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
@@ -161,14 +157,6 @@ services:
# volumes: # volumes:
# - grafana-data:/var/lib/grafana # - grafana-data:/var/lib/grafana
init:
container_name: init
image: busybox
env_file:
- .env
user: 0:0
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
volumes: volumes:
model-cache: model-cache:
prometheus-data: prometheus-data:

View File

@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.1.1-ubuntu@sha256:d1da838234ff2de93e0065ee1bf0e66d38f948dcc5d718c25fa6237e14b4424a image: grafana/grafana:12.1.0-ubuntu@sha256:397aa30dd1af16cb6c5c9879498e467973a7f87eacf949f6d5a29407a3843809
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@@ -5,13 +5,13 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
### Installation ### Installation
``` ```
$ pnpm install $ npm install
``` ```
### Local Development ### Local Development
``` ```
$ pnpm run start $ npm run start
``` ```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
@@ -19,7 +19,7 @@ This command starts a local development server and opens up a browser window. Mo
### Build ### Build
``` ```
$ pnpm run build $ npm run build
``` ```
This command generates static content into the `build` directory and can be served using any static contents hosting service. This command generates static content into the `build` directory and can be served using any static contents hosting service.
@@ -29,13 +29,13 @@ This command generates static content into the `build` directory and can be serv
Using SSH: Using SSH:
``` ```
$ USE_SSH=true pnpm run deploy $ USE_SSH=true npm run deploy
``` ```
Not using SSH: Not using SSH:
``` ```
$ GIT_USER=<Your GitHub username> pnpm run deploy $ GIT_USER=<Your GitHub username> npm run deploy
``` ```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

View File

@@ -5,7 +5,7 @@ After making any changes in the `server/src/schema`, a database migration need t
1. Run the command 1. Run the command
```bash ```bash
pnpm run migrations:generate <migration-name> npm run migrations:generate <migration-name>
``` ```
2. Check if the migration file makes sense. 2. Check if the migration file makes sense.

View File

@@ -204,8 +204,8 @@ When the Dev Container starts, it automatically:
1. **Runs post-create script** (`container-server-post-create.sh`): 1. **Runs post-create script** (`container-server-post-create.sh`):
- Adjusts file permissions for the `node` user - Adjusts file permissions for the `node` user
- Installs dependencies: `pnpm install` in all packages - Installs dependencies: `npm install` in all packages
- Builds TypeScript SDK: `pnpm run build` in `open-api/typescript-sdk` - Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
2. **Starts development servers** via VS Code tasks: 2. **Starts development servers** via VS Code tasks:
- `Immich API Server (Nest)` - API server with hot-reloading on port 2283 - `Immich API Server (Nest)` - API server with hot-reloading on port 2283
@@ -243,7 +243,7 @@ To connect the mobile app to your Dev Container:
- **Server code** (`/server`): Changes trigger automatic restart - **Server code** (`/server`): Changes trigger automatic restart
- **Web code** (`/web`): Changes trigger hot module replacement - **Web code** (`/web`): Changes trigger hot module replacement
- **Database migrations**: Run `pnpm run sync:sql` in the server directory - **Database migrations**: Run `npm run sync:sql` in the server directory
- **API changes**: Regenerate TypeScript SDK with `make open-api` - **API changes**: Regenerate TypeScript SDK with `make open-api`
## Testing ## Testing
@@ -273,19 +273,19 @@ make test-medium-dev # End-to-end tests
```bash ```bash
# Server tests # Server tests
cd /workspaces/immich/server cd /workspaces/immich/server
pnpm test # Run all tests npm test # Run all tests
pnpm run test:watch # Watch mode npm run test:watch # Watch mode
pnpm run test:cov # Coverage report npm run test:cov # Coverage report
# Web tests # Web tests
cd /workspaces/immich/web cd /workspaces/immich/web
pnpm test # Run all tests npm test # Run all tests
pnpm run test:watch # Watch mode npm run test:watch # Watch mode
# E2E tests # E2E tests
cd /workspaces/immich/e2e cd /workspaces/immich/e2e
pnpm run test # Run API tests npm run test # Run API tests
pnpm run test:web # Run web UI tests npm run test:web # Run web UI tests
``` ```
### Code Quality Commands ### Code Quality Commands

View File

@@ -8,34 +8,34 @@ When contributing code through a pull request, please check the following:
## Web Checks ## Web Checks
- [ ] `pnpm run lint` (linting via ESLint) - [ ] `npm run lint` (linting via ESLint)
- [ ] `pnpm run format` (formatting via Prettier) - [ ] `npm run format` (formatting via Prettier)
- [ ] `pnpm run check:svelte` (Type checking via SvelteKit) - [ ] `npm run check:svelte` (Type checking via SvelteKit)
- [ ] `pnpm run check:typescript` (check typescript) - [ ] `npm run check:typescript` (check typescript)
- [ ] `pnpm test` (unit tests) - [ ] `npm test` (unit tests)
## Documentation ## Documentation
- [ ] `pnpm run format` (formatting via Prettier) - [ ] `npm run format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation. - [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip AIO :::tip AIO
Run all web checks with `pnpm run check:all` Run all web checks with `npm run check:all`
::: :::
## Server Checks ## Server Checks
- [ ] `pnpm run lint` (linting via ESLint) - [ ] `npm run lint` (linting via ESLint)
- [ ] `pnpm run format` (formatting via Prettier) - [ ] `npm run format` (formatting via Prettier)
- [ ] `pnpm run check` (Type checking via `tsc`) - [ ] `npm run check` (Type checking via `tsc`)
- [ ] `pnpm test` (unit tests) - [ ] `npm test` (unit tests)
:::tip AIO :::tip AIO
Run all server checks with `pnpm run check:all` Run all server checks with `npm run check:all`
::: :::
:::info Auto Fix :::info Auto Fix
You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`. You can use `npm run __:fix` to potentially correct some issues automatically for `npm run format` and `lint`.
::: :::
## Mobile Checks ## Mobile Checks

View File

@@ -54,20 +54,20 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
If you only want to do web development connected to an existing, remote backend, follow these steps: If you only want to do web development connected to an existing, remote backend, follow these steps:
1. Build the Immich SDK - `cd open-api/typescript-sdk && pnpm i && pnpm run build && cd -` 1. Build the Immich SDK - `cd open-api/typescript-sdk && npm i && npm run build && cd -`
2. Enter the web directory - `cd web/` 2. Enter the web directory - `cd web/`
3. Install web dependencies - `pnpm i` 3. Install web dependencies - `npm i`
4. Start the web development server 4. Start the web development server
```bash ```bash
IMMICH_SERVER_URL=https://demo.immich.app/ pnpm run dev IMMICH_SERVER_URL=https://demo.immich.app/ npm run dev
``` ```
If you're using PowerShell on Windows you may need to set the env var separately like so: If you're using PowerShell on Windows you may need to set the env var separately like so:
```powershell ```powershell
$env:IMMICH_SERVER_URL = "https://demo.immich.app/" $env:IMMICH_SERVER_URL = "https://demo.immich.app/"
pnpm run dev npm run dev
``` ```
#### `@immich/ui` #### `@immich/ui`
@@ -75,12 +75,12 @@ pnpm run dev
To see local changes to `@immich/ui` in Immich, do the following: To see local changes to `@immich/ui` in Immich, do the following:
1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui` 1. Install `@immich/ui` as a sibling to `immich/`, for example `/home/user/immich` and `/home/user/ui`
2. Build the `@immich/ui` project via `pnpm run build` 2. Build the `@immich/ui` project via `npm run build`
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`) 3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yaml` file (`../../ui:/usr/ui`)
4. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`) 4. Uncomment the corresponding alias in the `web/vite.config.js` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui')`)
5. Uncomment the import statement in `web/src/app.css` file `@import '/usr/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';` 5. Uncomment the import statement in `web/src/app.css` file `@import '/usr/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
6. Start up the stack via `make dev` 6. Start up the stack via `make dev`
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`) 7. After making changes in `@immich/ui`, rebuild it (`npm run build`)
### Mobile app ### Mobile app

View File

@@ -4,8 +4,8 @@
### Unit tests ### Unit tests
Unit are run by calling `pnpm run test` from the `server/` directory. Unit are run by calling `npm run test` from the `server/` directory.
You need to run `pnpm install` (in `server/`) before _once_. You need to run `npm install` (in `server/`) before _once_.
### End to end tests ### End to end tests
@@ -17,14 +17,14 @@ make e2e
Before you can run the tests, you need to run the following commands _once_: Before you can run the tests, you need to run the following commands _once_:
- `pnpm install` (in `e2e/`) - `npm install` (in `e2e/`)
- `make open-api` (in the project root `/`) - `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via: Once the test environment is running, the e2e tests can be run via:
```bash ```bash
cd e2e/ cd e2e/
pnpm test npm test
``` ```
The tests check various things including: The tests check various things including:

20545
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,4 @@
[ [
{
"label": "v1.139.0",
"url": "https://v1.139.0.archive.immich.app"
},
{
"label": "v1.138.1",
"url": "https://v1.138.1.archive.immich.app"
},
{
"label": "v1.138.0",
"url": "https://v1.138.0.archive.immich.app"
},
{ {
"label": "v1.137.3", "label": "v1.137.3",
"url": "https://v1.137.3.archive.immich.app" "url": "https://v1.137.3.archive.immich.app"

1
e2e/.gitignore vendored
View File

@@ -3,4 +3,3 @@ node_modules/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/dist

1070
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.139.0", "version": "1.137.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.17.1", "@types/node": "^22.17.0",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",

View File

@@ -79,7 +79,7 @@ export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` }); export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
export const immichCli = (args: string[]) => export const immichCli = (args: string[]) =>
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise; executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise;
export const immichAdmin = (args: string[]) => export const immichAdmin = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*']; export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];

View File

@@ -28,9 +28,6 @@
"add_to_album": "Add to album", "add_to_album": "Add to album",
"add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_toggle": "Toggle selection for {album}",
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",
"add_to_shared_album": "Add to shared album", "add_to_shared_album": "Add to shared album",
"add_url": "Add URL", "add_url": "Add URL",
"added_to_archive": "Added to archive", "added_to_archive": "Added to archive",
@@ -500,9 +497,7 @@
"assets": "Assets", "assets": "Assets",
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal} albums",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album", "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
"assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", "assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
@@ -519,7 +514,6 @@
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}", "assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
"assets_trashed_from_server": "{count} asset(s) trashed from the Immich server", "assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
"assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums",
"authorized_devices": "Authorized Devices", "authorized_devices": "Authorized Devices",
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
"automatic_endpoint_switching_title": "Automatic URL switching", "automatic_endpoint_switching_title": "Automatic URL switching",
@@ -1062,7 +1056,6 @@
"filter_people": "Filter people", "filter_people": "Filter people",
"filter_places": "Filter places", "filter_places": "Filter places",
"find_them_fast": "Find them fast by name with search", "find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match", "fix_incorrect_match": "Fix incorrect match",
"folder": "Folder", "folder": "Folder",
"folder_not_found": "Folder not found", "folder_not_found": "Folder not found",
@@ -1184,7 +1177,6 @@
"language_search_hint": "Search languages...", "language_search_hint": "Search languages...",
"language_setting_description": "Select your preferred language", "language_setting_description": "Select your preferred language",
"large_files": "Large Files", "large_files": "Large Files",
"last": "Last",
"last_seen": "Last seen", "last_seen": "Last seen",
"latest_version": "Latest Version", "latest_version": "Latest Version",
"latitude": "Latitude", "latitude": "Latitude",
@@ -1203,7 +1195,6 @@
"library_page_sort_title": "Album title", "library_page_sort_title": "Album title",
"licenses": "Licenses", "licenses": "Licenses",
"light": "Light", "light": "Light",
"like": "Like",
"like_deleted": "Like deleted", "like_deleted": "Like deleted",
"link_motion_video": "Link motion video", "link_motion_video": "Link motion video",
"link_to_oauth": "Link to OAuth", "link_to_oauth": "Link to OAuth",

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:c642d5dfaf9115a12086785f23008558ae2e13bcd0c4794536340bcb777a4381 AS builder-cpu FROM python:3.11-bookworm@sha256:c1239cb82bf08176c4c90421ab425a1696257b098d9ce21e68de9319c255a47d AS builder-cpu
FROM builder-cpu AS builder-openvino FROM builder-cpu AS builder-openvino
@@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends g++ RUN apt-get update && apt-get install -y --no-install-recommends g++
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:f64ad69940b634e75d2e4d799eb5238066c5eeda49f76e782d4873c3d014ea33 /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest@sha256:67b2bcccdc103d608727d1b577e58008ef810f751ed324715eb60b3f0c040d30 /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
@@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \ uv pip install /opt/onnxruntime_rocm-*.whl; \
fi fi
FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-cpu FROM python:3.11-slim-bookworm@sha256:0ce77749ac83174a31d5e107ce0cfa6b28a2fd6b0615e029d9d84b39c48976ee AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-openvino FROM python:3.11-slim-bookworm@sha256:0ce77749ac83174a31d5e107ce0cfa6b28a2fd6b0615e029d9d84b39c48976ee AS prod-openvino
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

3258
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
cmake_minimum_required(VERSION 3.12) cmake_minimum_required(VERSION 3.10.2)
project("native_buffer")
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED add_library(native_buffer SHARED
src/main/cpp/native_buffer.c src/main/cpp/native_buffer.c)
)
find_library(log-lib log)
target_link_libraries(native_buffer ${log-lib})

View File

@@ -3,38 +3,50 @@
JNIEXPORT jlong JNICALL JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative( Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative(
JNIEnv *env, jclass clazz, jint size) { JNIEnv *env, jclass clazz, jint size)
{
void *ptr = malloc(size); void *ptr = malloc(size);
return (jlong) ptr; return (jlong)ptr;
} }
JNIEXPORT jlong JNICALL JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative( Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative(
JNIEnv *env, jclass clazz, jint size) { JNIEnv *env, jclass clazz, jint size)
{
void *ptr = malloc(size); void *ptr = malloc(size);
return (jlong) ptr; return (jlong)ptr;
} }
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative( Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
JNIEnv *env, jclass clazz, jlong address) { JNIEnv *env, jclass clazz, jlong address)
free((void *) address); {
if (address != 0)
{
free((void *)address);
}
} }
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative( Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
JNIEnv *env, jclass clazz, jlong address) { JNIEnv *env, jclass clazz, jlong address)
free((void *) address); {
if (address != 0)
{
free((void *)address);
}
} }
JNIEXPORT jobject JNICALL JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer( Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) { JNIEnv *env, jclass clazz, jlong address, jint capacity)
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity); {
return (*env)->NewDirectByteBuffer(env, (void*)address, capacity);
} }
JNIEXPORT jobject JNICALL JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer( Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) { JNIEnv *env, jclass clazz, jlong address, jint capacity)
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity); {
return (*env)->NewDirectByteBuffer(env, (void*)address, capacity);
} }

View File

@@ -10,11 +10,11 @@ import com.bumptech.glide.module.AppGlideModule
@GlideModule @GlideModule
class AppGlideModule : AppGlideModule() { class AppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) { override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder) super.applyOptions(context, builder)
// disable caching as this is already done on the Flutter side // disable caching as this is already done on the Flutter side
builder.setMemoryCache(MemoryCacheAdapter()) builder.setMemoryCache(MemoryCacheAdapter())
builder.setDiskCache(DiskCacheAdapter.Factory()) builder.setDiskCache(DiskCacheAdapter.Factory())
builder.setBitmapPool(BitmapPoolAdapter()) builder.setBitmapPool(BitmapPoolAdapter())
} }
} }

View File

@@ -25,206 +25,231 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future import java.util.concurrent.Future
data class Request( data class Request(
val taskFuture: Future<*>, val requestId: Long,
val cancellationSignal: CancellationSignal, val taskFuture: Future<*>,
val callback: (Result<Map<String, Long>>) -> Unit val cancellationSignal: CancellationSignal,
val callback: (Result<Map<String, Long>>) -> Unit
) )
class ThumbnailsImpl(context: Context) : ThumbnailApi { class ThumbnailsImpl(context: Context) : ThumbnailApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private val resolver: ContentResolver = ctx.contentResolver private val resolver: ContentResolver = ctx.contentResolver
private val requestThread = Executors.newSingleThreadExecutor() private val requestThread = Executors.newSingleThreadExecutor()
private val threadPool = private val threadPool =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1) Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1)
private val requestMap = ConcurrentHashMap<Long, Request>() private val requestMap = ConcurrentHashMap<Long, Request>()
companion object { companion object {
val CANCELLED = Result.success<Map<String, Long>>(mapOf()) val PROJECTION = arrayOf(
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 } MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.Files.FileColumns.MEDIA_TYPE,
init {
System.loadLibrary("native_buffer")
}
@JvmStatic
external fun allocateNative(size: Int): Long
@JvmStatic
external fun freeNative(pointer: Long)
@JvmStatic
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
}
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
threadPool.execute {
try {
val bytes = Base64.getDecoder().decode(thumbhash)
val image = ThumbHash.thumbHashToRGBA(bytes)
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong()
) )
callback(Result.success(res)) const val SELECTION = "${MediaStore.MediaColumns._ID} = ?"
} catch (e: Exception) { val URI: Uri = MediaStore.Files.getContentUri("external")
callback(Result.failure(e))
}
}
}
override fun requestImage( const val MEDIA_TYPE_IMAGE = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
assetId: String, const val MEDIA_TYPE_VIDEO = MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
requestId: Long, val CANCELLED = Result.success<Map<String, Long>>(mapOf())
width: Long, val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
height: Long,
isVideo: Boolean, init {
callback: (Result<Map<String, Long>>) -> Unit System.loadLibrary("native_buffer")
) {
val signal = CancellationSignal()
val task = threadPool.submit {
try {
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
} catch (e: Exception) {
when (e) {
is OperationCanceledException -> callback(CANCELLED)
is CancellationException -> callback(CANCELLED)
else -> callback(Result.failure(e))
} }
} finally {
requestMap.remove(requestId)
}
}
val request = Request(task, signal, callback)
requestMap[requestId] = request
}
override fun cancelImageRequest(requestId: Long) { @JvmStatic
val request = requestMap.remove(requestId) ?: return external fun allocateNative(size: Int): Long
request.taskFuture.cancel(false)
request.cancellationSignal.cancel() @JvmStatic
if (request.taskFuture.isCancelled) { external fun freeNative(pointer: Long)
requestThread.execute {
@JvmStatic
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
}
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
threadPool.execute {
try {
val bytes = Base64.getDecoder().decode(thumbhash)
val image = ThumbHash.thumbHashToRGBA(bytes)
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
override fun requestImage(
assetId: String,
requestId: Long,
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit
) {
val signal = CancellationSignal()
val task = threadPool.submit {
try {
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
} catch (e: Exception) {
when (e) {
is OperationCanceledException -> callback(CANCELLED)
is CancellationException -> callback(CANCELLED)
else -> callback(Result.failure(e))
}
} finally {
requestMap.remove(requestId)
}
}
val request = Request(requestId, task, signal, callback)
requestMap[requestId] = request
}
override fun cancelImageRequest(requestId: Long) {
val request = requestMap.remove(requestId) ?: return
request.taskFuture.cancel(false)
request.cancellationSignal.cancel()
if (request.taskFuture.isCancelled) {
requestThread.execute {
try {
request.callback(CANCELLED)
} catch (_: Exception) {
}
}
}
}
private fun getThumbnailBufferInternal(
assetId: String,
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
val targetWidth = width.toInt()
val targetHeight = height.toInt()
val id = assetId.toLong()
signal.throwIfCanceled()
val bitmap = if (isVideo) {
decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
} else {
decodeImage(id, targetWidth, targetHeight, signal)
}
processBitmap(bitmap, callback, signal)
}
private fun processBitmap(
bitmap: Bitmap,
callback: (Result<Map<String, Long>>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
val actualWidth = bitmap.width
val actualHeight = bitmap.height
val size = actualWidth * actualHeight * 4
val pointer = allocateNative(size)
try { try {
request.callback(CANCELLED) signal.throwIfCanceled()
} catch (_: Exception) { val buffer = wrapAsBuffer(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
bitmap.recycle()
signal.throwIfCanceled()
val res = mapOf(
"pointer" to pointer,
"width" to actualWidth.toLong(),
"height" to actualHeight.toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
freeNative(pointer)
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
} }
}
}
}
private fun getThumbnailBufferInternal(
assetId: String,
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
val targetWidth = width.toInt()
val targetHeight = height.toInt()
val id = assetId.toLong()
signal.throwIfCanceled()
val bitmap = if (isVideo) {
decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
} else {
decodeImage(id, targetWidth, targetHeight, signal)
} }
processBitmap(bitmap, callback, signal) private fun decodeImage(
} id: Long,
targetWidth: Int,
targetHeight: Int,
signal: CancellationSignal
): Bitmap {
signal.throwIfCanceled()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
if (targetHeight > 768 || targetWidth > 768) {
return decodeSource(uri, targetWidth, targetHeight, signal)
}
private fun processBitmap( return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bitmap: Bitmap, callback: (Result<Map<String, Long>>) -> Unit, signal: CancellationSignal resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
) { } else {
signal.throwIfCanceled() signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
val actualWidth = bitmap.width Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
val actualHeight = bitmap.height }
val size = actualWidth * actualHeight * 4
val pointer = allocateNative(size)
try {
signal.throwIfCanceled()
val buffer = wrapAsBuffer(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
bitmap.recycle()
signal.throwIfCanceled()
val res = mapOf(
"pointer" to pointer,
"width" to actualWidth.toLong(),
"height" to actualHeight.toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
freeNative(pointer)
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
private fun decodeImage(
id: Long, targetWidth: Int, targetHeight: Int, signal: CancellationSignal
): Bitmap {
signal.throwIfCanceled()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
if (targetHeight > 768 || targetWidth > 768) {
return decodeSource(uri, targetWidth, targetHeight, signal)
} }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { private fun decodeVideoThumbnail(
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) id: Long,
} else { targetWidth: Int,
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) } targetHeight: Int,
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS) signal: CancellationSignal
): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else {
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
}
} }
}
private fun decodeVideoThumbnail( private fun decodeSource(
id: Long, targetWidth: Int, targetHeight: Int, signal: CancellationSignal uri: Uri,
): Bitmap { targetWidth: Int,
signal.throwIfCanceled() targetHeight: Int,
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { signal: CancellationSignal
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id) ): Bitmap {
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) signal.throwIfCanceled()
} else { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) } val source = ImageDecoder.createSource(resolver, uri)
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS) signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
val sampleSize =
getSampleSize(info.size.width, info.size.height, targetWidth, targetHeight)
decoder.setTargetSampleSize(sampleSize)
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
} else {
val ref = Glide.with(ctx)
.asBitmap()
.priority(Priority.IMMEDIATE)
.load(uri)
.disallowHardwareConfig()
.format(DecodeFormat.PREFER_ARGB_8888)
.submit(targetWidth, targetHeight)
signal.setOnCancelListener { Glide.with(ctx).clear(ref) }
ref.get()
}
} }
}
private fun decodeSource( private fun getSampleSize(fullWidth: Int, fullHeight: Int, reqWidth: Int, reqHeight: Int): Int {
uri: Uri, targetWidth: Int, targetHeight: Int, signal: CancellationSignal return 1 shl max(
): Bitmap { 0, floor(
signal.throwIfCanceled() min(
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { log2(fullWidth / (2.0 * reqWidth)),
val source = ImageDecoder.createSource(resolver, uri) log2(fullHeight / (2.0 * reqHeight)),
signal.throwIfCanceled() )
ImageDecoder.decodeBitmap(source) { decoder, info, _ -> ).toInt()
val sampleSize =
getSampleSize(info.size.width, info.size.height, targetWidth, targetHeight)
decoder.setTargetSampleSize(sampleSize)
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
} else {
val ref = Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri)
.disallowHardwareConfig().format(DecodeFormat.PREFER_ARGB_8888)
.submit(targetWidth, targetHeight)
signal.setOnCancelListener { Glide.with(ctx).clear(ref) }
ref.get()
}
}
private fun getSampleSize(fullWidth: Int, fullHeight: Int, reqWidth: Int, reqHeight: Int): Int {
return 1 shl max(
0, floor(
min(
log2(fullWidth / reqWidth.toDouble()),
log2(fullHeight / reqHeight.toDouble()),
) )
).toInt() }
)
}
} }

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3005, "android.injected.version.code" => 3002,
"android.injected.version.name" => "1.139.0", "android.injected.version.name" => "1.137.3",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@@ -39,18 +40,15 @@ class ImmichTestHelper {
static Future<void> loadApp(WidgetTester tester) async { static Future<void> loadApp(WidgetTester tester) async {
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
// Clear all data from Isar (reuse existing instance if available) // Clear all data from Isar (reuse existing instance if available)
final (isar, drift, logDb) = await Bootstrap.initDB(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(isar, drift, logDb); final logDb = DriftLogger();
await Bootstrap.initDomain(db, logDb);
await Store.clear(); await Store.clear();
await isar.writeTxn(() => isar.clear()); await db.writeTxn(() => db.clear());
// Load main Widget // Load main Widget
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)],
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const app.MainWidget(), child: const app.MainWidget(),
), ),
); );

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -667,7 +667,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -811,7 +811,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -841,7 +841,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -875,7 +875,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -918,7 +918,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -958,7 +958,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -997,7 +997,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1041,7 +1041,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1082,7 +1082,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 215; CURRENT_PROJECT_VERSION = 213;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -57,7 +57,7 @@ class ThumbnailApiImpl: ThumbnailApi {
} }
} }
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let request = Request(callback: completion) let request = Request(callback: completion)
let item = DispatchWorkItem { let item = DispatchWorkItem {
if request.isCancelled { if request.isCancelled {
@@ -88,7 +88,7 @@ class ThumbnailApiImpl: ThumbnailApi {
Self.imageManager.requestImage( Self.imageManager.requestImage(
for: asset, for: asset,
targetSize: CGSize(width: Double(width), height: Double(height)), targetSize: CGSize(width: Double(width), height: Double(height)),
contentMode: .aspectFill, contentMode: .aspectFit,
options: Self.requestOptions, options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in resultHandler: { (_image, info) -> Void in
image = _image image = _image
@@ -169,7 +169,7 @@ class ThumbnailApiImpl: ThumbnailApi {
request.isCancelled = true request.isCancelled = true
guard let item = request.workItem else { return } guard let item = request.workItem else { return }
if item.isCancelled { if item.isCancelled {
cancelQueue.async { request.callback(Self.cancelledResult) } request.callback(Self.cancelledResult)
} }
} }
} }

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.138.1</string> <string>1.137.2</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -105,7 +105,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>215</string> <string>213</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true /> <true />
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
) )
increment_version_number( increment_version_number(
version_number: "1.139.0" version_number: "1.137.3"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -23,7 +23,6 @@ class RemoteAlbum {
final AlbumAssetOrder order; final AlbumAssetOrder order;
final int assetCount; final int assetCount;
final String ownerName; final String ownerName;
final bool isShared;
const RemoteAlbum({ const RemoteAlbum({
required this.id, required this.id,
@@ -37,7 +36,6 @@ class RemoteAlbum {
required this.order, required this.order,
required this.assetCount, required this.assetCount,
required this.ownerName, required this.ownerName,
required this.isShared,
}); });
@override @override
@@ -54,7 +52,6 @@ class RemoteAlbum {
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"} thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
assetCount: $assetCount assetCount: $assetCount
ownerName: $ownerName ownerName: $ownerName
isShared: $isShared
}'''; }''';
} }
@@ -72,8 +69,7 @@ class RemoteAlbum {
isActivityEnabled == other.isActivityEnabled && isActivityEnabled == other.isActivityEnabled &&
order == other.order && order == other.order &&
assetCount == other.assetCount && assetCount == other.assetCount &&
ownerName == other.ownerName && ownerName == other.ownerName;
isShared == other.isShared;
} }
@override @override
@@ -88,8 +84,7 @@ class RemoteAlbum {
isActivityEnabled.hashCode ^ isActivityEnabled.hashCode ^
order.hashCode ^ order.hashCode ^
assetCount.hashCode ^ assetCount.hashCode ^
ownerName.hashCode ^ ownerName.hashCode;
isShared.hashCode;
} }
RemoteAlbum copyWith({ RemoteAlbum copyWith({
@@ -104,7 +99,6 @@ class RemoteAlbum {
AlbumAssetOrder? order, AlbumAssetOrder? order,
int? assetCount, int? assetCount,
String? ownerName, String? ownerName,
bool? isShared,
}) { }) {
return RemoteAlbum( return RemoteAlbum(
id: id ?? this.id, id: id ?? this.id,
@@ -118,7 +112,6 @@ class RemoteAlbum {
order: order ?? this.order, order: order ?? this.order,
assetCount: assetCount ?? this.assetCount, assetCount: assetCount ?? this.assetCount,
ownerName: ownerName ?? this.ownerName, ownerName: ownerName ?? this.ownerName,
isShared: isShared ?? this.isShared,
); );
} }
} }

View File

@@ -15,7 +15,7 @@ import 'package:logging/logging.dart';
/// via [IStoreRepository] /// via [IStoreRepository]
class LogService { class LogService {
final LogRepository _logRepository; final LogRepository _logRepository;
final IStoreRepository _storeRepository; final IsarStoreRepository _storeRepository;
final List<LogMessage> _msgBuffer = []; final List<LogMessage> _msgBuffer = [];
@@ -38,7 +38,7 @@ class LogService {
static Future<LogService> init({ static Future<LogService> init({
required LogRepository logRepository, required LogRepository logRepository,
required IStoreRepository storeRepository, required IsarStoreRepository storeRepository,
bool shouldBuffer = true, bool shouldBuffer = true,
}) async { }) async {
_instance ??= await create( _instance ??= await create(
@@ -51,7 +51,7 @@ class LogService {
static Future<LogService> create({ static Future<LogService> create({
required LogRepository logRepository, required LogRepository logRepository,
required IStoreRepository storeRepository, required IsarStoreRepository storeRepository,
bool shouldBuffer = true, bool shouldBuffer = true,
}) async { }) async {
final instance = LogService._(logRepository, storeRepository, shouldBuffer); final instance = LogService._(logRepository, storeRepository, shouldBuffer);
@@ -92,7 +92,7 @@ class LogService {
} }
Future<void> setLogLevel(LogLevel level) async { Future<void> setLogLevel(LogLevel level) async {
await _storeRepository.upsert(StoreKey.logLevel, level.index); await _storeRepository.insert(StoreKey.logLevel, level.index);
Logger.root.level = level.toLevel(); Logger.root.level = level.toLevel();
} }

View File

@@ -6,13 +6,13 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
/// Provides access to a persistent key-value store with an in-memory cache. /// Provides access to a persistent key-value store with an in-memory cache.
/// Listens for repository changes to keep the cache updated. /// Listens for repository changes to keep the cache updated.
class StoreService { class StoreService {
final IStoreRepository _storeRepository; final IsarStoreRepository _storeRepository;
/// In-memory cache. Keys are [StoreKey.id] /// In-memory cache. Keys are [StoreKey.id]
final Map<int, Object?> _cache = {}; final Map<int, Object?> _cache = {};
late final StreamSubscription<List<StoreDto>> _storeUpdateSubscription; late final StreamSubscription<StoreDto> _storeUpdateSubscription;
StoreService._({required IStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository; StoreService._({required IsarStoreRepository storeRepository}) : _storeRepository = storeRepository;
// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider // TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
static StoreService? _instance; static StoreService? _instance;
@@ -24,29 +24,27 @@ class StoreService {
} }
// TODO: Replace the implementation with the one from create after removing the typedef // TODO: Replace the implementation with the one from create after removing the typedef
static Future<StoreService> init({required IStoreRepository storeRepository}) async { static Future<StoreService> init({required IsarStoreRepository storeRepository}) async {
_instance ??= await create(storeRepository: storeRepository); _instance ??= await create(storeRepository: storeRepository);
return _instance!; return _instance!;
} }
static Future<StoreService> create({required IStoreRepository storeRepository}) async { static Future<StoreService> create({required IsarStoreRepository storeRepository}) async {
final instance = StoreService._(isarStoreRepository: storeRepository); final instance = StoreService._(storeRepository: storeRepository);
await instance.populateCache(); await instance._populateCache();
instance._storeUpdateSubscription = instance._listenForChange(); instance._storeUpdateSubscription = instance._listenForChange();
return instance; return instance;
} }
Future<void> populateCache() async { Future<void> _populateCache() async {
final storeValues = await _storeRepository.getAll(); final storeValues = await _storeRepository.getAll();
for (StoreDto storeValue in storeValues) { for (StoreDto storeValue in storeValues) {
_cache[storeValue.key.id] = storeValue.value; _cache[storeValue.key.id] = storeValue.value;
} }
} }
StreamSubscription<List<StoreDto>> _listenForChange() => _storeRepository.watchAll().listen((events) { StreamSubscription<StoreDto> _listenForChange() => _storeRepository.watchAll().listen((event) {
for (final event in events) { _cache[event.key.id] = event.value;
_cache[event.key.id] = event.value;
}
}); });
/// Disposes the store and cancels the subscription. To reuse the store call init() again /// Disposes the store and cancels the subscription. To reuse the store call init() again
@@ -71,7 +69,7 @@ class StoreService {
/// Stores the [value] for the [key]. Skips write if value hasn't changed. /// Stores the [value] for the [key]. Skips write if value hasn't changed.
Future<void> put<U extends StoreKey<T>, T>(U key, T value) async { Future<void> put<U extends StoreKey<T>, T>(U key, T value) async {
if (_cache[key.id] == value) return; if (_cache[key.id] == value) return;
await _storeRepository.upsert(key, value); await _storeRepository.insert(key, value);
_cache[key.id] = value; _cache[key.id] = value;
} }

View File

@@ -169,36 +169,6 @@ class TimelineService {
return _buffer.elementAt(index - _bufferOffset); return _buffer.elementAt(index - _bufferOffset);
} }
/// Gets an asset at the given index, automatically loading the buffer if needed.
/// This is an async version that can handle out-of-range indices by loading the appropriate buffer.
Future<BaseAsset?> getAssetAsync(int index) async {
if (index < 0 || index >= _totalAssets) {
return null;
}
if (hasRange(index, 1)) {
return _buffer.elementAt(index - _bufferOffset);
}
// Load the buffer containing the requested index
try {
final assets = await loadAssets(index, 1);
return assets.isNotEmpty ? assets.first : null;
} catch (e) {
return null;
}
}
/// Safely gets an asset at the given index without throwing a RangeError.
/// Returns null if the index is out of bounds or not currently in the buffer.
/// For automatic buffer loading, use getAssetAsync instead.
BaseAsset? getAssetSafe(int index) {
if (index < 0 || index >= _totalAssets || !hasRange(index, 1)) {
return null;
}
return _buffer.elementAt(index - _bufferOffset);
}
Future<void> dispose() async { Future<void> dispose() async {
await _bucketSubscription?.cancel(); await _bucketSubscription?.cancel();
_bucketSubscription = null; _bucketSubscription = null;

View File

@@ -85,3 +85,13 @@ extension DateRangeFormatting on DateTime {
} }
} }
} }
extension IsSameExtension on DateTime {
bool isSameDay(DateTime other) {
return day == other.day && month == other.month && year == other.year;
}
bool isSameMonth(DateTime other) {
return month == other.month && year == other.year;
}
}

View File

@@ -32,3 +32,31 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
damping: 80, damping: 80,
); );
} }
class ScrollUnawareScrollPhysics extends ScrollPhysics {
const ScrollUnawareScrollPhysics({super.parent});
@override
ScrollUnawareScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ScrollUnawareScrollPhysics(parent: buildParent(ancestor));
}
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
return false;
}
}
class ScrollUnawareClampingScrollPhysics extends ClampingScrollPhysics {
const ScrollUnawareClampingScrollPhysics({super.parent});
@override
ScrollUnawareClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ScrollUnawareClampingScrollPhysics(parent: buildParent(ancestor));
}
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
return false;
}
}

View File

@@ -1,5 +1,3 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
part 'store.entity.g.dart'; part 'store.entity.g.dart';
@@ -13,13 +11,3 @@ class StoreValue {
const StoreValue(this.id, {this.intValue, this.strValue}); const StoreValue(this.id, {this.intValue, this.strValue});
} }
class StoreEntity extends Table with DriftDefaultsMixin {
IntColumn get id => integer()();
TextColumn get stringValue => text().nullable()();
IntColumn get intValue => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
}

View File

@@ -1,426 +0,0 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/store.entity.dart' as i2;
typedef $$StoreEntityTableCreateCompanionBuilder =
i1.StoreEntityCompanion Function({
required int id,
i0.Value<String?> stringValue,
i0.Value<int?> intValue,
});
typedef $$StoreEntityTableUpdateCompanionBuilder =
i1.StoreEntityCompanion Function({
i0.Value<int> id,
i0.Value<String?> stringValue,
i0.Value<int?> intValue,
});
class $$StoreEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$StoreEntityTable> {
$$StoreEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get stringValue => $composableBuilder(
column: $table.stringValue,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<int> get intValue => $composableBuilder(
column: $table.intValue,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$StoreEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$StoreEntityTable> {
$$StoreEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get stringValue => $composableBuilder(
column: $table.stringValue,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get intValue => $composableBuilder(
column: $table.intValue,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$StoreEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$StoreEntityTable> {
$$StoreEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumn<String> get stringValue => $composableBuilder(
column: $table.stringValue,
builder: (column) => column,
);
i0.GeneratedColumn<int> get intValue =>
$composableBuilder(column: $table.intValue, builder: (column) => column);
}
class $$StoreEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$StoreEntityTable,
i1.StoreEntityData,
i1.$$StoreEntityTableFilterComposer,
i1.$$StoreEntityTableOrderingComposer,
i1.$$StoreEntityTableAnnotationComposer,
$$StoreEntityTableCreateCompanionBuilder,
$$StoreEntityTableUpdateCompanionBuilder,
(
i1.StoreEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$StoreEntityTable,
i1.StoreEntityData
>,
),
i1.StoreEntityData,
i0.PrefetchHooks Function()
> {
$$StoreEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$StoreEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$StoreEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$StoreEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$StoreEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<int> id = const i0.Value.absent(),
i0.Value<String?> stringValue = const i0.Value.absent(),
i0.Value<int?> intValue = const i0.Value.absent(),
}) => i1.StoreEntityCompanion(
id: id,
stringValue: stringValue,
intValue: intValue,
),
createCompanionCallback:
({
required int id,
i0.Value<String?> stringValue = const i0.Value.absent(),
i0.Value<int?> intValue = const i0.Value.absent(),
}) => i1.StoreEntityCompanion.insert(
id: id,
stringValue: stringValue,
intValue: intValue,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$StoreEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$StoreEntityTable,
i1.StoreEntityData,
i1.$$StoreEntityTableFilterComposer,
i1.$$StoreEntityTableOrderingComposer,
i1.$$StoreEntityTableAnnotationComposer,
$$StoreEntityTableCreateCompanionBuilder,
$$StoreEntityTableUpdateCompanionBuilder,
(
i1.StoreEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$StoreEntityTable,
i1.StoreEntityData
>,
),
i1.StoreEntityData,
i0.PrefetchHooks Function()
>;
class $StoreEntityTable extends i2.StoreEntity
with i0.TableInfo<$StoreEntityTable, i1.StoreEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$StoreEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<int> id = i0.GeneratedColumn<int>(
'id',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _stringValueMeta = const i0.VerificationMeta(
'stringValue',
);
@override
late final i0.GeneratedColumn<String> stringValue =
i0.GeneratedColumn<String>(
'string_value',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _intValueMeta = const i0.VerificationMeta(
'intValue',
);
@override
late final i0.GeneratedColumn<int> intValue = i0.GeneratedColumn<int>(
'int_value',
aliasedName,
true,
type: i0.DriftSqlType.int,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [id, stringValue, intValue];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'store_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.StoreEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('string_value')) {
context.handle(
_stringValueMeta,
stringValue.isAcceptableOrUnknown(
data['string_value']!,
_stringValueMeta,
),
);
}
if (data.containsKey('int_value')) {
context.handle(
_intValueMeta,
intValue.isAcceptableOrUnknown(data['int_value']!, _intValueMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.StoreEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.StoreEntityData(
id: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}id'],
)!,
stringValue: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}string_value'],
),
intValue: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}int_value'],
),
);
}
@override
$StoreEntityTable createAlias(String alias) {
return $StoreEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class StoreEntityData extends i0.DataClass
implements i0.Insertable<i1.StoreEntityData> {
final int id;
final String? stringValue;
final int? intValue;
const StoreEntityData({required this.id, this.stringValue, this.intValue});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<int>(id);
if (!nullToAbsent || stringValue != null) {
map['string_value'] = i0.Variable<String>(stringValue);
}
if (!nullToAbsent || intValue != null) {
map['int_value'] = i0.Variable<int>(intValue);
}
return map;
}
factory StoreEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return StoreEntityData(
id: serializer.fromJson<int>(json['id']),
stringValue: serializer.fromJson<String?>(json['stringValue']),
intValue: serializer.fromJson<int?>(json['intValue']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'stringValue': serializer.toJson<String?>(stringValue),
'intValue': serializer.toJson<int?>(intValue),
};
}
i1.StoreEntityData copyWith({
int? id,
i0.Value<String?> stringValue = const i0.Value.absent(),
i0.Value<int?> intValue = const i0.Value.absent(),
}) => i1.StoreEntityData(
id: id ?? this.id,
stringValue: stringValue.present ? stringValue.value : this.stringValue,
intValue: intValue.present ? intValue.value : this.intValue,
);
StoreEntityData copyWithCompanion(i1.StoreEntityCompanion data) {
return StoreEntityData(
id: data.id.present ? data.id.value : this.id,
stringValue: data.stringValue.present
? data.stringValue.value
: this.stringValue,
intValue: data.intValue.present ? data.intValue.value : this.intValue,
);
}
@override
String toString() {
return (StringBuffer('StoreEntityData(')
..write('id: $id, ')
..write('stringValue: $stringValue, ')
..write('intValue: $intValue')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, stringValue, intValue);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.StoreEntityData &&
other.id == this.id &&
other.stringValue == this.stringValue &&
other.intValue == this.intValue);
}
class StoreEntityCompanion extends i0.UpdateCompanion<i1.StoreEntityData> {
final i0.Value<int> id;
final i0.Value<String?> stringValue;
final i0.Value<int?> intValue;
const StoreEntityCompanion({
this.id = const i0.Value.absent(),
this.stringValue = const i0.Value.absent(),
this.intValue = const i0.Value.absent(),
});
StoreEntityCompanion.insert({
required int id,
this.stringValue = const i0.Value.absent(),
this.intValue = const i0.Value.absent(),
}) : id = i0.Value(id);
static i0.Insertable<i1.StoreEntityData> custom({
i0.Expression<int>? id,
i0.Expression<String>? stringValue,
i0.Expression<int>? intValue,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (stringValue != null) 'string_value': stringValue,
if (intValue != null) 'int_value': intValue,
});
}
i1.StoreEntityCompanion copyWith({
i0.Value<int>? id,
i0.Value<String?>? stringValue,
i0.Value<int?>? intValue,
}) {
return i1.StoreEntityCompanion(
id: id ?? this.id,
stringValue: stringValue ?? this.stringValue,
intValue: intValue ?? this.intValue,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<int>(id.value);
}
if (stringValue.present) {
map['string_value'] = i0.Variable<String>(stringValue.value);
}
if (intValue.present) {
map['int_value'] = i0.Variable<int>(intValue.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('StoreEntityCompanion(')
..write('id: $id, ')
..write('stringValue: $stringValue, ')
..write('intValue: $intValue')
..write(')'))
.toString();
}
}

View File

@@ -1,86 +0,0 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ffi/ffi.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:logging/logging.dart';
part 'local_image_request.dart';
part 'thumbhash_image_request.dart';
part 'remote_image_request.dart';
abstract class ImageRequest {
static int _nextRequestId = 0;
final int requestId = _nextRequestId++;
bool _isCancelled = false;
get isCancelled => _isCancelled;
ImageRequest();
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
void cancel() {
if (_isCancelled) {
return;
}
_isCancelled = true;
return _onCancelled();
}
void _onCancelled();
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
final address = info['pointer'];
if (address == null) {
return null;
}
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
return null;
}
final int actualWidth;
final int actualHeight;
final int actualSize;
final ui.ImmutableBuffer buffer;
try {
actualWidth = info['width']!;
actualHeight = info['height']!;
actualSize = actualWidth * actualHeight * 4;
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
} finally {
malloc.free(pointer);
}
if (_isCancelled) {
buffer.dispose();
return null;
}
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: actualWidth,
height: actualHeight,
pixelFormat: ui.PixelFormat.rgba8888,
);
final codec = await descriptor.instantiateCodec();
if (_isCancelled) {
buffer.dispose();
descriptor.dispose();
codec.dispose();
return null;
}
return await codec.getNextFrame();
}
}

View File

@@ -1,35 +0,0 @@
part of 'image_request.dart';
class LocalImageRequest extends ImageRequest {
final String localId;
final int width;
final int height;
final AssetType assetType;
LocalImageRequest({required this.localId, required ui.Size size, required this.assetType})
: width = size.width.toInt(),
height = size.height.toInt();
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
final Map<String, int> info = await thumbnailApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
isVideo: assetType == AssetType.video,
);
final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<void> _onCancelled() {
return thumbnailApi.cancelImageRequest(requestId);
}
}

View File

@@ -1,136 +0,0 @@
part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest');
static final client = HttpClient()..maxConnectionsPerHost = 16;
final RemoteCacheManager? cacheManager;
final String uri;
final Map<String, String> headers;
HttpClientRequest? _request;
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileImage != null) {
return cachedFileImage;
}
try {
final buffer = await _downloadImage(uri);
if (buffer == null) {
return null;
}
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
if (_isCancelled) {
return null;
}
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (cachedFileImage != null) {
return cachedFileImage;
}
rethrow;
} finally {
_request = null;
}
}
Future<ImmutableBuffer?> _downloadImage(String url) async {
if (_isCancelled) {
return null;
}
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
request.abort();
return _request = null;
}
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
final response = await request.close();
if (_isCancelled) {
return null;
}
final bytes = Uint8List(response.contentLength);
int offset = 0;
final subscription = response.listen((List<int> chunk) {
// this is important to break the response stream if the request is cancelled
if (_isCancelled) {
throw StateError('Cancelled request');
}
bytes.setAll(offset, chunk);
offset += chunk.length;
}, cancelOnError: true);
cacheManager?.putStreamedFile(url, response);
await subscription.asFuture();
return await ImmutableBuffer.fromUint8List(bytes);
}
Future<ImageInfo?> _loadCachedFile(
String url,
ImageDecoderCallback decode,
double scale, {
required bool inMemoryOnly,
}) async {
final cacheManager = this.cacheManager;
if (_isCancelled || cacheManager == null) {
return null;
}
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) {
return null;
}
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
_evictFile(url);
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager?.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
}
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
if (_isCancelled) {
buffer.dispose();
return null;
}
final codec = await decode(buffer);
if (_isCancelled) {
buffer.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
}
@override
void _onCancelled() {
_request?.abort();
_request = null;
}
}

View File

@@ -1,21 +0,0 @@
part of 'image_request.dart';
class ThumbhashImageRequest extends ImageRequest {
final String thumbhash;
ThumbhashImageRequest({required this.thumbhash});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
void _onCancelled() {}
}

View File

@@ -0,0 +1,286 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:ffi/ffi.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
abstract class ImageRequest {
static int _nextRequestId = 0;
final int requestId = _nextRequestId++;
bool _isCancelled = false;
get isCancelled => _isCancelled;
ImageRequest();
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
void cancel() {
if (isCancelled) {
return;
}
_isCancelled = true;
if (!kReleaseMode) {
debugPrint('Cancelling image request $requestId');
}
return _onCancelled();
}
void _onCancelled();
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
final address = info['pointer'];
if (address == null) {
if (!kReleaseMode) {
debugPrint('Platform image request for $requestId was cancelled');
}
return null;
}
final pointer = Pointer<Uint8>.fromAddress(address);
try {
if (_isCancelled) {
return null;
}
final actualWidth = info['width']!;
final actualHeight = info['height']!;
final actualSize = actualWidth * actualHeight * 4;
final buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
if (_isCancelled) {
return null;
}
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: actualWidth,
height: actualHeight,
pixelFormat: ui.PixelFormat.rgba8888,
);
final codec = await descriptor.instantiateCodec();
if (_isCancelled) {
return null;
}
return await codec.getNextFrame();
} finally {
malloc.free(pointer);
}
}
}
class ThumbhashImageRequest extends ImageRequest {
final String thumbhash;
ThumbhashImageRequest({required this.thumbhash});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Thumbhash request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
void _onCancelled() {}
}
class LocalImageRequest extends ImageRequest {
final String localId;
final int width;
final int height;
final AssetType assetType;
LocalImageRequest({required this.localId, required ui.Size size, required this.assetType})
: width = size.width.toInt(),
height = size.height.toInt();
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final Map<String, int> info = await thumbnailApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
isVideo: assetType == AssetType.video,
);
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Local image request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<void> _onCancelled() {
return thumbnailApi.cancelImageRequest(requestId);
}
}
class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest');
static final cacheManager = RemoteImageCacheManager();
static final client = HttpClient()..maxConnectionsPerHost = 32;
String uri;
Map<String, String> headers;
HttpClientRequest? _request;
RemoteImageRequest({required this.uri, required this.headers});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// so it just makes things slower and more memory hungry. Even just saving files to disk
// for offline use adds too much overhead as these calls add up. We only prefer fetching from it when
// it can skip the DB call.
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileImage != null) {
return cachedFileImage;
}
try {
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final buffer = await _downloadImage(uri);
if (buffer == null) {
return null;
}
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Remote image download request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
if (_isCancelled) {
if (!kReleaseMode) {
debugPrint('Remote image download request for $requestId was cancelled');
}
return null;
}
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (cachedFileImage != null) {
return cachedFileImage;
}
rethrow;
} finally {
_request = null;
}
}
Future<ImmutableBuffer?> _downloadImage(String url) async {
if (_isCancelled) {
return null;
}
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
request.abort();
return _request = null;
}
final headers = ApiService.getRequestHeaders();
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
final response = await request.close();
final bytes = await consolidateHttpClientResponseBytes(response);
if (_isCancelled) {
return null;
}
return await ImmutableBuffer.fromUint8List(bytes);
}
Future<ImageInfo?> _loadCachedFile(
String url,
ImageDecoderCallback decode,
double scale, {
required bool inMemoryOnly,
}) async {
if (_isCancelled) {
return null;
}
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) {
return null;
}
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
_evictFile(url);
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
}
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
if (_isCancelled) {
buffer.dispose();
return null;
}
final codec = await decode(buffer);
if (_isCancelled) {
buffer.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
}
@override
void _onCancelled() {
_request?.abort();
_request = null;
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -137,22 +135,4 @@ class DriftBackupRepository extends DriftDatabaseRepository {
return query.map((localAsset) => localAsset.toDto()).get(); return query.map((localAsset) => localAsset.toDto()).get();
} }
FutureOr<List<LocalAlbum>> getSourceAlbums(String localAssetId) {
final query = _db.localAlbumEntity.select()
..where(
(lae) =>
existsQuery(
_db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.albumId])
..where(
_db.localAlbumAssetEntity.albumId.equalsExp(lae.id) &
_db.localAlbumAssetEntity.assetId.equals(localAssetId),
),
) &
lae.backupSelection.equalsValue(BackupSelection.selected),
)
..orderBy([(lae) => OrderingTerm.asc(lae.name)]);
return query.map((localAlbum) => localAlbum.toDto()).get();
}
} }

View File

@@ -18,7 +18,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
@@ -59,7 +58,6 @@ class IsarDatabaseRepository implements IDatabaseRepository {
StackEntity, StackEntity,
PersonEntity, PersonEntity,
AssetFaceEntity, AssetFaceEntity,
StoreEntity,
], ],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
) )
@@ -68,7 +66,7 @@ class Drift extends $Drift implements IDatabaseRepository {
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
@override @override
int get schemaVersion => 8; int get schemaVersion => 7;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -120,9 +118,6 @@ class Drift extends $Drift implements IDatabaseRepository {
from6To7: (m, v7) async { from6To7: (m, v7) async {
await m.createIndex(v7.idxLatLng); await m.createIndex(v7.idxLatLng);
}, },
from7To8: (m, v8) async {
await m.create(v8.storeEntity);
},
), ),
); );

View File

@@ -33,11 +33,9 @@ import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i15; as i15;
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'
as i16; as i16;
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i17;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i18; as i17;
import 'package:drift/internal/modular.dart' as i19; import 'package:drift/internal/modular.dart' as i18;
abstract class $Drift extends i0.GeneratedDatabase { abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e); $Drift(i0.QueryExecutor e) : super(e);
@@ -71,10 +69,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i15.$PersonEntityTable personEntity = i15.$PersonEntityTable(this); late final i15.$PersonEntityTable personEntity = i15.$PersonEntityTable(this);
late final i16.$AssetFaceEntityTable assetFaceEntity = i16 late final i16.$AssetFaceEntityTable assetFaceEntity = i16
.$AssetFaceEntityTable(this); .$AssetFaceEntityTable(this);
late final i17.$StoreEntityTable storeEntity = i17.$StoreEntityTable(this); i17.MergedAssetDrift get mergedAssetDrift => i18.ReadDatabaseContainer(
i18.MergedAssetDrift get mergedAssetDrift => i19.ReadDatabaseContainer(
this, this,
).accessor<i18.MergedAssetDrift>(i18.MergedAssetDrift.new); ).accessor<i17.MergedAssetDrift>(i17.MergedAssetDrift.new);
@override @override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables => Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>(); allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -101,7 +98,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
memoryAssetEntity, memoryAssetEntity,
personEntity, personEntity,
assetFaceEntity, assetFaceEntity,
storeEntity,
i9.idxLatLng, i9.idxLatLng,
]; ];
@override @override
@@ -317,6 +313,4 @@ class $DriftManager {
i15.$$PersonEntityTableTableManager(_db, _db.personEntity); i15.$$PersonEntityTableTableManager(_db, _db.personEntity);
i16.$$AssetFaceEntityTableTableManager get assetFaceEntity => i16.$$AssetFaceEntityTableTableManager get assetFaceEntity =>
i16.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity); i16.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i17.$$StoreEntityTableTableManager get storeEntity =>
i17.$$StoreEntityTableTableManager(_db, _db.storeEntity);
} }

View File

@@ -3049,392 +3049,6 @@ final class Schema7 extends i0.VersionedSchema {
); );
} }
final class Schema8 extends i0.VersionedSchema {
Schema8({required super.database}) : super(version: 8);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
idxLatLng,
];
late final Shape16 userEntity = Shape16(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_84,
_column_85,
_column_5,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape6 localAlbumEntity = Shape6(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 localAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35],
attachedDatabase: database,
),
alias: null,
);
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 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)',
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
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_36, _column_60],
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_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
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_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
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_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
}
class Shape18 extends i0.VersionedTable {
Shape18({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get stringValue =>
columnsByName['string_value']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get intValue =>
columnsByName['int_value']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_87(String aliasedName) =>
i1.GeneratedColumn<int>(
'id',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<String> _column_88(String aliasedName) =>
i1.GeneratedColumn<String>(
'string_value',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<int> _column_89(String aliasedName) =>
i1.GeneratedColumn<int>(
'int_value',
aliasedName,
true,
type: i1.DriftSqlType.int,
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -3442,7 +3056,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5, required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -3476,11 +3089,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from6To7(migrator, schema); await from6To7(migrator, schema);
return 7; return 7;
case 7:
final schema = Schema8(database: database);
final migrator = i1.Migrator(database, schema);
await from7To8(migrator, schema);
return 8;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -3494,7 +3102,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5, required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -3503,6 +3110,5 @@ i1.OnUpgrade stepByStep({
from4To5: from4To5, from4To5: from4To5,
from5To6: from5To6, from5To6: from5To6,
from6To7: from6To7, from6To7: from6To7,
from7To8: from7To8,
), ),
); );

View File

@@ -8,7 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart'; import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount }
class DriftLocalAlbumRepository extends DriftDatabaseRepository { class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
@@ -40,7 +40,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum), SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum),
SortLocalAlbumsBy.name => OrderingTerm.asc(_db.localAlbumEntity.name), SortLocalAlbumsBy.name => OrderingTerm.asc(_db.localAlbumEntity.name),
SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount), SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount),
SortLocalAlbumsBy.newestAsset => OrderingTerm.desc(_db.localAlbumEntity.updatedAt),
}); });
} }
query.orderBy(orderings); query.orderBy(orderings);
@@ -56,9 +55,8 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final assetsToDelete = _platform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : await getAssetIds(albumId); final assetsToDelete = _platform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : await getAssetIds(albumId);
await _deleteAssets(assetsToDelete); await _deleteAssets(assetsToDelete);
await _db.managers.localAlbumEntity // All the other assets that are still associated will be unlinked automatically on-cascade
.filter((a) => a.id.equals(albumId) & a.backupSelection.equals(BackupSelection.none)) await _db.managers.localAlbumEntity.filter((a) => a.id.equals(albumId)).delete();
.delete();
}); });
Future<void> syncDeletes(String albumId, Iterable<String> assetIdsToKeep) async { Future<void> syncDeletes(String albumId, Iterable<String> assetIdsToKeep) async {
@@ -153,10 +151,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
await deleteSmt.go(); await deleteSmt.go();
} }
// Only remove albums that are not explicitly selected or excluded from backups await _db.localAlbumEntity.deleteWhere((f) => f.marker_.isNotNull());
await _db.localAlbumEntity.deleteWhere(
(f) => f.marker_.isNotNull() & f.backupSelection.equalsValue(BackupSelection.none),
);
}); });
} }
@@ -324,7 +319,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
]) ])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]) ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)])
..limit(1); ..limit(1);
final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();

View File

@@ -31,17 +31,11 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
useColumns: false, useColumns: false,
), ),
leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false), leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
]); ]);
query query
..where(_db.remoteAssetEntity.deletedAt.isNull()) ..where(_db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount]) ..addColumns([assetCount])
..addColumns([_db.userEntity.name]) ..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]); ..groupBy([_db.remoteAlbumEntity.id]);
if (sortBy.isNotEmpty) { if (sortBy.isNotEmpty) {
@@ -59,11 +53,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.map( .map(
(row) => row (row) => row
.readTable(_db.remoteAlbumEntity) .readTable(_db.remoteAlbumEntity)
.toDto( .toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
) )
.get(); .get();
} }
@@ -88,27 +78,17 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false, useColumns: false,
), ),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
]) ])
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull()) ..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount]) ..addColumns([assetCount])
..addColumns([_db.userEntity.name]) ..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]); ..groupBy([_db.remoteAlbumEntity.id]);
return query return query
.map( .map(
(row) => row (row) => row
.readTable(_db.remoteAlbumEntity) .readTable(_db.remoteAlbumEntity)
.toDto( .toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
) )
.getSingleOrNull(); .getSingleOrNull();
} }
@@ -274,24 +254,13 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false, useColumns: false,
), ),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
]) ])
..where(_db.remoteAlbumEntity.id.equals(albumId)) ..where(_db.remoteAlbumEntity.id.equals(albumId))
..addColumns([_db.userEntity.name]) ..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]); ..groupBy([_db.remoteAlbumEntity.id]);
return query.map((row) { return query.map((row) {
final album = row final album = row.readTable(_db.remoteAlbumEntity).toDto(ownerName: row.read(_db.userEntity.name)!);
.readTable(_db.remoteAlbumEntity)
.toDto(
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
);
return album; return album;
}).watchSingleOrNull(); }).watchSingleOrNull();
} }
@@ -324,7 +293,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
} }
extension on RemoteAlbumEntityData { extension on RemoteAlbumEntityData {
RemoteAlbum toDto({int assetCount = 0, required String ownerName, required bool isShared}) { RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
return RemoteAlbum( return RemoteAlbum(
id: id, id: id,
name: name, name: name,
@@ -337,7 +306,6 @@ extension on RemoteAlbumEntityData {
order: order, order: order,
assetCount: assetCount, assetCount: assetCount,
ownerName: ownerName, ownerName: ownerName,
isShared: isShared,
); );
} }
} }

View File

@@ -1,30 +1,16 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
// Temporary interface until Isar is removed to make the service work with both Isar and Sqlite class IsarStoreRepository extends IsarDatabaseRepository {
abstract class IStoreRepository {
Future<bool> deleteAll();
Stream<List<StoreDto<Object>>> watchAll();
Future<void> delete<T>(StoreKey<T> key);
Future<bool> upsert<T>(StoreKey<T> key, T value);
Future<T?> tryGet<T>(StoreKey<T> key);
Stream<T?> watch<T>(StoreKey<T> key);
Future<List<StoreDto<Object>>> getAll();
}
class IsarStoreRepository extends IsarDatabaseRepository implements IStoreRepository {
final Isar _db; final Isar _db;
final validStoreKeys = StoreKey.values.map((e) => e.id).toSet(); final validStoreKeys = StoreKey.values.map((e) => e.id).toSet();
IsarStoreRepository(super.db) : _db = db; IsarStoreRepository(super.db) : _db = db;
@override
Future<bool> deleteAll() async { Future<bool> deleteAll() async {
return await transaction(() async { return await transaction(() async {
await _db.storeValues.clear(); await _db.storeValues.clear();
@@ -32,29 +18,25 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi
}); });
} }
@override Stream<StoreDto<Object>> watchAll() {
Stream<List<StoreDto<Object>>> watchAll() {
return _db.storeValues return _db.storeValues
.filter() .filter()
.anyOf(validStoreKeys, (query, id) => query.idEqualTo(id)) .anyOf(validStoreKeys, (query, id) => query.idEqualTo(id))
.watch(fireImmediately: true) .watch(fireImmediately: true)
.asyncMap((entities) => Future.wait(entities.map((entity) => _toUpdateEvent(entity)))); .asyncExpand((entities) => Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))));
} }
@override
Future<void> delete<T>(StoreKey<T> key) async { Future<void> delete<T>(StoreKey<T> key) async {
return await transaction(() async => await _db.storeValues.delete(key.id)); return await transaction(() async => await _db.storeValues.delete(key.id));
} }
@override Future<bool> insert<T>(StoreKey<T> key, T value) async {
Future<bool> upsert<T>(StoreKey<T> key, T value) async {
return await transaction(() async { return await transaction(() async {
await _db.storeValues.put(await _fromValue(key, value)); await _db.storeValues.put(await _fromValue(key, value));
return true; return true;
}); });
} }
@override
Future<T?> tryGet<T>(StoreKey<T> key) async { Future<T?> tryGet<T>(StoreKey<T> key) async {
final entity = (await _db.storeValues.get(key.id)); final entity = (await _db.storeValues.get(key.id));
if (entity == null) { if (entity == null) {
@@ -63,7 +45,13 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi
return await _toValue(key, entity); return await _toValue(key, entity);
} }
@override Future<bool> update<T>(StoreKey<T> key, T value) async {
return await transaction(() async {
await _db.storeValues.put(await _fromValue(key, value));
return true;
});
}
Stream<T?> watch<T>(StoreKey<T> key) async* { Stream<T?> watch<T>(StoreKey<T> key) async* {
yield* _db.storeValues yield* _db.storeValues
.watchObject(key.id, fireImmediately: true) .watchObject(key.id, fireImmediately: true)
@@ -100,93 +88,8 @@ class IsarStoreRepository extends IsarDatabaseRepository implements IStoreReposi
return StoreValue(key.id, intValue: intValue, strValue: strValue); return StoreValue(key.id, intValue: intValue, strValue: strValue);
} }
@override
Future<List<StoreDto<Object>>> getAll() async { Future<List<StoreDto<Object>>> getAll() async {
final entities = await _db.storeValues.filter().anyOf(validStoreKeys, (query, id) => query.idEqualTo(id)).findAll(); final entities = await _db.storeValues.filter().anyOf(validStoreKeys, (query, id) => query.idEqualTo(id)).findAll();
return Future.wait(entities.map((e) => _toUpdateEvent(e)).toList()); return Future.wait(entities.map((e) => _toUpdateEvent(e)).toList());
} }
} }
class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepository {
final Drift _db;
final validStoreKeys = StoreKey.values.map((e) => e.id).toSet();
DriftStoreRepository(super.db) : _db = db;
@override
Future<bool> deleteAll() async {
await _db.storeEntity.deleteAll();
return true;
}
@override
Future<List<StoreDto<Object>>> getAll() async {
final query = _db.storeEntity.select()..where((entity) => entity.id.isIn(validStoreKeys));
return query.asyncMap((entity) => _toUpdateEvent(entity)).get();
}
@override
Stream<List<StoreDto<Object>>> watchAll() {
final query = _db.storeEntity.select()..where((entity) => entity.id.isIn(validStoreKeys));
return query.asyncMap((entity) => _toUpdateEvent(entity)).watch();
}
@override
Future<void> delete<T>(StoreKey<T> key) async {
await _db.storeEntity.deleteWhere((entity) => entity.id.equals(key.id));
return;
}
@override
Future<bool> upsert<T>(StoreKey<T> key, T value) async {
await _db.storeEntity.insertOnConflictUpdate(await _fromValue(key, value));
return true;
}
@override
Future<T?> tryGet<T>(StoreKey<T> key) async {
final entity = await _db.managers.storeEntity.filter((entity) => entity.id.equals(key.id)).getSingleOrNull();
if (entity == null) {
return null;
}
return await _toValue(key, entity);
}
@override
Stream<T?> watch<T>(StoreKey<T> key) async* {
final query = _db.storeEntity.select()..where((entity) => entity.id.equals(key.id));
yield* query.watchSingleOrNull().asyncMap((e) async => e == null ? null : await _toValue(key, e));
}
Future<StoreDto<Object>> _toUpdateEvent(StoreEntityData entity) async {
final key = StoreKey.values.firstWhere((e) => e.id == entity.id) as StoreKey<Object>;
final value = await _toValue(key, entity);
return StoreDto(key, value);
}
Future<T?> _toValue<T>(StoreKey<T> key, StoreEntityData entity) async =>
switch (key.type) {
const (int) => entity.intValue,
const (String) => entity.stringValue,
const (bool) => entity.intValue == 1,
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
const (UserDto) =>
entity.stringValue == null ? null : await DriftUserRepository(_db).get(entity.stringValue!),
_ => null,
}
as T?;
Future<StoreEntityCompanion> _fromValue<T>(StoreKey<T> key, T value) async {
final (int? intValue, String? strValue) = switch (key.type) {
const (int) => (value as int, null),
const (String) => (null, value as String),
const (bool) => ((value as bool) ? 1 : 0, null),
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
const (UserDto) => (null, (await DriftUserRepository(_db).upsert(value as UserDto)).id),
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
};
return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue));
}
}

View File

@@ -258,11 +258,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
); );
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder( TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) => filter: (row) => row.deletedAt.isNull() & row.isFavorite.equals(true) & row.ownerId.equals(userId),
row.deletedAt.isNull() &
row.isFavorite.equals(true) &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
groupBy: groupBy, groupBy: groupBy,
); );

View File

@@ -1,8 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity; import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
@@ -65,40 +63,3 @@ class IsarUserRepository extends IsarDatabaseRepository {
return true; return true;
} }
} }
class DriftUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftUserRepository(super.db) : _db = db;
Future<UserDto?> get(String id) =>
_db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull().then((user) => user?.toDto());
Future<UserDto> upsert(UserDto user) async {
await _db.userEntity.insertOnConflictUpdate(
UserEntityCompanion(
id: Value(user.id),
isAdmin: Value(user.isAdmin),
updatedAt: Value(user.updatedAt),
name: Value(user.name),
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
),
);
return user;
}
}
extension on UserEntityData {
UserDto toDto() {
return UserDto(
id: id,
email: email,
name: name,
isAdmin: isAdmin,
updatedAt: updatedAt,
profileChangedAt: profileChangedAt,
hasProfileImage: hasProfileImage,
);
}
}

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
@@ -40,21 +41,18 @@ import 'package:worker_manager/worker_manager.dart';
void main() async { void main() async {
ImmichWidgetsBinding(); ImmichWidgetsBinding();
final (isar, drift, logDb) = await Bootstrap.initDB(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(isar, drift, logDb); final logDb = DriftLogger();
await Bootstrap.initDomain(db, logDb);
await initApp(); await initApp();
// Warm-up isolate pool for worker manager // Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true); await workerManager.init(dynamicSpawning: true);
await migrateDatabaseIfNeeded(isar, drift); await migrateDatabaseIfNeeded(db);
HttpSSLOptions.apply(); HttpSSLOptions.apply();
runApp( runApp(
ProviderScope( ProviderScope(
overrides: [ overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)],
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const MainWidget(), child: const MainWidget(),
), ),
); );
@@ -96,9 +94,7 @@ Future<void> initApp() async {
// Initialize the file downloader // Initialize the file downloader
await FileDownloader().configure( await FileDownloader().configure(
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
globalConfig: (Config.holdingQueue, (6, 6, 3)),
// On Android, if files are larger than 256MB, run in foreground service
globalConfig: [(Config.holdingQueue, (6, 6, 3)), (Config.runInForegroundIfFileLargerThan, 256)],
); );
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
@@ -186,13 +182,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'), complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'),
progressBar: true, progressBar: true,
); );
FileDownloader().configureNotificationForGroup(
kBackupGroup,
running: TaskNotification('uploading_media'.tr(), '${'file_name'.tr()}: {displayName}'),
complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'),
progressBar: true,
);
} }
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async { Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {

View File

@@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -40,7 +39,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
return; return;
} }
await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id); await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
} }
@@ -249,7 +247,6 @@ class _RemainderCard extends ConsumerWidget {
title: "backup_controller_page_remainder".tr(), title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(), subtitle: "backup_controller_page_remainder_sub".tr(),
info: remainderCount.toString(), info: remainderCount.toString(),
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
); );
} }
} }

View File

@@ -1,94 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class DriftBackupAssetDetailPage extends ConsumerWidget {
const DriftBackupAssetDetailPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<LocalAsset>> result = ref.watch(driftBackupCandidateProvider);
return Scaffold(
appBar: AppBar(title: Text('backup_controller_page_remainder'.t(context: context))),
body: result.when(
data: (List<LocalAsset> candidates) {
return ListView.separated(
padding: const EdgeInsets.only(top: 16.0),
separatorBuilder: (context, index) => Divider(color: context.colorScheme.outlineVariant),
itemCount: candidates.length,
itemBuilder: (context, index) {
final asset = candidates[index];
final albumsAsyncValue = ref.watch(driftCandidateBackupAlbumInfoProvider(asset.id));
return LargeLeadingTile(
title: Text(
asset.name,
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500, fontSize: 16),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asset.createdAt.toString(),
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
),
Text(
asset.checksum ?? "N/A",
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
overflow: TextOverflow.ellipsis,
),
albumsAsyncValue.when(
data: (albums) {
if (albums.isEmpty) {
return const SizedBox.shrink();
}
return Text(
albums.map((a) => a.name).join(', '),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
overflow: TextOverflow.ellipsis,
);
},
error: (error, stackTrace) =>
Text('Error: $error', style: TextStyle(color: context.colorScheme.error)),
loading: () => const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
),
],
),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: SizedBox(
width: 64,
height: 64,
child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover),
),
),
trailing: const Padding(padding: EdgeInsets.only(right: 24, left: 8), child: Icon(Icons.image_search)),
onTap: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
},
);
},
);
},
error: (Object error, StackTrace stackTrace) {
return Center(child: Text('Error: $error'));
},
loading: () {
return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive()));
},
),
);
}
}

View File

@@ -224,7 +224,7 @@ class FileDetailDialog extends ConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: const BorderRadius.all(Radius.circular(12)),
), ),
child: asset != null child: asset != null
? Thumbnail.fromAsset(asset: asset, size: const Size(128, 128), fit: BoxFit.cover) ? Thumbnail(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
: null, : null,
), ),
), ),

View File

@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
@@ -14,8 +13,8 @@ import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@RoutePage() @RoutePage()
@@ -29,7 +28,7 @@ class ChangeExperiencePage extends ConsumerStatefulWidget {
} }
class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> { class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
AsyncValue<bool> hasMigrated = const AsyncValue.loading(); bool hasMigrated = false;
@override @override
void initState() { void initState() {
@@ -38,60 +37,46 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
} }
Future<void> _handleMigration() async { Future<void> _handleMigration() async {
try { if (widget.switchingToBeta) {
if (widget.switchingToBeta) { final assetNotifier = ref.read(assetProvider.notifier);
final assetNotifier = ref.read(assetProvider.notifier); if (assetNotifier.mounted) {
if (assetNotifier.mounted) { assetNotifier.dispose();
assetNotifier.dispose(); }
} final albumNotifier = ref.read(albumProvider.notifier);
final albumNotifier = ref.read(albumProvider.notifier); if (albumNotifier.mounted) {
if (albumNotifier.mounted) { albumNotifier.dispose();
albumNotifier.dispose();
}
// Cancel uploads
await Store.put(StoreKey.backgroundBackup, false);
ref
.read(backupProvider.notifier)
.configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {});
ref.read(backupProvider.notifier).setAutoBackup(false);
ref.read(backupProvider.notifier).cancelBackup();
ref.read(manualUploadProvider.notifier).cancelBackup();
// Start listening to new websocket events
ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents();
final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (permission.isGranted) {
await ref.read(backgroundSyncProvider).syncLocal(full: true);
await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider));
}
} else {
await ref.read(backgroundSyncProvider).cancel();
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
ref.read(websocketProvider.notifier).startListeningToOldEvents();
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
} }
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); // Cancel uploads
await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); await Store.put(StoreKey.backgroundBackup, false);
ref
.read(backupProvider.notifier)
.configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {});
ref.read(backupProvider.notifier).setAutoBackup(false);
ref.read(backupProvider.notifier).cancelBackup();
ref.read(manualUploadProvider.notifier).cancelBackup();
// Start listening to new websocket events
ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents();
if (mounted) { final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
setState(() {
HapticFeedback.heavyImpact(); if (permission.isGranted) {
hasMigrated = const AsyncValue.data(true); await ref.read(backgroundSyncProvider).syncLocal(full: true);
}); await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider));
} await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider));
} catch (e, s) {
Logger("ChangeExperiencePage").severe("Error during migration", e, s);
if (mounted) {
setState(() {
hasMigrated = AsyncValue.error(e, s);
});
} }
} else {
await ref.read(backgroundSyncProvider).cancel();
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
ref.read(websocketProvider.notifier).startListeningToOldEvents();
}
if (mounted) {
setState(() {
HapticFeedback.heavyImpact();
hasMigrated = true;
});
} }
} }
@@ -104,34 +89,44 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
children: [ children: [
AnimatedSwitcher( AnimatedSwitcher(
duration: Durations.long4, duration: Durations.long4,
child: hasMigrated.when( child: hasMigrated
data: (data) => const Icon(Icons.check_circle_rounded, color: Colors.green, size: 48.0), ? const Icon(Icons.check_circle_rounded, color: Colors.green, size: 48.0)
error: (error, stackTrace) => const Icon(Icons.error, color: Colors.red, size: 48.0), : const SizedBox(width: 50.0, height: 50.0, child: CircularProgressIndicator()),
loading: () => const SizedBox(width: 50.0, height: 50.0, child: CircularProgressIndicator()),
),
), ),
const SizedBox(height: 16.0), const SizedBox(height: 16.0),
SizedBox( Center(
width: 300.0, child: Column(
child: AnimatedSwitcher( children: [
duration: Durations.long4, SizedBox(
child: hasMigrated.when( width: 300.0,
data: (data) => Text( child: AnimatedSwitcher(
"Migration success!\nPlease close and reopen the app to apply changes", duration: Durations.long4,
style: context.textTheme.titleMedium, child: hasMigrated
textAlign: TextAlign.center, ? Text(
"Migration success!",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
)
: Text(
"Data migration in progress...\nPlease wait and don't close this page",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
), ),
error: (error, stackTrace) => Text( if (hasMigrated)
"Migration failed!\nError: $error", Padding(
style: context.textTheme.titleMedium, padding: const EdgeInsets.only(top: 16.0),
textAlign: TextAlign.center, child: ElevatedButton(
), onPressed: () {
loading: () => Text( context.replaceRoute(
"Data migration in progress...\nPlease wait and don't close this page", widget.switchingToBeta ? const TabShellRoute() : const TabControllerRoute(),
style: context.textTheme.titleMedium, );
textAlign: TextAlign.center, },
), child: const Text("Continue"),
), ),
),
],
), ),
), ),
], ],

View File

@@ -134,10 +134,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
ref.read(remoteAlbumProvider.notifier).refresh(); ref.read(remoteAlbumProvider.notifier).refresh();
} }
if (index == 3) {
ref.invalidate(localAlbumProvider);
}
ref.read(hapticFeedbackProvider.notifier).selectionClick(); ref.read(hapticFeedbackProvider.notifier).selectionClick();
router.setActiveIndex(index); router.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index]; ref.read(tabProvider.notifier).state = TabEnum.values[index];

View File

@@ -1,104 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
@RoutePage()
class DriftActivitiesPage extends HookConsumerWidget {
const DriftActivitiesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider)!;
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
final listViewScrollController = useScrollController();
void scrollToBottom() {
listViewScrollController.animateTo(
listViewScrollController.position.maxScrollExtent + 80,
duration: const Duration(milliseconds: 600),
curve: Curves.fastOutSlowIn,
);
}
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
scrollToBottom();
}
return Scaffold(
appBar: AppBar(
title: asset == null ? Text(album.name) : null,
actions: [const LikeActivityActionButton(menuItem: true)],
actionsPadding: const EdgeInsets.only(right: 8),
),
body: activities.widgetWhen(
onData: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
);
return SafeArea(
child: Stack(
children: [
ListView.builder(
controller: listViewScrollController,
itemCount: data.length + 1,
itemBuilder: (context, index) {
if (index == data.length) {
return const SizedBox(height: 80);
}
final activity = data[index];
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
return Padding(
padding: const EdgeInsets.all(5),
child: DismissibleActivity(
activity.id,
ActivityTile(activity),
onDismiss: canDelete
? (activityId) async => await activityNotifier.removeActivity(activity.id)
: null,
),
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
),
child: DriftActivityTextField(
isEnabled: album.isActivityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
),
),
],
),
);
},
),
resizeToAvoidBottomInset: true,
);
}
}

View File

@@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
final asset = selectedAssets.elementAt(index); final asset = selectedAssets.elementAt(index);
return GestureDetector( return GestureDetector(
onTap: onBackgroundTapped, onTap: onBackgroundTapped,
child: Thumbnail.fromAsset(asset: asset), child: Thumbnail(asset: asset),
); );
}, childCount: selectedAssets.length), }, childCount: selectedAssets.length),
), ),

View File

@@ -163,11 +163,7 @@ class _PlaceTile extends StatelessWidget {
title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)), title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect( leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)), borderRadius: const BorderRadius.all(Radius.circular(20)),
child: SizedBox( child: Thumbnail(size: const Size(80, 80), fit: BoxFit.cover, remoteId: place.$2),
width: 80,
height: 80,
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover),
),
), ),
); );
} }

View File

@@ -165,10 +165,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
} }
} }
Future<void> showActivity(BuildContext context) async {
context.pushRoute(const DriftActivitiesRoute());
}
void showOptionSheet(BuildContext context) { void showOptionSheet(BuildContext context) {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false; final isOwner = user != null ? user.id == _album.ownerId : false;
@@ -245,7 +241,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onShowOptions: () => showOptionSheet(context), onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(), onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context), onEditTitle: () => showEditTitleAndDescription(context),
onActivity: () => showActivity(context),
), ),
bottomSheet: RemoteAlbumBottomSheet(album: _album), bottomSheet: RemoteAlbumBottomSheet(album: _album),
), ),

View File

@@ -1,64 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class LikeActivityActionButton extends ConsumerWidget {
const LikeActivityActionButton({super.key, this.menuItem = false});
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider);
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
onTap(Activity? liked) async {
if (user == null) {
return;
}
if (liked != null) {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).removeActivity(liked.id);
} else {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).addLike();
}
ref.invalidate(albumActivityProvider(album?.id ?? "", asset?.id));
}
return activities.when(
data: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
);
return BaseActionButton(
maxWidth: 60,
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
label: "like".t(context: context),
onPressed: () => onTap(liked),
menuItem: menuItem,
);
},
// default to empty heart during loading
loading: () => BaseActionButton(
iconData: Icons.favorite_border,
label: "like".t(context: context),
menuItem: menuItem,
),
error: (error, stack) => Text("Error: $error"),
);
}
}

View File

@@ -458,7 +458,7 @@ class _AlbumList extends ConsumerWidget {
leading: album.thumbnailAssetId != null leading: album.thumbnailAssetId != null
? ClipRRect( ? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)), borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
) )
: SizedBox( : SizedBox(
width: 80, width: 80,
@@ -577,7 +577,7 @@ class _GridAlbumCard extends ConsumerWidget {
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: album.thumbnailAssetId != null child: album.thumbnailAssetId != null
? Thumbnail.remote(remoteId: album.thumbnailAssetId!) ? Thumbnail(remoteId: album.thumbnailAssetId)
: Container( : Container(
color: context.colorScheme.surfaceContainerHighest, color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey), child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),

View File

@@ -1,109 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class DriftActivityTextField extends ConsumerStatefulWidget {
final bool isEnabled;
final String? likeId;
final Function(String) onSubmit;
final Function()? onKeyboardFocus;
const DriftActivityTextField({
required this.onSubmit,
this.isEnabled = true,
this.likeId,
this.onKeyboardFocus,
super.key,
});
@override
ConsumerState<DriftActivityTextField> createState() => _DriftActivityTextFieldState();
}
class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField> {
late FocusNode inputFocusNode;
late TextEditingController inputController;
bool sendEnabled = false;
@override
void initState() {
super.initState();
inputController = TextEditingController();
inputFocusNode = FocusNode();
inputFocusNode.requestFocus();
inputFocusNode.addListener(() {
if (inputFocusNode.hasFocus) {
widget.onKeyboardFocus?.call();
}
});
inputController.addListener(() {
setState(() {
sendEnabled = inputController.text.trim().isNotEmpty;
});
});
}
@override
void dispose() {
inputController.dispose();
inputFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
// Pass text to callback and reset controller
void onEditingComplete() {
if (inputController.text.trim().isEmpty) {
return;
}
widget.onSubmit(inputController.text);
inputController.clear();
inputFocusNode.unfocus();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: TextField(
controller: inputController,
enabled: widget.isEnabled,
focusNode: inputFocusNode,
textInputAction: TextInputAction.send,
autofocus: false,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12), // Adjust as needed
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
prefixIcon: user != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(user: user, size: 30, radius: 15),
)
: null,
suffixIcon: IconButton(
onPressed: sendEnabled ? onEditingComplete : null,
icon: const Icon(Icons.send),
iconSize: 24,
color: context.primaryColor,
disabledColor: context.colorScheme.secondaryContainer,
),
hintText: !widget.isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
),
onEditingComplete: onEditingComplete,
onTapOutside: (_) => inputFocusNode.unfocus(),
),
);
}
}

View File

@@ -25,7 +25,6 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
@@ -64,7 +63,6 @@ const double _kBottomSheetMinimumExtent = 0.4;
const double _kBottomSheetSnapExtent = 0.7; const double _kBottomSheetSnapExtent = 0.7;
class _AssetViewerState extends ConsumerState<AssetViewer> { class _AssetViewerState extends ConsumerState<AssetViewer> {
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
late PageController pageController; late PageController pageController;
late DraggableScrollableController bottomSheetController; late DraggableScrollableController bottomSheetController;
PersistentBottomSheetController? sheetCloseController; PersistentBottomSheetController? sheetCloseController;
@@ -92,9 +90,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Delayed operations that should be cancelled on disposal // Delayed operations that should be cancelled on disposal
final List<Timer> _delayedOperations = []; final List<Timer> _delayedOperations = [];
ImageStream? _prevPreCacheStream;
ImageStream? _nextPreCacheStream;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -115,8 +110,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bottomSheetController.dispose(); bottomSheetController.dispose();
_cancelTimers(); _cancelTimers();
reloadSubscription?.cancel(); reloadSubscription?.cancel();
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
super.dispose(); super.dispose();
} }
@@ -134,23 +127,33 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_delayedOperations.clear(); _delayedOperations.clear();
} }
// This is used to calculate the scale of the asset when the bottom sheet is showing.
// It is a small increment to ensure that the asset is slightly zoomed in when the
// bottom sheet is showing, which emulates the zoom effect.
double get _getScaleForBottomSheet => (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + 0.01;
double _getVerticalOffsetForBottomSheet(double extent) => double _getVerticalOffsetForBottomSheet(double extent) =>
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent); (context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
ImageStream _precacheImage(BaseAsset asset) { Future<void> _precacheImage(int index) async {
final provider = getFullImageProvider(asset, size: context.sizeData); if (!mounted || index < 0 || index >= totalAssets) {
return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener);
}
void _onAssetChanged(int index) async {
// Validate index bounds and try to get asset, loading buffer if needed
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null) {
return; return;
} }
final asset = ref.read(timelineServiceProvider).getAsset(index);
final screenSize = Size(context.width, context.height);
// Precache both thumbnail and full image for smooth transitions
unawaited(
Future.wait([
precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}),
precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}),
]),
);
}
void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
// Always holds the current asset from the timeline // Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset); ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed // The currentAssetNotifier actually holds the current asset that is displayed
@@ -165,19 +168,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_cancelTimers(); _cancelTimers();
// This will trigger the pre-caching of adjacent assets ensuring // This will trigger the pre-caching of adjacent assets ensuring
// that they are ready when the user navigates to them. // that they are ready when the user navigates to them.
final timer = Timer(Durations.medium4, () async { final timer = Timer(Durations.medium4, () {
// Check if widget is still mounted before proceeding // Check if widget is still mounted before proceeding
if (!mounted) return; if (!mounted) return;
final (prevAsset, nextAsset) = await ( for (final offset in [-1, 1]) {
timelineService.getAssetAsync(index - 1), unawaited(_precacheImage(index + offset));
timelineService.getAssetAsync(index + 1), }
).wait;
if (!mounted) return;
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
_prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null;
_nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null;
}); });
_delayedOperations.add(timer); _delayedOperations.add(timer);
@@ -220,15 +217,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final verticalOffset = final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset); controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
initialScale = controller.scale;
controller.scale = (controller.scale ?? 1.0) + 0.01;
} }
} }
void _onPageChanged(int index, PhotoViewControllerBase? controller) { void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index); _onAssetChanged(index);
viewController = controller; viewController = controller;
// If the bottom sheet is showing, we need to adjust scale the asset to
// emulate the zoom effect
if (showingBottomSheet) {
initialScale = controller?.scale;
controller?.scale = _getScaleForBottomSheet;
}
} }
void _onDragStart( void _onDragStart(
@@ -411,22 +412,16 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
} }
void _onAssetReloadEvent() async { void _onAssetReloadEvent() {
final index = pageController.page?.round() ?? 0;
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) {
return;
}
final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) {
return;
}
setState(() { setState(() {
final index = pageController.page?.round() ?? 0;
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) {
return;
}
_onAssetChanged(pageController.page!.round()); _onAssetChanged(pageController.page!.round());
sheetCloseController?.close(); sheetCloseController?.close();
}); });
@@ -435,7 +430,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) { void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true); ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale; initialScale = viewController?.scale;
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01); viewController?.updateMultiple(scale: _getScaleForBottomSheet);
previousExtent = _kBottomSheetMinimumExtent; previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet( sheetCloseController = showBottomSheet(
context: ctx, context: ctx,
@@ -473,7 +468,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
return const Center(child: ImmichLoadingIndicator()); BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
return Container(
width: double.infinity,
height: double.infinity,
color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain),
);
} }
void _onScaleStateChanged(PhotoViewScaleState scaleState) { void _onScaleStateChanged(PhotoViewScaleState scaleState) {
@@ -488,34 +493,18 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx; scaffoldContext ??= ctx;
final timelineService = ref.read(timelineServiceProvider); BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final asset = timelineService.getAssetSafe(index);
// If asset is not available in buffer, return a placeholder
if (asset == null) {
return PhotoViewGalleryPageOptions.customChild(
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
child: Container(
width: ctx.width,
height: ctx.height,
color: backgroundColor,
child: const Center(child: CircularProgressIndicator()),
),
);
}
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
} }
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (displayAsset.isImage && !isPlayingMotionVideo) { if (asset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, displayAsset); return _imageBuilder(ctx, asset);
} }
return _videoBuilder(ctx, displayAsset); return _videoBuilder(ctx, asset);
} }
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
@@ -526,6 +515,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.999,
minScale: PhotoViewComputedScale.contained * 0.999,
disableScaleGestures: showingBottomSheet, disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
@@ -536,7 +527,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: size.width, width: size.width,
height: size.height, height: size.height,
color: backgroundColor, color: backgroundColor,
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), child: Thumbnail(asset: asset, fit: BoxFit.contain),
), ),
); );
} }
@@ -554,7 +545,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown, onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0, maxScale: 1.0,
minScale: PhotoViewComputedScale.contained * 0.99,
basePosition: Alignment.center, basePosition: Alignment.center,
child: SizedBox( child: SizedBox(
width: ctx.width, width: ctx.width,

View File

@@ -8,7 +8,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -32,7 +31,6 @@ class ViewerBottomBar extends ConsumerWidget {
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
if (!showControls) { if (!showControls) {
opacity = 0; opacity = 0;
@@ -42,15 +40,10 @@ class ViewerBottomBar extends ConsumerWidget {
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(), if (asset.type == AssetType.image) const EditImageActionButton(),
if (isOwner) ...[ if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
if (asset.hasRemote && isOwner && isArchived) asset.isLocalOnly
const UnArchiveActionButton(source: ActionSource.viewer) ? const DeleteLocalActionButton(source: ActionSource.viewer)
else : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
const ArchiveActionButton(source: ActionSource.viewer),
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
]; ];
return IgnorePointer( return IgnorePointer(

View File

@@ -6,6 +6,17 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -14,8 +25,6 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -35,25 +44,32 @@ class AssetDetailBottomSheet extends ConsumerWidget {
} }
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final buttonContext = ActionButtonContext( final actions = <Widget>[
asset: asset, const ShareActionButton(source: ActionSource.viewer),
isOwner: isOwner, if (asset.hasRemote) ...[
isArchived: isArchived, const ShareLinkActionButton(source: ActionSource.viewer),
isTrashEnabled: isTrashEnable, const ArchiveActionButton(source: ActionSource.viewer),
isInLockedView: isInLockedView, if (!asset.hasLocal) const DownloadActionButton(source: ActionSource.viewer),
currentAlbum: currentAlbum, isTrashEnable
source: ActionSource.viewer, ? const TrashActionButton(source: ActionSource.viewer)
); : const DeletePermanentActionButton(source: ActionSource.viewer),
const DeleteActionButton(source: ActionSource.viewer),
const MoveToLockFolderActionButton(source: ActionSource.viewer),
],
if (asset.storage == AssetState.local) ...[
const DeleteLocalActionButton(source: ActionSource.viewer),
const UploadActionButton(source: ActionSource.timeline),
],
if (currentAlbum != null) RemoveFromAlbumActionButton(albumId: currentAlbum.id, source: ActionSource.viewer),
];
final actions = ActionButtonBuilder.build(buttonContext); final lockedViewActions = <Widget>[];
return BaseBottomSheet( return BaseBottomSheet(
actions: actions, actions: isInLockedView ? lockedViewActions : actions,
slivers: const [_AssetDetailBottomSheet()], slivers: const [_AssetDetailBottomSheet()],
controller: controller, controller: controller,
initialChildSize: initialChildSize, initialChildSize: initialChildSize,

View File

@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@@ -14,9 +13,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
@@ -29,17 +28,12 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final album = ref.watch(currentRemoteAlbumProvider);
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final previousRouteName = ref.watch(previousRouteNameProvider); final previousRouteName = ref.watch(previousRouteNameProvider);
final showViewInTimelineButton = final showViewInTimelineButton = previousRouteName != TabShellRoute.name && previousRouteName != null;
previousRouteName != TabShellRoute.name &&
previousRouteName != AssetViewerRoute.name &&
previousRouteName != null;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
@@ -50,16 +44,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
} }
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final websocketConnected = ref.watch(websocketProvider.select((c) => c.isConnected));
final actions = <Widget>[ final actions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
onPressed: () {
context.navigateTo(const DriftActivitiesRoute());
},
),
if (showViewInTimelineButton) if (showViewInTimelineButton)
IconButton( IconButton(
onPressed: () async { onPressed: () async {
@@ -68,7 +56,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt)); EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
}, },
icon: const Icon(Icons.image_search), icon: const Icon(Icons.image_search),
tooltip: 'view_in_timeline'.t(context: context), tooltip: 'view_in_timeline',
), ),
if (asset.hasRemote && isOwner && !asset.isFavorite) if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
@@ -79,7 +67,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
]; ];
final lockedViewActions = <Widget>[ final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
const _KebabMenu(), const _KebabMenu(),
]; ];

View File

@@ -69,8 +69,10 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
builder: (BuildContext context, ScrollController scrollController) { builder: (BuildContext context, ScrollController scrollController) {
return Card( return Card(
color: widget.backgroundColor ?? context.colorScheme.surfaceContainer, color: widget.backgroundColor ?? context.colorScheme.surface,
elevation: 3.0, borderOnForeground: false,
clipBehavior: Clip.antiAlias,
elevation: 6.0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
margin: const EdgeInsets.symmetric(horizontal: 0), margin: const EdgeInsets.symmetric(horizontal: 0),
child: CustomScrollView( child: CustomScrollView(

View File

@@ -1,105 +1,27 @@
import 'package:async/async.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.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/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:logging/logging.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
abstract class CancellableImageProvider<T extends Object> extends ImageProvider<T> { abstract class CancellableImageProvider {
void cancel(); void cancel();
} }
mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvider<T> { mixin class CancellableImageProviderMixin implements CancellableImageProvider {
static final _log = Logger('CancellableImageProviderMixin');
bool isCancelled = false;
ImageRequest? request; ImageRequest? request;
CancelableOperation<ImageInfo?>? cachedOperation;
ImageInfo? getInitialImage(CancellableImageProvider provider) {
final completer = CancelableCompleter<ImageInfo?>(onCancel: provider.cancel);
final cachedStream = provider.resolve(const ImageConfiguration());
ImageInfo? cachedImage;
final listener = ImageStreamListener((image, synchronousCall) {
if (synchronousCall) {
cachedImage = image;
}
if (!completer.isCompleted) {
completer.complete(image);
}
}, onError: completer.completeError);
cachedStream.addListener(listener);
if (cachedImage != null) {
cachedStream.removeListener(listener);
return cachedImage;
}
completer.operation.valueOrCancellation().whenComplete(() {
cachedStream.removeListener(listener);
cachedOperation = null;
});
cachedOperation = completer.operation;
return null;
}
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
if (isCancelled) {
evict();
return;
}
this.request = request;
try {
final image = await request.load(decode);
if (image == null || isCancelled) {
evict();
return;
}
yield image;
} finally {
this.request = null;
}
}
Stream<ImageInfo> initialImageStream() async* {
final cachedOperation = this.cachedOperation;
if (cachedOperation == null) {
return;
}
try {
final cachedImage = await cachedOperation.valueOrCancellation();
if (cachedImage != null && !isCancelled) {
yield cachedImage;
}
} catch (e, stack) {
_log.severe('Error loading initial image', e, stack);
} finally {
this.cachedOperation = null;
}
}
@override @override
void cancel() { void cancel() {
isCancelled = true;
final request = this.request; final request = this.request;
if (request != null) { if (request == null) {
this.request = null; return;
request.cancel();
}
final operation = cachedOperation;
if (operation != null) {
this.cachedOperation = null;
operation.cancel();
} }
this.request = null;
return request.cancel();
} }
} }
@@ -150,3 +72,26 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
bool _shouldUseLocalAsset(BaseAsset asset) => bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
ImageInfo? getCachedImage(ImageProvider key) {
ImageInfo? thumbnail;
final ImageStreamCompleter? stream = PaintingBinding.instance.imageCache.putIfAbsent(
key,
() => throw Exception(), // don't bother loading if it isn't cached
onError: (_, __) {},
);
if (stream != null) {
void listener(ImageInfo info, bool synchronousCall) {
thumbnail = info;
}
try {
stream.addListener(ImageStreamListener(listener));
} finally {
stream.removeListener(ImageStreamListener(listener));
}
}
return thumbnail;
}

View File

@@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget {
return ClipRRect( return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)), borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Thumbnail.fromAsset(asset: data), child: Thumbnail(asset: data),
); );
}, },
error: (error, stack) { error: (error, stack) {

View File

@@ -4,13 +4,12 @@ import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider> class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with CancellableImageProviderMixin {
with CancellableImageProviderMixin<LocalThumbProvider> {
final String id; final String id;
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
@@ -24,35 +23,43 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
@override @override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( final completer = OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
], ],
onDispose: cancel,
); );
completer.addOnLastListenerRemovedCallback(cancel);
return completer;
} }
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) async* {
return loadRequest(LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType), decode); final request = this.request = LocalImageRequest(localId: key.id, size: size, assetType: key.assetType);
try {
final image = await request.load(decode);
if (image != null) {
yield image;
}
} finally {
this.request = null;
}
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalThumbProvider) { if (other is LocalThumbProvider) {
return id == other.id; return id == other.id && size == other.size;
} }
return false; return false;
} }
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode ^ size.hashCode;
} }
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider> class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with CancellableImageProviderMixin {
with CancellableImageProviderMixin<LocalFullImageProvider> {
final String id; final String id;
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
@@ -66,34 +73,35 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
@override @override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( final completer = OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), initialImage: getCachedImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
], ],
onDispose: cancel,
); );
completer.addOnLastListenerRemovedCallback(cancel);
return completer;
} }
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
evict();
return;
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final request = LocalImageRequest( final request = this.request = LocalImageRequest(
localId: key.id, localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType, assetType: key.assetType,
); );
yield* loadRequest(request, decode); try {
final image = await request.load(decode);
if (image != null) {
yield image;
}
} finally {
this.request = null;
}
} }
@override @override

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