Compare commits

...

18 Commits

Author SHA1 Message Date
midzelis a24186e883 PNPM documentation changes 2025-08-19 04:13:15 +00:00
midzelis 85c348d87c Add pnpm setup to mobile workflow for translation formatting
• Add pnpm action setup step to mobile unit tests workflow
• Required for translation file formatting and sorting operations
2025-08-19 04:01:05 +00:00
midzelis b0778dcc49 Fix typescript-sdk package issues
• Remove unknown "build" dependency that was incorrectly added to package.json
• Update pnpm-lock.yaml to reflect dependency removal
2025-08-19 04:00:39 +00:00
midzelis 7b1011c091 Remove unused Docker volumes and volume mounts
• Remove node_modules volume mounts from devcontainer configuration
• Remove unused named volumes for pnpm-store, node_modules, and cache directories
• Clean up Docker Compose configuration after removing volume isolation
2025-08-19 04:00:16 +00:00
midzelis 494384ca8a Remove Docker volume isolation for node_modules directories
• Remove volume mounts for node_modules and related directories
• Allow shared access between host and container filesystem
• Update init container to handle file ownership with conditional existence check
2025-08-19 03:59:47 +00:00
midzelis 0afe49cd8a Delete npm locks 2025-08-19 03:58:52 +00:00
midzelis 678ea38f2f Use 'server/.nvmrc' for fix-format.yml GHA 2025-08-19 03:58:52 +00:00
midzelis 23cce1ea91 Address additional review feedback for pnpm migration
• Fix node-version-file paths in GitHub workflow configurations
• Refactor .pnpmfile.cjs to use switch statement for better code organization
• Correct cache type typo in fix-format workflow
• Simplify Vite configuration by merging configs inline
• Update package description for consistency
2025-08-19 03:58:52 +00:00
midzelis 0bfc8beec1 Refine pnpm migration based on review feedback
• Replace SKIP_SHARP_FILTERING with SHARP_IGNORE_GLOBAL_LIBVIPS environment variable
• Improve Sharp package filtering to include specific Linux architectures for Docker builds
• Optimize Dockerfile dependency caching with improved layer structure
• Clean up workspace configuration and remove redundant settings
2025-08-19 03:58:21 +00:00
midzelis 0992d50699 Migrate from npm to pnpm across entire project
• Update all GitHub workflow files to use pnpm instead of npm
• Replace npm commands with pnpm equivalents in devcontainer scripts
• Remove package-lock.json files and update to use pnpm-lock.yaml
• Consolidate node version references to use server/.nvmrc
2025-08-19 03:57:34 +00:00
Arthur Normand d4f2b43f64 fix: improve duplicate utility text contrast (#21045) 2025-08-19 02:18:52 +00:00
Arthur Normand f343b0e58f fix: always show resolution in details panel (#21046)
Always show resolution in details panel
2025-08-19 02:17:45 +00:00
Aaron Tulino a8b4a5e856 fix(mobile): sort local album by most recently modified (#21038)
Sort with SQL instead

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-08-19 02:13:40 +00:00
renovate[bot] e7e030279b fix(deps): update machine-learning (#21044) 2025-08-18 21:55:49 -04:00
xCJPECKOVERx 9ff664ed36 feat(web): Add to Multiple Albums (#20072)
* Multi add to album picker:
- update modal for multi select
- Update add-to-album and add-to-album-action to work with new array return from AlbumPickerModal
- Add asset-utils.addAssetsToAlbums (incomplete)

* initial addToAlbums endpoint

* - fix endpoint
- add test

* - update return type
- make open-api

* - simplify return dto
- handle notification

* - fix returns
- clean up

* - update i18n
- format & check

* - checks

* - correct successId count
- fix assets_cannot_be_added language call

* tests

* foromat

* refactor

* - update successful add message to included total attempted

* - fix web test
- format i18n

* - fix open-api

* - fix imports to resolve checks

* - PR suggestions

* open-api

* refactor addAssetsToAlbums

* refactor it again

* - fix error returns and tests

* - swap icon for IconButton
- don't nest the buttons

* open-api

* - Cleanup multi-select button to match Thumbnail

* merge and openapi

* - remove onclick from icon element

* - fix double onClose call with keyboard shortcuts

* - spelling and formatting
- apply new api permission

* - open-api

* chore: styling

* translation

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-08-19 00:42:47 +00:00
Jason Rasmussen e00556a34a feat: get metadata about the current api key (#21027) 2025-08-18 18:15:03 -05:00
xCJPECKOVERx a313e4338e feat(web): Skip duplicates (#20880)
* - add skip button to duplicates-compare-control

* - cleanup

* - change to next/previous
- move buttons to duplicates page, intead of compareControl
- add param based control/position

* - remove index param on keep/dedupe all

* - cleanup

* - cleanup index corrections

* - add left/right arrow keyboard shortcuts for previous/next
- cleanup

* - cleanup
2025-08-18 18:11:53 -05:00
Aaron Tulino 257b0c74af fix(mobile): show most recent image in album as thumbnail (#21037)
Show most recent image in album as thumbnail
Fixes #21004
2025-08-18 18:02:18 -05:00
86 changed files with 27561 additions and 62612 deletions
+3 -2
View File
@@ -49,10 +49,11 @@ 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" \
@@ -8,21 +8,13 @@ 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
@@ -33,17 +25,10 @@ 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:
@@ -3,15 +3,20 @@
# 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" log "Immich workspace not found"jj
exit 1 exit 1
) )
while true; do while true; do
run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch run_cmd pnpm --filter immich exec 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
@@ -3,6 +3,13 @@
# 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" || (
@@ -16,7 +23,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 node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}" run_cmd pnpm --filter immich-web exec 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
-3
View File
@@ -6,9 +6,6 @@ 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"
-28
View File
@@ -1,28 +0,0 @@
{
"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"
}
}
}
}
+14 -11
View File
@@ -33,21 +33,24 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
# Setup .npmrc file to publish to npm - name: Setup pnpm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Prepare SDK - name: Setup typescript-sdk
run: npm ci --prefix ../open-api/typescript-sdk/ run: pnpm install && pnpm run build
- name: Build SDK working-directory: ./open-api/typescript-sdk
run: npm run build --prefix ../open-api/typescript-sdk/
- run: npm ci - run: pnpm install --frozen-lockfile
- run: npm run build - run: pnpm build
- run: npm publish - run: pnpm 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 }}
+9 -6
View File
@@ -55,21 +55,24 @@ jobs:
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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run npm install - name: Run install
run: npm ci run: pnpm install
- name: Check formatting - name: Check formatting
run: npm run format run: pnpm format
- name: Run build - name: Run build
run: npm run build run: pnpm 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
+2 -2
View File
@@ -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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Fix formatting - name: Fix formatting
run: make install-all && make format-all run: make install-all && make format-all
+8 -5
View File
@@ -20,18 +20,21 @@ jobs:
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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install deps - name: Install deps
run: npm ci run: pnpm install --frozen-lockfile
- name: Build - name: Build
run: npm run build run: pnpm build
- name: Publish - name: Publish
run: npm publish run: pnpm publish
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+187 -272
View File
@@ -4,13 +4,10 @@ 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
@@ -32,7 +29,6 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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:
@@ -58,11 +54,9 @@ 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
@@ -73,39 +67,33 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./server working-directory: ./server
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run package manager install
- name: Run npm install run: pnpm install
run: npm ci
- name: Run linter - name: Run linter
run: npm run lint run: pnpm lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: npm run format run: pnpm format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc - name: Run tsc
run: npm run check run: pnpm check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run small tests & coverage - name: Run small tests & coverage
run: npm test run: pnpm 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
@@ -116,43 +104,36 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./cli working-directory: ./cli
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk - name: Setup typescript-sdk
run: npm ci && npm run build run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Install deps - name: Install deps
run: npm ci run: pnpm install
- name: Run linter - name: Run linter
run: npm run lint run: pnpm lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: npm run format run: pnpm format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc - name: Run tsc
run: npm run check run: pnpm check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test run: pnpm 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
@@ -163,36 +144,31 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./cli working-directory: ./cli
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk - name: Setup typescript-sdk
run: npm ci && npm run build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Install deps - name: Install deps
run: npm ci run: pnpm install --frozen-lockfile
# Skip linter & formatter in Windows test. # Skip linter & formatter in Windows test.
- name: Run tsc - name: Run tsc
run: npm run check run: pnpm check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test run: pnpm test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
web-lint: web-lint:
name: Lint Web name: Lint Web
needs: pre-job needs: pre-job
@@ -203,39 +179,33 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./web working-directory: ./web
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Run pnpm install
- name: Run npm install run: pnpm rebuild && pnpm install --frozen-lockfile
run: npm ci
- name: Run linter - name: Run linter
run: npm run lint:p run: pnpm lint:p
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: npm run format run: pnpm format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run svelte checks - name: Run svelte checks
run: npm run check:svelte run: pnpm check:svelte
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
web-unit-tests: web-unit-tests:
name: Test Web name: Test Web
needs: pre-job needs: pre-job
@@ -246,35 +216,30 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./web working-directory: ./web
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Run npm install - name: Run npm install
run: npm ci run: pnpm install --frozen-lockfile
- name: Run tsc - name: Run tsc
run: npm run check:typescript run: pnpm check:typescript
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test run: pnpm test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
i18n-tests: i18n-tests:
name: Test i18n name: Test i18n
needs: pre-job needs: pre-job
@@ -287,27 +252,24 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies - name: Install dependencies
run: npm --prefix=web ci run: pnpm --filter=immich-web install --frozen-lockfile
- name: Format - name: Format
run: npm --prefix=web run format:i18n run: pnpm --filter=immich-web 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:
@@ -316,7 +278,6 @@ 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
@@ -327,41 +288,35 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run linter - name: Run linter
run: npm run lint run: pnpm lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter - name: Run formatter
run: npm run format run: pnpm format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc - name: Run tsc
run: npm run check run: pnpm 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
@@ -372,27 +327,24 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./server working-directory: ./server
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
- name: Run npm install run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
run: npm ci
- name: Run medium tests - name: Run medium tests
run: npm run test:medium run: pnpm 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
@@ -406,43 +358,41 @@ 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: pnpm install --frozen-lockfile && pnpm 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: npm ci && npm run build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli working-directory: ./cli
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install --frozen-lockfile
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: npm run test run: pnpm 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
@@ -456,42 +406,36 @@ 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: npm ci && npm run build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install --frozen-lockfile
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]
@@ -502,7 +446,6 @@ 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
@@ -514,21 +457,19 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - 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: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Generate translation file - name: Generate translation file
run: make translation 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
@@ -543,7 +484,6 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - 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
@@ -566,7 +506,6 @@ 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
@@ -577,27 +516,24 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./.github working-directory: ./.github
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
- name: Run npm install run: pnpm install --frozen-lockfile
run: npm ci
- name: Run formatter - name: Run formatter
run: npm run format run: pnpm format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
shellcheck: shellcheck:
name: ShellCheck name: ShellCheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -607,15 +543,11 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - 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/** **/open-api/** **/openapi** **/node_modules/**
**/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
@@ -626,23 +558,20 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies - name: Install server dependencies
run: npm --prefix=server ci run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Build the app - name: Build the app
run: npm --prefix=server run build run: pnpm --filter immich 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
@@ -651,7 +580,6 @@ 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:
@@ -660,7 +588,6 @@ 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
@@ -674,45 +601,36 @@ jobs:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: immich POSTGRES_DB: immich
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
--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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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: 'npm' cache: 'pnpm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies - name: Install server dependencies
run: npm ci run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build the app - name: Build the app
run: npm run build run: pnpm build
- name: Run existing migrations - name: Run existing migrations
run: npm run migrations:run run: pnpm migrations:run
- name: Test npm run schema:reset command works - name: Test npm run schema:reset command works
run: npm run schema:reset run: pnpm schema:reset
- name: Generate new migrations - name: Generate new migrations
continue-on-error: true continue-on-error: true
run: npm run migrations:generate src/TestMigration run: pnpm 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
@@ -728,19 +646,16 @@ 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: npm run sync:sql run: pnpm 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:
@@ -751,77 +666,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
+1
View File
@@ -5,6 +5,7 @@
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
**/.pnpm-store/**
docker/upload docker/upload
docker/library docker/library
+39
View File
@@ -0,0 +1,39 @@
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 optionalDepdencies from sharp (they will be compiled from source), except:
// include the precompiled musl version of sharp, for web/Dockerfile
// include precompiled linux-x64 version of sharp, for server/Dockerfile, stage: web-prod
// include precompiled linux-arm64 version of sharp, for server/Dockerfile, stage: web-prod
if (
dep.includes("musl") ||
dep.includes("linux-x64") ||
dep.includes("linux-arm64")
) {
continue;
}
delete pkg.optionalDependencies[dep];
}
break;
}
return pkg;
},
},
};
+2 -1
View File
@@ -56,7 +56,8 @@
"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"
+47 -27
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:
npm --prefix server run sync:sql pnpm --filter immich run sync:sql
attach-server: attach-server:
docker exec -it docker_immich-server_1 sh docker exec -it docker_immich-server_1 sh
@@ -53,31 +53,40 @@ 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-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix pnpm --filter $(call map-package,$*) audit fix
install-%: install-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i pnpm --filter $(call map-package,$*) install $(if $(FROZEN),--frozen-lockfile) $(if $(OFFLINE),--offline)
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-%
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build pnpm --filter $(call map-package,$*) run build
format-%: format-%:
npm --prefix $* run format:fix pnpm --filter $(call map-package,$*) run format:fix
lint-%: lint-%:
npm --prefix $* run lint:fix pnpm --filter $(call map-package,$*) run lint:fix
lint-web:
pnpm --filter $(call map-package,$*) run lint:p
check-%: check-%:
npm --prefix $* run check pnpm --filter $(call map-package,$*) run check
check-web: check-web:
npm --prefix web run check:typescript pnpm --filter immich-web run check:typescript
npm --prefix web run check:svelte pnpm --filter immich-web run check:svelte
test-%: test-%:
npm --prefix $* run test pnpm --filter $(call map-package,$*) run test
test-e2e: test-e2e:
docker compose -f ./e2e/docker-compose.yml build docker compose -f ./e2e/docker-compose.yml build
npm --prefix e2e run test pnpm --filter immich-e2e run test
npm --prefix e2e run test:web pnpm --filter immich-e2e run test:web
test-medium: test-medium:
docker run \ docker run \
--rm \ --rm \
@@ -87,25 +96,36 @@ 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 "npm ci && npm run test:medium -- --run" -c "pnpm test:medium -- --run"
test-medium-dev: test-medium-dev:
docker exec -it immich_server /bin/sh -c "npm run test:medium" docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ; install-all:
install-all: $(foreach M,$(MODULES),install-$M) ; pnpm -r --filter '!documentation' install
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ; build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ; check-all:
audit-all: $(foreach M,$(MODULES),audit-$M) ; pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/"
hygiene-all: lint-all format-all check-all sql audit-all; lint-all:
test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ; pnpm -r --filter '!documentation' run lint:fix
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
+8 -13
View File
@@ -1,19 +1,14 @@
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/package.json cli/package-lock.json ./ COPY ./cli ./cli/
RUN npm ci COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/
RUN corepack enable pnpm && \
COPY cli . pnpm install --filter @immich/sdk --filter @immich/cli --frozen-lockfile && \
RUN npm run build pnpm --filter @immich/sdk build && \
pnpm --filter @immich/cli build
WORKDIR /import WORKDIR /import
ENTRYPOINT ["node", "/usr/src/app/dist"] ENTRYPOINT ["node", "/usr/src/app/cli/dist"]
+8 -4
View File
@@ -6,8 +6,10 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder: Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
$ npm install # if you don't have pnpm installed
$ npm run build $ npm install -g pnpm
$ pnpm install
$ pnpm build
Then, to build the open-api client run the following in the open-api folder: Then, to build the open-api client run the following in the open-api folder:
@@ -15,8 +17,10 @@ Then, to build the open-api client run the following in the open-api folder:
To run the Immich CLI from source, run the following in the cli folder: To run the Immich CLI from source, run the following in the cli folder:
$ npm install # if you don't have pnpm installed
$ npm run build $ npm install -g pnpm
$ pnpm install
$ pnpm build
$ ts-node . $ ts-node .
You'll need ts-node, the easiest way to install it is to use npm: You'll need ts-node, the easiest way to install it is to use npm:
-4600
View File
File diff suppressed because it is too large Load Diff
+22 -11
View File
@@ -21,17 +21,16 @@ 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:
- ../server:/usr/src/app/server - ..:/usr/src/app
- ../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
@@ -58,8 +57,12 @@ services:
- 9231:9231 - 9231:9231
- 2283:2283 - 2283:2283
depends_on: depends_on:
- redis redis:
- database condition: service_started
database:
condition: service_started
init:
condition: service_completed_successfully
healthcheck: healthcheck:
disable: false disable: false
@@ -68,6 +71,7 @@ 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: web/Dockerfile dockerfile: web/Dockerfile
@@ -78,18 +82,17 @@ services:
- 3000:3000 - 3000:3000
- 24678:24678 - 24678:24678
volumes: volumes:
- ../web:/usr/src/app/web - ..:/usr/src/app
- ../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
@@ -157,6 +160,14 @@ 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:
+5
View File
@@ -1,2 +1,7 @@
build/ build/
.docusaurus/ .docusaurus/
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+1 -1
View File
@@ -5,7 +5,7 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
### Installation ### Installation
``` ```
$ npm install $ pnpm install
``` ```
### Local Development ### Local Development
+1 -1
View File
@@ -204,7 +204,7 @@ 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: `npm install` in all packages - Installs dependencies: `pnpm install` in all packages
- Builds TypeScript SDK: `npm 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:
+1 -1
View File
@@ -56,7 +56,7 @@ If you only want to do web development connected to an existing, remote backend,
1. Build the Immich SDK - `cd open-api/typescript-sdk && npm i && npm 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 - `npm i` 3. Install web dependencies - `pnpm i`
4. Start the web development server 4. Start the web development server
```bash ```bash
+1 -1
View File
@@ -5,7 +5,7 @@
### Unit tests ### Unit tests
Unit are run by calling `npm run test` from the `server/` directory. Unit are run by calling `npm run test` from the `server/` directory.
You need to run `npm install` (in `server/`) before _once_. You need to run `pnpm install` (in `server/`) before _once_.
### End to end tests ### End to end tests
-20545
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,3 +3,4 @@ node_modules/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/dist
-7419
View File
File diff suppressed because it is too large Load Diff
+1 -1
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('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise; executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).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 = ["'", '"', ',', '{', '}', '*'];
+8
View File
@@ -28,6 +28,9 @@
"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",
@@ -497,7 +500,9 @@
"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} 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",
@@ -514,6 +519,7 @@
"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",
@@ -1056,6 +1062,7 @@
"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",
@@ -1177,6 +1184,7 @@
"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",
+4 -4
View File
@@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:85c4ac66dea23fbd1beb5c48957c2589d104002f8b11c90a186be421117da5e0 AS builder-cpu FROM python:3.11-bookworm@sha256:c642d5dfaf9115a12086785f23008558ae2e13bcd0c4794536340bcb777a4381 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:cda9608307dbbfc1769f3b6b1f9abf5f1360de0be720f544d29a7ae2863c47ef /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest@sha256:f64ad69940b634e75d2e4d799eb5238066c5eeda49f76e782d4873c3d014ea33 /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:01f98e2d213e1cda58a21dabfd107c4a71c99caa0c932c593acfce05315b7251 AS prod-cpu FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 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:01f98e2d213e1cda58a21dabfd107c4a71c99caa0c932c593acfce05315b7251 AS prod-openvino FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 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 && \
+80 -80
View File
@@ -1341,7 +1341,7 @@ wheels = [
[[package]] [[package]]
name = "locust" name = "locust"
version = "2.38.0" version = "2.38.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "configargparse" }, { name = "configargparse" },
@@ -1361,9 +1361,9 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fb/93/ecd79dde28e24bdc99488d4e2c0ad4117252257d5cbdd61e3b14d1f03786/locust-2.38.0.tar.gz", hash = "sha256:5bd6c29d8423733cb5d9a265548c9fef7b731f2254aa91885d6c98d0d39f90f0", size = 1406518, upload-time = "2025-08-07T10:18:52.584Z" } sdist = { url = "https://files.pythonhosted.org/packages/be/03/2f92b75d971e6043cca6fcec59ceccfa800a1324425a74950603d8cac33a/locust-2.38.1.tar.gz", hash = "sha256:4ad9f2f9e7d56b7747ba67cb16e47ca0466b3908f402f50660f15f37621a5218", size = 1406572, upload-time = "2025-08-12T11:38:52.007Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/be/57ca67b95c45e69c173e86fe5c934d789effc2ec203d3e3ec2a0b32aa707/locust-2.38.0-py3-none-any.whl", hash = "sha256:b92c937e8659e9ffd6d6d1cab2f63f70aa98c87975911938d1f473534f46fd78", size = 1424083, upload-time = "2025-08-07T10:18:50.499Z" }, { url = "https://files.pythonhosted.org/packages/dd/f6/4a8087f44abd67bb8cc51fba52dcfdddc09d69c154819d56b7da4c79f9ad/locust-2.38.1-py3-none-any.whl", hash = "sha256:34978219ee0d682a135fd4c67f287c26725e7b3fa83d34d65be70efdb42ab4d1", size = 1424130, upload-time = "2025-08-12T11:38:49.707Z" },
] ]
[[package]] [[package]]
@@ -1838,79 +1838,79 @@ wheels = [
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.11.1" version = "3.11.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/3b/fd9ff8ff64ae3900f11554d5cfc835fb73e501e043c420ad32ec574fe27f/orjson-3.11.1.tar.gz", hash = "sha256:48d82770a5fd88778063604c566f9c7c71820270c9cc9338d25147cbf34afd96", size = 5393373, upload-time = "2025-07-25T14:33:52.898Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/1d/5e0ae38788bdf0721326695e65fdf41405ed535f633eb0df0f06f57552fa/orjson-3.11.2.tar.gz", hash = "sha256:91bdcf5e69a8fd8e8bdb3de32b31ff01d2bd60c1e8d5fe7d5afabdcf19920309", size = 5470739, upload-time = "2025-08-12T15:12:28.626Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/8b/7dd88f416e2e5834fd9809d871f471aae7d12dfd83d4786166fa5a926601/orjson-3.11.1-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:92d771c492b64119456afb50f2dff3e03a2db8b5af0eba32c5932d306f970532", size = 241312, upload-time = "2025-07-25T14:31:52.841Z" }, { url = "https://files.pythonhosted.org/packages/a1/7b/7aebe925c6b1c46c8606a960fe1d6b681fccd4aaf3f37cd647c3309d6582/orjson-3.11.2-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d6b8a78c33496230a60dc9487118c284c15ebdf6724386057239641e1eb69761", size = 226896, upload-time = "2025-08-12T15:10:22.02Z" },
{ url = "https://files.pythonhosted.org/packages/f3/5d/5bfc371bd010ffbec90e64338aa59abcb13ed94191112199048653ee2f34/orjson-3.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0085ef83a4141c2ed23bfec5fecbfdb1e95dd42fc8e8c76057bdeeec1608ea65", size = 132791, upload-time = "2025-07-25T14:31:55.547Z" }, { url = "https://files.pythonhosted.org/packages/7d/39/c952c9b0d51063e808117dd1e53668a2e4325cc63cfe7df453d853ee8680/orjson-3.11.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc04036eeae11ad4180d1f7b5faddb5dab1dee49ecd147cd431523869514873b", size = 111845, upload-time = "2025-08-12T15:10:24.963Z" },
{ url = "https://files.pythonhosted.org/packages/48/e2/c07854a6bad71e4249345efadb686c0aff250073bdab8ba9be7626af6516/orjson-3.11.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5caf7f13f2e1b4e137060aed892d4541d07dabc3f29e6d891e2383c7ed483440", size = 128690, upload-time = "2025-07-25T14:31:56.708Z" }, { url = "https://files.pythonhosted.org/packages/f5/dc/90b7f29be38745eeacc30903b693f29fcc1097db0c2a19a71ffb3e9f2a5f/orjson-3.11.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c04325839c5754c253ff301cee8aaed7442d974860a44447bb3be785c411c27", size = 116395, upload-time = "2025-08-12T15:10:26.314Z" },
{ url = "https://files.pythonhosted.org/packages/48/e4/2e075348e7772aa1404d51d8df25ff4d6ee3daf682732cb21308e3b59c32/orjson-3.11.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f716bcc166524eddfcf9f13f8209ac19a7f27b05cf591e883419079d98c8c99d", size = 130646, upload-time = "2025-07-25T14:31:58.165Z" }, { url = "https://files.pythonhosted.org/packages/10/c2/fe84ba63164c22932b8d59b8810e2e58590105293a259e6dd1bfaf3422c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32769e04cd7fdc4a59854376211145a1bbbc0aea5e9d6c9755d3d3c301d7c0df", size = 118768, upload-time = "2025-08-12T15:10:27.605Z" },
{ url = "https://files.pythonhosted.org/packages/97/09/50daacd3ac7ae564186924c8d1121940f2c78c64d6804dbe81dd735ab087/orjson-3.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:507d6012fab05465d8bf21f5d7f4635ba4b6d60132874e349beff12fb51af7fe", size = 132620, upload-time = "2025-07-25T14:31:59.226Z" }, { url = "https://files.pythonhosted.org/packages/a9/ce/d9748ec69b1a4c29b8e2bab8233e8c41c583c69f515b373f1fb00247d8c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff285d14917ea1408a821786e3677c5261fa6095277410409c694b8e7720ae0", size = 120887, upload-time = "2025-08-12T15:10:29.153Z" },
{ url = "https://files.pythonhosted.org/packages/da/21/5f22093fa90e6d6fcf8111942b530a4ad19ee1cc0b06ddad4a63b16ab852/orjson-3.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1545083b0931f754c80fd2422a73d83bea7a6d1b6de104a5f2c8dd3d64c291e", size = 135121, upload-time = "2025-07-25T14:32:00.653Z" }, { url = "https://files.pythonhosted.org/packages/c1/66/b90fac8e4a76e83f981912d7f9524d402b31f6c1b8bff3e498aa321c326c/orjson-3.11.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2662f908114864b63ff75ffe6ffacf996418dd6cc25e02a72ad4bda81b1ec45a", size = 123650, upload-time = "2025-08-12T15:10:30.602Z" },
{ url = "https://files.pythonhosted.org/packages/48/90/77ad4bfa6bd400a3d241695e3e39975e32fe027aea5cb0b171bd2080c427/orjson-3.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e217ce3bad76351e1eb29ebe5ca630326f45cd2141f62620107a229909501a3", size = 131131, upload-time = "2025-07-25T14:32:01.821Z" }, { url = "https://files.pythonhosted.org/packages/33/81/56143898d1689c7f915ac67703efb97e8f2f8d5805ce8c2c3fd0f2bb6e3d/orjson-3.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab463cf5d08ad6623a4dac1badd20e88a5eb4b840050c4812c782e3149fe2334", size = 121287, upload-time = "2025-08-12T15:10:31.868Z" },
{ url = "https://files.pythonhosted.org/packages/5a/64/d383675229f7ffd971b6ec6cdd3016b00877bb6b2d5fc1fd099c2ec2ad57/orjson-3.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06ef26e009304bda4df42e4afe518994cde6f89b4b04c0ff24021064f83f4fbb", size = 131025, upload-time = "2025-07-25T14:32:02.879Z" }, { url = "https://files.pythonhosted.org/packages/80/de/f9c6d00c127be766a3739d0d85b52a7c941e437d8dd4d573e03e98d0f89c/orjson-3.11.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:64414241bde943cbf3c00d45fcb5223dca6d9210148ba984aae6b5d63294502b", size = 119637, upload-time = "2025-08-12T15:10:33.078Z" },
{ url = "https://files.pythonhosted.org/packages/d4/82/e4017d8d98597f6056afaf75021ff390154d1e2722c66ba45a4d50f82606/orjson-3.11.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ba49683b87bea3ae1489a88e766e767d4f423a669a61270b6d6a7ead1c33bd65", size = 404464, upload-time = "2025-07-25T14:32:04.384Z" }, { url = "https://files.pythonhosted.org/packages/67/4c/ab70c7627022d395c1b4eb5badf6196b7144e82b46a3a17ed2354f9e592d/orjson-3.11.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:7773e71c0ae8c9660192ff144a3d69df89725325e3d0b6a6bb2c50e5ebaf9b84", size = 392478, upload-time = "2025-08-12T15:10:34.669Z" },
{ url = "https://files.pythonhosted.org/packages/77/7e/45c7f813c30d386c0168a32ce703494262458af6b222a3eeac1c0bb88822/orjson-3.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5072488fcc5cbcda2ece966d248e43ea1d222e19dd4c56d3f82747777f24d864", size = 146416, upload-time = "2025-07-25T14:32:05.57Z" }, { url = "https://files.pythonhosted.org/packages/77/91/d890b873b69311db4fae2624c5603c437df9c857fb061e97706dac550a77/orjson-3.11.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:652ca14e283b13ece35bf3a86503c25592f294dbcfc5bb91b20a9c9a62a3d4be", size = 134343, upload-time = "2025-08-12T15:10:35.978Z" },
{ url = "https://files.pythonhosted.org/packages/41/71/6ccb4d7875ec3349409960769a28349f477856f05de9fd961454c2b99230/orjson-3.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f58ae2bcd119226fe4aa934b5880fe57b8e97b69e51d5d91c88a89477a307016", size = 135497, upload-time = "2025-07-25T14:32:06.704Z" }, { url = "https://files.pythonhosted.org/packages/47/16/1aa248541b4830274a079c4aeb2aa5d1ff17c3f013b1d0d8d16d0848f3de/orjson-3.11.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:26e99e98df8990ecfe3772bbdd7361f602149715c2cbc82e61af89bfad9528a4", size = 123887, upload-time = "2025-08-12T15:10:37.601Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ce/df8dac7da075962fdbfca55d53e3601aa910c9f23606033bf0f084835720/orjson-3.11.1-cp310-cp310-win32.whl", hash = "sha256:6723be919c07906781b9c63cc52dc7d2fb101336c99dd7e85d3531d73fb493f7", size = 136807, upload-time = "2025-07-25T14:32:08.303Z" }, { url = "https://files.pythonhosted.org/packages/95/e4/7419833c55ac8b5f385d00c02685a260da1f391e900fc5c3e0b797e0d506/orjson-3.11.2-cp310-cp310-win32.whl", hash = "sha256:5814313b3e75a2be7fe6c7958201c16c4560e21a813dbad25920752cecd6ad66", size = 124560, upload-time = "2025-08-12T15:10:38.966Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/f6c2be24709d1742d878b4530fa0c3f4a5e190d51397b680abbf44d11dbf/orjson-3.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:5fd44d69ddfdfb4e8d0d83f09d27a4db34930fba153fbf79f8d4ae8b47914e04", size = 131561, upload-time = "2025-07-25T14:32:09.444Z" }, { url = "https://files.pythonhosted.org/packages/74/f8/27ca7ef3e194c462af32ce1883187f5ec483650c559166f0de59c4c2c5f0/orjson-3.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc471ce2225ab4c42ca672f70600d46a8b8e28e8d4e536088c1ccdb1d22b35ce", size = 119700, upload-time = "2025-08-12T15:10:40.911Z" },
{ url = "https://files.pythonhosted.org/packages/a5/92/7ab270b5b3df8d5b0d3e572ddf2f03c9f6a79726338badf1ec8594e1469d/orjson-3.11.1-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:15e2a57ce3b57c1a36acffcc02e823afefceee0a532180c2568c62213c98e3ef", size = 240918, upload-time = "2025-07-25T14:32:11.021Z" }, { url = "https://files.pythonhosted.org/packages/78/7d/e295df1ac9920cbb19fb4c1afa800e86f175cb657143aa422337270a4782/orjson-3.11.2-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:888b64ef7eaeeff63f773881929434a5834a6a140a63ad45183d59287f07fc6a", size = 226502, upload-time = "2025-08-12T15:10:42.284Z" },
{ url = "https://files.pythonhosted.org/packages/80/41/df44684cfbd2e2e03bf9b09fdb14b7abcfff267998790b6acfb69ad435f0/orjson-3.11.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:17040a83ecaa130474af05bbb59a13cfeb2157d76385556041f945da936b1afd", size = 129386, upload-time = "2025-07-25T14:32:12.361Z" }, { url = "https://files.pythonhosted.org/packages/65/21/ffb0f10ea04caf418fb4e7ad1fda4b9ab3179df9d7a33b69420f191aadd5/orjson-3.11.2-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:83387cc8b26c9fa0ae34d1ea8861a7ae6cff8fb3e346ab53e987d085315a728e", size = 115999, upload-time = "2025-08-12T15:10:43.738Z" },
{ url = "https://files.pythonhosted.org/packages/c1/08/958f56edd18ba1827ad0c74b2b41a7ae0864718adee8ccb5d1a5528f8761/orjson-3.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a68f23f09e5626cc0867a96cf618f68b91acb4753d33a80bf16111fd7f9928c", size = 132508, upload-time = "2025-07-25T14:32:13.917Z" }, { url = "https://files.pythonhosted.org/packages/90/d5/8da1e252ac3353d92e6f754ee0c85027c8a2cda90b6899da2be0df3ef83d/orjson-3.11.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7e35f003692c216d7ee901b6b916b5734d6fc4180fcaa44c52081f974c08e17", size = 111563, upload-time = "2025-08-12T15:10:45.301Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b6/5e56e189dacbf51e53ba8150c20e61ee746f6d57b697f5c52315ffc88a83/orjson-3.11.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47e07528bb6ccbd6e32a55e330979048b59bfc5518b47c89bc7ab9e3de15174a", size = 128501, upload-time = "2025-07-25T14:32:15.13Z" }, { url = "https://files.pythonhosted.org/packages/4f/81/baabc32e52c570b0e4e1044b1bd2ccbec965e0de3ba2c13082255efa2006/orjson-3.11.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4a0a4c29ae90b11d0c00bcc31533854d89f77bde2649ec602f512a7e16e00640", size = 116222, upload-time = "2025-08-12T15:10:46.92Z" },
{ url = "https://files.pythonhosted.org/packages/fe/de/f6c301a514f5934405fd4b8f3d3efc758c911d06c3de3f4be1e30d675fa4/orjson-3.11.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3807cce72bf40a9d251d689cbec28d2efd27e0f6673709f948f971afd52cb09", size = 130465, upload-time = "2025-07-25T14:32:17.355Z" }, { url = "https://files.pythonhosted.org/packages/8d/b7/da2ad55ad80b49b560dce894c961477d0e76811ee6e614b301de9f2f8728/orjson-3.11.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:585d712b1880f68370108bc5534a257b561672d1592fae54938738fe7f6f1e33", size = 118594, upload-time = "2025-08-12T15:10:48.488Z" },
{ url = "https://files.pythonhosted.org/packages/47/08/f7dbaab87d6f05eebff2d7b8e6a8ed5f13b2fe3e3ae49472b527d03dbd7a/orjson-3.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b2dc7e88da4ca201c940f5e6127998d9e89aa64264292334dad62854bc7fc27", size = 132416, upload-time = "2025-07-25T14:32:18.933Z" }, { url = "https://files.pythonhosted.org/packages/61/be/014f7eab51449f3c894aa9bbda2707b5340c85650cb7d0db4ec9ae280501/orjson-3.11.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d08e342a7143f8a7c11f1c4033efe81acbd3c98c68ba1b26b96080396019701f", size = 120700, upload-time = "2025-08-12T15:10:49.811Z" },
{ url = "https://files.pythonhosted.org/packages/43/3f/dd5a185273b7ba6aa238cfc67bf9edaa1885ae51ce942bc1a71d0f99f574/orjson-3.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3091dad33ac9e67c0a550cfff8ad5be156e2614d6f5d2a9247df0627751a1495", size = 134924, upload-time = "2025-07-25T14:32:20.134Z" }, { url = "https://files.pythonhosted.org/packages/cf/ae/c217903a30c51341868e2d8c318c59a8413baa35af54d7845071c8ccd6fe/orjson-3.11.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c0f84fc50398773a702732c87cd622737bf11c0721e6db3041ac7802a686fb", size = 123433, upload-time = "2025-08-12T15:10:51.06Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/729d23510eaa81f0ce9d938d99d72dcf5e4ed3609d9d0bcf9c8a282cc41a/orjson-3.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ed0fce2307843b79a0c83de49f65b86197f1e2310de07af9db2a1a77a61ce4c", size = 130938, upload-time = "2025-07-25T14:32:21.769Z" }, { url = "https://files.pythonhosted.org/packages/57/c2/b3c346f78b1ff2da310dd300cb0f5d32167f872b4d3bb1ad122c889d97b0/orjson-3.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:140f84e3c8d4c142575898c91e3981000afebf0333df753a90b3435d349a5fe5", size = 121061, upload-time = "2025-08-12T15:10:52.381Z" },
{ url = "https://files.pythonhosted.org/packages/82/96/120feb6807f9e1f4c68fc842a0f227db8575eafb1a41b2537567b91c19d8/orjson-3.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a31e84782a18c30abd56774c0cfa7b9884589f4d37d9acabfa0504dad59bb9d", size = 130811, upload-time = "2025-07-25T14:32:22.931Z" }, { url = "https://files.pythonhosted.org/packages/00/c8/c97798f6010327ffc75ad21dd6bca11ea2067d1910777e798c2849f1c68f/orjson-3.11.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96304a2b7235e0f3f2d9363ddccdbfb027d27338722fe469fe656832a017602e", size = 119410, upload-time = "2025-08-12T15:10:53.692Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/4695e946a453fa22ff945da4b1ed0691b3f4ec86b828d398288db4a0ff79/orjson-3.11.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26b6c821abf1ae515fbb8e140a2406c9f9004f3e52acb780b3dee9bfffddbd84", size = 404272, upload-time = "2025-07-25T14:32:25.238Z" }, { url = "https://files.pythonhosted.org/packages/37/fd/df720f7c0e35694617b7f95598b11a2cb0374661d8389703bea17217da53/orjson-3.11.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d7612bb227d5d9582f1f50a60bd55c64618fc22c4a32825d233a4f2771a428a", size = 392294, upload-time = "2025-08-12T15:10:55.079Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7b/1c953e2c9e55af126c6cb678a30796deb46d7713abdeb706b8765929464c/orjson-3.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f857b3d134b36a8436f1e24dcb525b6b945108b30746c1b0b556200b5cb76d39", size = 146196, upload-time = "2025-07-25T14:32:26.909Z" }, { url = "https://files.pythonhosted.org/packages/ba/52/0120d18f60ab0fe47531d520372b528a45c9a25dcab500f450374421881c/orjson-3.11.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a134587d18fe493befc2defffef2a8d27cfcada5696cb7234de54a21903ae89a", size = 134134, upload-time = "2025-08-12T15:10:56.568Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c2/bef5d3bc83f2e178592ff317e2cf7bd38ebc16b641f076ea49f27aadd1d3/orjson-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df146f2a14116ce80f7da669785fcb411406d8e80136558b0ecda4c924b9ac55", size = 135336, upload-time = "2025-07-25T14:32:28.22Z" }, { url = "https://files.pythonhosted.org/packages/ec/10/1f967671966598366de42f07e92b0fc694ffc66eafa4b74131aeca84915f/orjson-3.11.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0b84455e60c4bc12c1e4cbaa5cfc1acdc7775a9da9cec040e17232f4b05458bd", size = 123745, upload-time = "2025-08-12T15:10:57.907Z" },
{ url = "https://files.pythonhosted.org/packages/92/95/bc6006881ebdb4608ed900a763c3e3c6be0d24c3aadd62beb774f9464ec6/orjson-3.11.1-cp311-cp311-win32.whl", hash = "sha256:d777c57c1f86855fe5492b973f1012be776e0398571f7cc3970e9a58ecf4dc17", size = 136665, upload-time = "2025-07-25T14:32:29.976Z" }, { url = "https://files.pythonhosted.org/packages/43/eb/76081238671461cfd0f47e0c24f408ffa66184237d56ef18c33e86abb612/orjson-3.11.2-cp311-cp311-win32.whl", hash = "sha256:f0660efeac223f0731a70884e6914a5f04d613b5ae500744c43f7bf7b78f00f9", size = 124393, upload-time = "2025-08-12T15:10:59.267Z" },
{ url = "https://files.pythonhosted.org/packages/59/c3/1f2b9cc0c60ea2473d386fed2df2b25ece50aeb73c798d4669aadff3061e/orjson-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:e9a5fd589951f02ec2fcb8d69339258bbf74b41b104c556e6d4420ea5e059313", size = 131388, upload-time = "2025-07-25T14:32:31.595Z" }, { url = "https://files.pythonhosted.org/packages/26/76/cc598c1811ba9ba935171267b02e377fc9177489efce525d478a2999d9cc/orjson-3.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:955811c8405251d9e09cbe8606ad8fdef49a451bcf5520095a5ed38c669223d8", size = 119561, upload-time = "2025-08-12T15:11:00.559Z" },
{ url = "https://files.pythonhosted.org/packages/b0/e5/40c97e5a6b85944022fe54b463470045b8651b7bb2f1e16a95c42812bf97/orjson-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:4cddbe41ee04fddad35d75b9cf3e3736ad0b80588280766156b94783167777af", size = 126786, upload-time = "2025-07-25T14:32:32.787Z" }, { url = "https://files.pythonhosted.org/packages/d8/17/c48011750f0489006f7617b0a3cebc8230f36d11a34e7e9aca2085f07792/orjson-3.11.2-cp311-cp311-win_arm64.whl", hash = "sha256:2e4d423a6f838552e3a6d9ec734b729f61f88b1124fd697eab82805ea1a2a97d", size = 114186, upload-time = "2025-08-12T15:11:01.931Z" },
{ url = "https://files.pythonhosted.org/packages/98/77/e55513826b712807caadb2b733eee192c1df105c6bbf0d965c253b72f124/orjson-3.11.1-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2b7c8be96db3a977367250c6367793a3c5851a6ca4263f92f0b48d00702f9910", size = 240955, upload-time = "2025-07-25T14:32:34.056Z" }, { url = "https://files.pythonhosted.org/packages/40/02/46054ebe7996a8adee9640dcad7d39d76c2000dc0377efa38e55dc5cbf78/orjson-3.11.2-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:901d80d349d8452162b3aa1afb82cec5bee79a10550660bc21311cc61a4c5486", size = 226528, upload-time = "2025-08-12T15:11:03.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/88/a78132dddcc9c3b80a9fa050b3516bb2c996a9d78ca6fb47c8da2a80a696/orjson-3.11.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:72e18088f567bd4a45db5e3196677d9ed1605e356e500c8e32dd6e303167a13d", size = 129294, upload-time = "2025-07-25T14:32:35.323Z" }, { url = "https://files.pythonhosted.org/packages/e2/c6/6b6f0b4d8aea1137436546b990f71be2cd8bd870aa2f5aa14dba0fcc95dc/orjson-3.11.2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:cf3bd3967a360e87ee14ed82cb258b7f18c710dacf3822fb0042a14313a673a1", size = 115931, upload-time = "2025-08-12T15:11:04.759Z" },
{ url = "https://files.pythonhosted.org/packages/09/02/6591e0dcb2af6bceea96cb1b5f4b48c1445492a3ef2891ac4aa306bb6f73/orjson-3.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d346e2ae1ce17888f7040b65a5a4a0c9734cb20ffbd228728661e020b4c8b3a5", size = 132310, upload-time = "2025-07-25T14:32:36.53Z" }, { url = "https://files.pythonhosted.org/packages/ae/05/4205cc97c30e82a293dd0d149b1a89b138ebe76afeca66fc129fa2aa4e6a/orjson-3.11.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26693dde66910078229a943e80eeb99fdce6cd2c26277dc80ead9f3ab97d2131", size = 111382, upload-time = "2025-08-12T15:11:06.468Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/c1cfbc617bcfa4835db275d5e0fe9bbdbe561a4b53d3b2de16540ec29c50/orjson-3.11.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4bda5426ebb02ceb806a7d7ec9ba9ee5e0c93fca62375151a7b1c00bc634d06b", size = 128529, upload-time = "2025-07-25T14:32:37.817Z" }, { url = "https://files.pythonhosted.org/packages/50/c7/b8a951a93caa821f9272a7c917115d825ae2e4e8768f5ddf37968ec9de01/orjson-3.11.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad4c8acb50a28211c33fc7ef85ddf5cb18d4636a5205fd3fa2dce0411a0e30c", size = 116271, upload-time = "2025-08-12T15:11:07.845Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bd/91a156c5df3aaf1d68b2ab5be06f1969955a8d3e328d7794f4338ac1d017/orjson-3.11.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10506cebe908542c4f024861102673db534fd2e03eb9b95b30d94438fa220abf", size = 130925, upload-time = "2025-07-25T14:32:39.03Z" }, { url = "https://files.pythonhosted.org/packages/17/03/1006c7f8782d5327439e26d9b0ec66500ea7b679d4bbb6b891d2834ab3ee/orjson-3.11.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:994181e7f1725bb5f2d481d7d228738e0743b16bf319ca85c29369c65913df14", size = 119086, upload-time = "2025-08-12T15:11:09.329Z" },
{ url = "https://files.pythonhosted.org/packages/a3/4c/a65cc24e9a5f87c9833a50161ab97b5edbec98bec99dfbba13827549debc/orjson-3.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45202ee3f5494644e064c41abd1320497fb92fd31fc73af708708af664ac3b56", size = 132432, upload-time = "2025-07-25T14:32:40.619Z" }, { url = "https://files.pythonhosted.org/packages/44/61/57d22bc31f36a93878a6f772aea76b2184102c6993dea897656a66d18c74/orjson-3.11.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbb79a0476393c07656b69c8e763c3cc925fa8e1d9e9b7d1f626901bb5025448", size = 120724, upload-time = "2025-08-12T15:11:10.674Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4d/3fc3e5d7115f4f7d01b481e29e5a79bcbcc45711a2723242787455424f40/orjson-3.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5adaf01b92e0402a9ac5c3ebe04effe2bbb115f0914a0a53d34ea239a746289", size = 135069, upload-time = "2025-07-25T14:32:41.84Z" }, { url = "https://files.pythonhosted.org/packages/78/a9/4550e96b4c490c83aea697d5347b8f7eb188152cd7b5a38001055ca5b379/orjson-3.11.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191ed27a1dddb305083d8716af413d7219f40ec1d4c9b0e977453b4db0d6fb6c", size = 123577, upload-time = "2025-08-12T15:11:12.015Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c6/7585aa8522af896060dc0cd7c336ba6c574ae854416811ee6642c505cc95/orjson-3.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6162a1a757a1f1f4a94bc6ffac834a3602e04ad5db022dd8395a54ed9dd51c81", size = 131045, upload-time = "2025-07-25T14:32:43.085Z" }, { url = "https://files.pythonhosted.org/packages/3a/86/09b8cb3ebd513d708ef0c92d36ac3eebda814c65c72137b0a82d6d688fc4/orjson-3.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0afb89f16f07220183fd00f5f297328ed0a68d8722ad1b0c8dcd95b12bc82804", size = 121195, upload-time = "2025-08-12T15:11:13.399Z" },
{ url = "https://files.pythonhosted.org/packages/6a/4e/b8a0a943793d2708ebc39e743c943251e08ee0f3279c880aefd8e9cb0c70/orjson-3.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:78404206977c9f946613d3f916727c189d43193e708d760ea5d4b2087d6b0968", size = 130597, upload-time = "2025-07-25T14:32:44.336Z" }, { url = "https://files.pythonhosted.org/packages/37/68/7b40b39ac2c1c644d4644e706d0de6c9999764341cd85f2a9393cb387661/orjson-3.11.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ab6e6b4e93b1573a026b6ec16fca9541354dd58e514b62c558b58554ae04307", size = 119234, upload-time = "2025-08-12T15:11:15.134Z" },
{ url = "https://files.pythonhosted.org/packages/72/2b/7d30e2aed2f585d5d385fb45c71d9b16ba09be58c04e8767ae6edc6c9282/orjson-3.11.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:db48f8e81072e26df6cdb0e9fff808c28597c6ac20a13d595756cf9ba1fed48a", size = 404207, upload-time = "2025-07-25T14:32:45.612Z" }, { url = "https://files.pythonhosted.org/packages/40/7c/bb6e7267cd80c19023d44d8cbc4ea4ed5429fcd4a7eb9950f50305697a28/orjson-3.11.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9cb23527efb61fb75527df55d20ee47989c4ee34e01a9c98ee9ede232abf6219", size = 392250, upload-time = "2025-08-12T15:11:16.604Z" },
{ url = "https://files.pythonhosted.org/packages/1b/7e/772369ec66fcbce79477f0891918309594cd00e39b67a68d4c445d2ab754/orjson-3.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0c1e394e67ced6bb16fea7054d99fbdd99a539cf4d446d40378d4c06e0a8548d", size = 146628, upload-time = "2025-07-25T14:32:46.981Z" }, { url = "https://files.pythonhosted.org/packages/64/f2/6730ace05583dbca7c1b406d59f4266e48cd0d360566e71482420fb849fc/orjson-3.11.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a4dd1268e4035af21b8a09e4adf2e61f87ee7bf63b86d7bb0a237ac03fad5b45", size = 134572, upload-time = "2025-08-12T15:11:18.205Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c8/62bdb59229d7e393ae309cef41e32cc1f0b567b21dfd0742da70efb8b40c/orjson-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e7a840752c93d4eecd1378e9bb465c3703e127b58f675cd5c620f361b6cf57a4", size = 135449, upload-time = "2025-07-25T14:32:48.727Z" }, { url = "https://files.pythonhosted.org/packages/96/0f/7d3e03a30d5aac0432882b539a65b8c02cb6dd4221ddb893babf09c424cc/orjson-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff8b155b145eaf5a9d94d2c476fbe18d6021de93cf36c2ae2c8c5b775763f14e", size = 123869, upload-time = "2025-08-12T15:11:19.554Z" },
{ url = "https://files.pythonhosted.org/packages/02/47/1c99aa60e19f781424eabeaacd9e999eafe5b59c81ead4273b773f0f3af1/orjson-3.11.1-cp312-cp312-win32.whl", hash = "sha256:4537b0e09f45d2b74cb69c7f39ca1e62c24c0488d6bf01cd24673c74cd9596bf", size = 136653, upload-time = "2025-07-25T14:32:50.622Z" }, { url = "https://files.pythonhosted.org/packages/45/80/1513265eba6d4a960f078f4b1d2bff94a571ab2d28c6f9835e03dfc65cc6/orjson-3.11.2-cp312-cp312-win32.whl", hash = "sha256:ae3bb10279d57872f9aba68c9931aa71ed3b295fa880f25e68da79e79453f46e", size = 124430, upload-time = "2025-08-12T15:11:20.914Z" },
{ url = "https://files.pythonhosted.org/packages/31/9a/132999929a2892ab07e916669accecc83e5bff17e11a1186b4c6f23231f0/orjson-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:dbee6b050062540ae404530cacec1bf25e56e8d87d8d9b610b935afeb6725cae", size = 131426, upload-time = "2025-07-25T14:32:51.883Z" }, { url = "https://files.pythonhosted.org/packages/fb/61/eadf057b68a332351eeb3d89a4cc538d14f31cd8b5ec1b31a280426ccca2/orjson-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:d026e1967239ec11a2559b4146a61d13914504b396f74510a1c4d6b19dfd8732", size = 119598, upload-time = "2025-08-12T15:11:22.372Z" },
{ url = "https://files.pythonhosted.org/packages/9c/77/d984ee5a1ca341090902e080b187721ba5d1573a8d9759e0c540975acfb2/orjson-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:f55e557d4248322d87c4673e085c7634039ff04b47bfc823b87149ae12bef60d", size = 126635, upload-time = "2025-07-25T14:32:53.2Z" }, { url = "https://files.pythonhosted.org/packages/6b/3f/7f4b783402143d965ab7e9a2fc116fdb887fe53bdce7d3523271cd106098/orjson-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:59f8d5ad08602711af9589375be98477d70e1d102645430b5a7985fdbf613b36", size = 114052, upload-time = "2025-08-12T15:11:23.762Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e9/880ef869e6f66279ce3a381a32afa0f34e29a94250146911eee029e56efc/orjson-3.11.1-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:53cfefe4af059e65aabe9683f76b9c88bf34b4341a77d329227c2424e0e59b0e", size = 240835, upload-time = "2025-07-25T14:32:54.507Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/0dd6b4750eb556ae4e2c6a9cb3e219ec642e9c6d95f8ebe5dc9020c67204/orjson-3.11.2-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a079fdba7062ab396380eeedb589afb81dc6683f07f528a03b6f7aae420a0219", size = 226419, upload-time = "2025-08-12T15:11:25.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/1f/52039ef3d03eeea21763b46bc99ebe11d9de8510c72b7b5569433084a17e/orjson-3.11.1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:93d5abed5a6f9e1b6f9b5bf6ed4423c11932b5447c2f7281d3b64e0f26c6d064", size = 129226, upload-time = "2025-07-25T14:32:55.908Z" }, { url = "https://files.pythonhosted.org/packages/44/d5/e67f36277f78f2af8a4690e0c54da6b34169812f807fd1b4bfc4dbcf9558/orjson-3.11.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:6a5f62ebbc530bb8bb4b1ead103647b395ba523559149b91a6c545f7cd4110ad", size = 115803, upload-time = "2025-08-12T15:11:27.357Z" },
{ url = "https://files.pythonhosted.org/packages/ee/da/59fdffc9465a760be2cd3764ef9cd5535eec8f095419f972fddb123b6d0e/orjson-3.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dbf06642f3db2966df504944cdd0eb68ca2717f0353bb20b20acd78109374a6", size = 132261, upload-time = "2025-07-25T14:32:57.538Z" }, { url = "https://files.pythonhosted.org/packages/24/37/ff8bc86e0dacc48f07c2b6e20852f230bf4435611bab65e3feae2b61f0ae/orjson-3.11.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7df6c7b8b0931feb3420b72838c3e2ba98c228f7aa60d461bc050cf4ca5f7b2", size = 111337, upload-time = "2025-08-12T15:11:28.805Z" },
{ url = "https://files.pythonhosted.org/packages/bb/5c/8610911c7e969db7cf928c8baac4b2f1e68d314bc3057acf5ca64f758435/orjson-3.11.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dddf4e78747fa7f2188273f84562017a3c4f0824485b78372513c1681ea7a894", size = 128614, upload-time = "2025-07-25T14:32:58.808Z" }, { url = "https://files.pythonhosted.org/packages/b9/25/37d4d3e8079ea9784ea1625029988e7f4594ce50d4738b0c1e2bf4a9e201/orjson-3.11.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f59dfea7da1fced6e782bb3699718088b1036cb361f36c6e4dd843c5111aefe", size = 116222, upload-time = "2025-08-12T15:11:30.18Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a1/a1db9d4310d014c90f3b7e9b72c6fb162cba82c5f46d0b345669eaebdd3a/orjson-3.11.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa3fe8653c9f57f0e16f008e43626485b6723b84b2f741f54d1258095b655912", size = 130968, upload-time = "2025-07-25T14:33:00.038Z" }, { url = "https://files.pythonhosted.org/packages/b7/32/a63fd9c07fce3b4193dcc1afced5dd4b0f3a24e27556604e9482b32189c9/orjson-3.11.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edf49146520fef308c31aa4c45b9925fd9c7584645caca7c0c4217d7900214ae", size = 119020, upload-time = "2025-08-12T15:11:31.59Z" },
{ url = "https://files.pythonhosted.org/packages/56/ff/11acd1fd7c38ea7a1b5d6bf582ae3da05931bee64620995eb08fd63c77fe/orjson-3.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6334d2382aff975a61f6f4d1c3daf39368b887c7de08f7c16c58f485dcf7adb2", size = 132439, upload-time = "2025-07-25T14:33:01.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/b6/400792b8adc3079a6b5d649264a3224d6342436d9fac9a0ed4abc9dc4596/orjson-3.11.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50995bbeb5d41a32ad15e023305807f561ac5dcd9bd41a12c8d8d1d2c83e44e6", size = 120721, upload-time = "2025-08-12T15:11:33.035Z" },
{ url = "https://files.pythonhosted.org/packages/70/f9/bb564dd9450bf8725e034a8ad7f4ae9d4710a34caf63b85ce1c0c6d40af0/orjson-3.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3d0855b643f259ee0cb76fe3df4c04483354409a520a902b067c674842eb6b8", size = 135299, upload-time = "2025-07-25T14:33:03.079Z" }, { url = "https://files.pythonhosted.org/packages/40/f3/31ab8f8c699eb9e65af8907889a0b7fef74c1d2b23832719a35da7bb0c58/orjson-3.11.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cc42960515076eb639b705f105712b658c525863d89a1704d984b929b0577d1", size = 123574, upload-time = "2025-08-12T15:11:34.433Z" },
{ url = "https://files.pythonhosted.org/packages/94/bb/c8eafe6051405e241dda3691db4d9132d3c3462d1d10a17f50837dd130b4/orjson-3.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0eacdfeefd0a79987926476eb16e0245546bedeb8febbbbcf4b653e79257a8e4", size = 131004, upload-time = "2025-07-25T14:33:04.416Z" }, { url = "https://files.pythonhosted.org/packages/bd/a6/ce4287c412dff81878f38d06d2c80845709c60012ca8daf861cb064b4574/orjson-3.11.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c56777cab2a7b2a8ea687fedafb84b3d7fdafae382165c31a2adf88634c432fa", size = 121225, upload-time = "2025-08-12T15:11:36.133Z" },
{ url = "https://files.pythonhosted.org/packages/a2/40/bed8d7dcf1bd2df8813bf010a25f645863a2f75e8e0ebdb2b55784cf1a62/orjson-3.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ed07faf9e4873518c60480325dcbc16d17c59a165532cccfb409b4cdbaeff24", size = 130583, upload-time = "2025-07-25T14:33:05.768Z" }, { url = "https://files.pythonhosted.org/packages/69/b0/7a881b2aef4fed0287d2a4fbb029d01ed84fa52b4a68da82bdee5e50598e/orjson-3.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07349e88025b9b5c783077bf7a9f401ffbfb07fd20e86ec6fc5b7432c28c2c5e", size = 119201, upload-time = "2025-08-12T15:11:37.642Z" },
{ url = "https://files.pythonhosted.org/packages/57/e7/cfa2eb803ad52d74fbb5424a429b5be164e51d23f1d853e5e037173a5c48/orjson-3.11.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d308dd578ae3658f62bb9eba54801533225823cd3248c902be1ebc79b5e014", size = 404218, upload-time = "2025-07-25T14:33:07.117Z" }, { url = "https://files.pythonhosted.org/packages/cf/98/a325726b37f7512ed6338e5e65035c3c6505f4e628b09a5daf0419f054ea/orjson-3.11.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:45841fbb79c96441a8c58aa29ffef570c5df9af91f0f7a9572e5505e12412f15", size = 392193, upload-time = "2025-08-12T15:11:39.153Z" },
{ url = "https://files.pythonhosted.org/packages/d5/21/bc703af5bc6e9c7e18dcf4404dcc4ec305ab9bb6c82d3aee5952c0c56abf/orjson-3.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c4aa13ca959ba6b15c0a98d3d204b850f9dc36c08c9ce422ffb024eb30d6e058", size = 146605, upload-time = "2025-07-25T14:33:08.55Z" }, { url = "https://files.pythonhosted.org/packages/cb/4f/a7194f98b0ce1d28190e0c4caa6d091a3fc8d0107ad2209f75c8ba398984/orjson-3.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13d8d8db6cd8d89d4d4e0f4161acbbb373a4d2a4929e862d1d2119de4aa324ac", size = 134548, upload-time = "2025-08-12T15:11:40.768Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d26a0150534c4965a06f556aa68bf3c3b82999d5d7b0facd3af7b390c4af/orjson-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:be3d0653322abc9b68e5bcdaee6cfd58fcbe9973740ab222b87f4d687232ab1f", size = 135434, upload-time = "2025-07-25T14:33:09.967Z" }, { url = "https://files.pythonhosted.org/packages/e8/5e/b84caa2986c3f472dc56343ddb0167797a708a8d5c3be043e1e2677b55df/orjson-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51da1ee2178ed09c00d09c1b953e45846bbc16b6420965eb7a913ba209f606d8", size = 123798, upload-time = "2025-08-12T15:11:42.164Z" },
{ url = "https://files.pythonhosted.org/packages/89/b6/1cb28365f08cbcffc464f8512320c6eb6db6a653f03d66de47ea3c19385f/orjson-3.11.1-cp313-cp313-win32.whl", hash = "sha256:4dd34e7e2518de8d7834268846f8cab7204364f427c56fb2251e098da86f5092", size = 136596, upload-time = "2025-07-25T14:33:11.333Z" }, { url = "https://files.pythonhosted.org/packages/9c/5b/e398449080ce6b4c8fcadad57e51fa16f65768e1b142ba90b23ac5d10801/orjson-3.11.2-cp313-cp313-win32.whl", hash = "sha256:51dc033df2e4a4c91c0ba4f43247de99b3cbf42ee7a42ee2b2b2f76c8b2f2cb5", size = 124402, upload-time = "2025-08-12T15:11:44.036Z" },
{ url = "https://files.pythonhosted.org/packages/f9/35/7870d0d3ed843652676d84d8a6038791113eacc85237b673b925802826b8/orjson-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:d6895d32032b6362540e6d0694b19130bb4f2ad04694002dce7d8af588ca5f77", size = 131319, upload-time = "2025-07-25T14:33:12.614Z" }, { url = "https://files.pythonhosted.org/packages/b3/66/429e4608e124debfc4790bfc37131f6958e59510ba3b542d5fc163be8e5f/orjson-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:29d91d74942b7436f29b5d1ed9bcfc3f6ef2d4f7c4997616509004679936650d", size = 119498, upload-time = "2025-08-12T15:11:45.864Z" },
{ url = "https://files.pythonhosted.org/packages/b7/3e/5bcd50fd865eb664d4edfdaaaff51e333593ceb5695a22c0d0a0d2b187ba/orjson-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:bb7c36d5d3570fcbb01d24fa447a21a7fe5a41141fd88e78f7994053cc4e28f4", size = 126613, upload-time = "2025-07-25T14:33:13.927Z" }, { url = "https://files.pythonhosted.org/packages/7b/04/f8b5f317cce7ad3580a9ad12d7e2df0714dfa8a83328ecddd367af802f5b/orjson-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:4ca4fb5ac21cd1e48028d4f708b1bb13e39c42d45614befd2ead004a8bba8535", size = 114051, upload-time = "2025-08-12T15:11:47.555Z" },
{ url = "https://files.pythonhosted.org/packages/61/d8/0a5cd31ed100b4e569e143cb0cddefc21f0bcb8ce284f44bca0bb0e10f3d/orjson-3.11.1-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7b71ef394327b3d0b39f6ea7ade2ecda2731a56c6a7cbf0d6a7301203b92a89b", size = 240819, upload-time = "2025-07-25T14:33:15.223Z" }, { url = "https://files.pythonhosted.org/packages/74/83/2c363022b26c3c25b3708051a19d12f3374739bb81323f05b284392080c0/orjson-3.11.2-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3dcba7101ea6a8d4ef060746c0f2e7aa8e2453a1012083e1ecce9726d7554cb7", size = 226406, upload-time = "2025-08-12T15:11:49.445Z" },
{ url = "https://files.pythonhosted.org/packages/b9/95/7eb2c76c92192ceca16bc81845ff100bbb93f568b4b94d914b6a4da47d61/orjson-3.11.1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:77c0fe28ed659b62273995244ae2aa430e432c71f86e4573ab16caa2f2e3ca5e", size = 129218, upload-time = "2025-07-25T14:33:16.637Z" }, { url = "https://files.pythonhosted.org/packages/b0/a7/aa3c973de0b33fc93b4bd71691665ffdfeae589ea9d0625584ab10a7d0f5/orjson-3.11.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:15d17bdb76a142e1f55d91913e012e6e6769659daa6bfef3ef93f11083137e81", size = 115788, upload-time = "2025-08-12T15:11:50.992Z" },
{ url = "https://files.pythonhosted.org/packages/da/84/e6b67f301b18adbbc346882f456bea44daebbd032ba725dbd7b741e3a7f1/orjson-3.11.1-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:1495692f1f1ba2467df429343388a0ed259382835922e124c0cfdd56b3d1f727", size = 132238, upload-time = "2025-07-25T14:33:17.934Z" }, { url = "https://files.pythonhosted.org/packages/ef/f2/e45f233dfd09fdbb052ec46352363dca3906618e1a2b264959c18f809d0b/orjson-3.11.2-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:53c9e81768c69d4b66b8876ec3c8e431c6e13477186d0db1089d82622bccd19f", size = 111318, upload-time = "2025-08-12T15:11:52.495Z" },
{ url = "https://files.pythonhosted.org/packages/84/78/a45a86e29d9b2f391f9d00b22da51bc4b46b86b788fd42df2c5fcf3e8005/orjson-3.11.1-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:08c6a762fca63ca4dc04f66c48ea5d2428db55839fec996890e1bfaf057b658c", size = 130998, upload-time = "2025-07-25T14:33:19.282Z" }, { url = "https://files.pythonhosted.org/packages/3e/23/cf5a73c4da6987204cbbf93167f353ff0c5013f7c5e5ef845d4663a366da/orjson-3.11.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d4f13af59a7b84c1ca6b8a7ab70d608f61f7c44f9740cd42409e6ae7b6c8d8b7", size = 121231, upload-time = "2025-08-12T15:11:53.941Z" },
{ url = "https://files.pythonhosted.org/packages/ea/8f/6eb3ee6760d93b2ce996a8529164ee1f5bafbdf64b74c7314b68db622b32/orjson-3.11.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e26794fe3976810b2c01fda29bd9ac7c91a3c1284b29cc9a383989f7b614037", size = 130559, upload-time = "2025-07-25T14:33:20.589Z" }, { url = "https://files.pythonhosted.org/packages/40/1d/47468a398ae68a60cc21e599144e786e035bb12829cb587299ecebc088f1/orjson-3.11.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bde64aa469b5ee46cc960ed241fae3721d6a8801dacb2ca3466547a2535951e4", size = 119204, upload-time = "2025-08-12T15:11:55.409Z" },
{ url = "https://files.pythonhosted.org/packages/1b/78/9572ae94bdba6813917c9387e7834224c011ea6b4530ade07d718fd31598/orjson-3.11.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4b4b4f8f0b1d3ef8dc73e55363a0ffe012a42f4e2f1a140bf559698dca39b3fa", size = 404231, upload-time = "2025-07-25T14:33:22.019Z" }, { url = "https://files.pythonhosted.org/packages/4d/d9/f99433d89b288b5bc8836bffb32a643f805e673cf840ef8bab6e73ced0d1/orjson-3.11.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b5ca86300aeb383c8fa759566aca065878d3d98c3389d769b43f0a2e84d52c5f", size = 392237, upload-time = "2025-08-12T15:11:57.18Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a3/68381ad0757e084927c5ee6cfdeab1c6c89405949ee493db557e60871c4c/orjson-3.11.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:848be553ea35aa89bfefbed2e27c8a41244c862956ab8ba00dc0b27e84fd58de", size = 146658, upload-time = "2025-07-25T14:33:23.675Z" }, { url = "https://files.pythonhosted.org/packages/d4/dc/1b9d80d40cebef603325623405136a29fb7d08c877a728c0943dd066c29a/orjson-3.11.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24e32a558ebed73a6a71c8f1cbc163a7dd5132da5270ff3d8eeb727f4b6d1bc7", size = 134578, upload-time = "2025-08-12T15:11:58.844Z" },
{ url = "https://files.pythonhosted.org/packages/00/db/fac56acf77aab778296c3f541a3eec643266f28ecd71d6c0cba251e47655/orjson-3.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c964c29711a4b1df52f8d9966f015402a6cf87753a406c1c4405c407dd66fd45", size = 135443, upload-time = "2025-07-25T14:33:25.04Z" }, { url = "https://files.pythonhosted.org/packages/45/b3/72e7a4c5b6485ef4e83ef6aba7f1dd041002bad3eb5d1d106ca5b0fc02c6/orjson-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e36319a5d15b97e4344110517450396845cc6789aed712b1fbf83c1bd95792f6", size = 123799, upload-time = "2025-08-12T15:12:00.352Z" },
{ url = "https://files.pythonhosted.org/packages/76/b1/326fa4b87426197ead61c1eec2eeb3babc9eb33b480ac1f93894e40c8c08/orjson-3.11.1-cp314-cp314-win32.whl", hash = "sha256:33aada2e6b6bc9c540d396528b91e666cedb383740fee6e6a917f561b390ecb1", size = 136643, upload-time = "2025-07-25T14:33:26.449Z" }, { url = "https://files.pythonhosted.org/packages/c8/3e/a3d76b392e7acf9b34dc277171aad85efd6accc75089bb35b4c614990ea9/orjson-3.11.2-cp314-cp314-win32.whl", hash = "sha256:40193ada63fab25e35703454d65b6afc71dbc65f20041cb46c6d91709141ef7f", size = 124461, upload-time = "2025-08-12T15:12:01.854Z" },
{ url = "https://files.pythonhosted.org/packages/0f/8e/2987ae2109f3bfd39680f8a187d1bc09ad7f8fb019dcdc719b08c7242ade/orjson-3.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:68e10fd804e44e36188b9952543e3fa22f5aa8394da1b5283ca2b423735c06e8", size = 131324, upload-time = "2025-07-25T14:33:27.896Z" }, { url = "https://files.pythonhosted.org/packages/fb/e3/75c6a596ff8df9e4a5894813ff56695f0a218e6ea99420b4a645c4f7795d/orjson-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:7c8ac5f6b682d3494217085cf04dadae66efee45349ad4ee2a1da3c97e2305a8", size = 119494, upload-time = "2025-08-12T15:12:03.337Z" },
{ url = "https://files.pythonhosted.org/packages/21/5f/253e08e6974752b124fbf3a4de3ad53baa766b0cb4a333d47706d307e396/orjson-3.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:f3cf6c07f8b32127d836be8e1c55d4f34843f7df346536da768e9f73f22078a1", size = 126605, upload-time = "2025-07-25T14:33:29.244Z" }, { url = "https://files.pythonhosted.org/packages/5b/3d/9e74742fc261c5ca473c96bb3344d03995869e1dc6402772c60afb97736a/orjson-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:21cf261e8e79284242e4cb1e5924df16ae28255184aafeff19be1405f6d33f67", size = 114046, upload-time = "2025-08-12T15:12:04.87Z" },
] ]
[[package]] [[package]]
@@ -2919,23 +2919,23 @@ wheels = [
[[package]] [[package]]
name = "types-pyyaml" name = "types-pyyaml"
version = "6.0.12.20250516" version = "6.0.12.20250809"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" } sdist = { url = "https://files.pythonhosted.org/packages/36/21/52ffdbddea3c826bc2758d811ccd7f766912de009c5cf096bd5ebba44680/types_pyyaml-6.0.12.20250809.tar.gz", hash = "sha256:af4a1aca028f18e75297da2ee0da465f799627370d74073e96fee876524f61b5", size = 17385, upload-time = "2025-08-09T03:14:34.867Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" }, { url = "https://files.pythonhosted.org/packages/35/3e/0346d09d6e338401ebf406f12eaf9d0b54b315b86f1ec29e34f1a0aedae9/types_pyyaml-6.0.12.20250809-py3-none-any.whl", hash = "sha256:032b6003b798e7de1a1ddfeefee32fac6486bdfe4845e0ae0e7fb3ee4512b52f", size = 20277, upload-time = "2025-08-09T03:14:34.055Z" },
] ]
[[package]] [[package]]
name = "types-requests" name = "types-requests"
version = "2.32.4.20250611" version = "2.32.4.20250809"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/6d/7f/73b3a04a53b0fd2a911d4ec517940ecd6600630b559e4505cc7b68beb5a0/types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826", size = 23118, upload-time = "2025-06-11T03:11:41.272Z" } sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ea/0be9258c5a4fa1ba2300111aa5a0767ee6d18eb3fd20e91616c12082284d/types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072", size = 20643, upload-time = "2025-06-11T03:11:40.186Z" }, { url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" },
] ]
[[package]] [[package]]
@@ -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 } enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
class DriftLocalAlbumRepository extends DriftDatabaseRepository { class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
@@ -40,6 +40,7 @@ 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);
@@ -319,7 +320,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.asc(_db.localAssetEntity.id)]) ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..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();
@@ -18,7 +18,7 @@ final localAlbumServiceProvider = Provider<LocalAlbumService>(
); );
final localAlbumProvider = FutureProvider<List<LocalAlbum>>( final localAlbumProvider = FutureProvider<List<LocalAlbum>>(
(ref) => LocalAlbumService(ref.watch(localAlbumRepository)).getAll(), (ref) => LocalAlbumService(ref.watch(localAlbumRepository)).getAll(sortBy: {SortLocalAlbumsBy.newestAsset}),
); );
final localAlbumThumbnailProvider = FutureProvider.family<LocalAsset?, String>( final localAlbumThumbnailProvider = FutureProvider.family<LocalAsset?, String>(
+5
View File
@@ -77,12 +77,14 @@ Class | Method | HTTP request | Description
*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} | *APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} |
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} | *APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys | *APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys |
*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me |
*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} | *APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} |
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities | *ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} | *ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} |
*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities | *ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities |
*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics | *ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics |
*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets | *AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets |
*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets |
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | *AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | *AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | *AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
@@ -299,6 +301,8 @@ Class | Method | HTTP request | Description
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
- [AlbumUserRole](doc//AlbumUserRole.md) - [AlbumUserRole](doc//AlbumUserRole.md)
- [AlbumsAddAssetsDto](doc//AlbumsAddAssetsDto.md)
- [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md)
- [AlbumsResponse](doc//AlbumsResponse.md) - [AlbumsResponse](doc//AlbumsResponse.md)
- [AlbumsUpdate](doc//AlbumsUpdate.md) - [AlbumsUpdate](doc//AlbumsUpdate.md)
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
@@ -333,6 +337,7 @@ Class | Method | HTTP request | Description
- [AudioCodec](doc//AudioCodec.md) - [AudioCodec](doc//AudioCodec.md)
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md) - [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
- [AvatarUpdate](doc//AvatarUpdate.md) - [AvatarUpdate](doc//AvatarUpdate.md)
- [BulkIdErrorReason](doc//BulkIdErrorReason.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md) - [BulkIdsDto](doc//BulkIdsDto.md)
- [CLIPConfig](doc//CLIPConfig.md) - [CLIPConfig](doc//CLIPConfig.md)
+3
View File
@@ -79,6 +79,8 @@ part 'model/album_user_add_dto.dart';
part 'model/album_user_create_dto.dart'; part 'model/album_user_create_dto.dart';
part 'model/album_user_response_dto.dart'; part 'model/album_user_response_dto.dart';
part 'model/album_user_role.dart'; part 'model/album_user_role.dart';
part 'model/albums_add_assets_dto.dart';
part 'model/albums_add_assets_response_dto.dart';
part 'model/albums_response.dart'; part 'model/albums_response.dart';
part 'model/albums_update.dart'; part 'model/albums_update.dart';
part 'model/all_job_status_response_dto.dart'; part 'model/all_job_status_response_dto.dart';
@@ -113,6 +115,7 @@ part 'model/asset_visibility.dart';
part 'model/audio_codec.dart'; part 'model/audio_codec.dart';
part 'model/auth_status_response_dto.dart'; part 'model/auth_status_response_dto.dart';
part 'model/avatar_update.dart'; part 'model/avatar_update.dart';
part 'model/bulk_id_error_reason.dart';
part 'model/bulk_id_response_dto.dart'; part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart'; part 'model/bulk_ids_dto.dart';
part 'model/clip_config.dart'; part 'model/clip_config.dart';
+67
View File
@@ -91,6 +91,73 @@ class AlbumsApi {
return null; return null;
} }
/// This endpoint requires the `albumAsset.create` permission.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> addAssetsToAlbumsWithHttpInfo(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/assets';
// ignore: prefer_final_locals
Object? postBody = albumsAddAssetsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint requires the `albumAsset.create` permission.
///
/// Parameters:
///
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<AlbumsAddAssetsResponseDto?> addAssetsToAlbums(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
final response = await addAssetsToAlbumsWithHttpInfo(albumsAddAssetsDto, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumsAddAssetsResponseDto',) as AlbumsAddAssetsResponseDto;
}
return null;
}
/// This endpoint requires the `albumUser.create` permission. /// This endpoint requires the `albumUser.create` permission.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
+41
View File
@@ -213,6 +213,47 @@ class APIKeysApi {
return null; return null;
} }
/// Performs an HTTP 'GET /api-keys/me' operation and returns the [Response].
Future<Response> getMyApiKeyWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/api-keys/me';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<APIKeyResponseDto?> getMyApiKey() async {
final response = await getMyApiKeyWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'APIKeyResponseDto',) as APIKeyResponseDto;
}
return null;
}
/// This endpoint requires the `apiKey.update` permission. /// This endpoint requires the `apiKey.update` permission.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
+6
View File
@@ -212,6 +212,10 @@ class ApiClient {
return AlbumUserResponseDto.fromJson(value); return AlbumUserResponseDto.fromJson(value);
case 'AlbumUserRole': case 'AlbumUserRole':
return AlbumUserRoleTypeTransformer().decode(value); return AlbumUserRoleTypeTransformer().decode(value);
case 'AlbumsAddAssetsDto':
return AlbumsAddAssetsDto.fromJson(value);
case 'AlbumsAddAssetsResponseDto':
return AlbumsAddAssetsResponseDto.fromJson(value);
case 'AlbumsResponse': case 'AlbumsResponse':
return AlbumsResponse.fromJson(value); return AlbumsResponse.fromJson(value);
case 'AlbumsUpdate': case 'AlbumsUpdate':
@@ -280,6 +284,8 @@ class ApiClient {
return AuthStatusResponseDto.fromJson(value); return AuthStatusResponseDto.fromJson(value);
case 'AvatarUpdate': case 'AvatarUpdate':
return AvatarUpdate.fromJson(value); return AvatarUpdate.fromJson(value);
case 'BulkIdErrorReason':
return BulkIdErrorReasonTypeTransformer().decode(value);
case 'BulkIdResponseDto': case 'BulkIdResponseDto':
return BulkIdResponseDto.fromJson(value); return BulkIdResponseDto.fromJson(value);
case 'BulkIdsDto': case 'BulkIdsDto':
+3
View File
@@ -79,6 +79,9 @@ String parameterToString(dynamic value) {
if (value is AudioCodec) { if (value is AudioCodec) {
return AudioCodecTypeTransformer().encode(value).toString(); return AudioCodecTypeTransformer().encode(value).toString();
} }
if (value is BulkIdErrorReason) {
return BulkIdErrorReasonTypeTransformer().encode(value).toString();
}
if (value is CQMode) { if (value is CQMode) {
return CQModeTypeTransformer().encode(value).toString(); return CQModeTypeTransformer().encode(value).toString();
} }
+111
View File
@@ -0,0 +1,111 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AlbumsAddAssetsDto {
/// Returns a new [AlbumsAddAssetsDto] instance.
AlbumsAddAssetsDto({
this.albumIds = const [],
this.assetIds = const [],
});
List<String> albumIds;
List<String> assetIds;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsDto &&
_deepEquality.equals(other.albumIds, albumIds) &&
_deepEquality.equals(other.assetIds, assetIds);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumIds.hashCode) +
(assetIds.hashCode);
@override
String toString() => 'AlbumsAddAssetsDto[albumIds=$albumIds, assetIds=$assetIds]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumIds'] = this.albumIds;
json[r'assetIds'] = this.assetIds;
return json;
}
/// Returns a new [AlbumsAddAssetsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumsAddAssetsDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumsAddAssetsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AlbumsAddAssetsDto(
albumIds: json[r'albumIds'] is Iterable
? (json[r'albumIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
assetIds: json[r'assetIds'] is Iterable
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<AlbumsAddAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumsAddAssetsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AlbumsAddAssetsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AlbumsAddAssetsDto> mapFromJson(dynamic json) {
final map = <String, AlbumsAddAssetsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumsAddAssetsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AlbumsAddAssetsDto-objects as value to a dart map
static Map<String, List<AlbumsAddAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AlbumsAddAssetsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AlbumsAddAssetsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumIds',
'assetIds',
};
}
@@ -0,0 +1,132 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AlbumsAddAssetsResponseDto {
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
AlbumsAddAssetsResponseDto({
required this.albumSuccessCount,
required this.assetSuccessCount,
this.error,
required this.success,
});
int albumSuccessCount;
int assetSuccessCount;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
BulkIdErrorReason? error;
bool success;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
other.albumSuccessCount == albumSuccessCount &&
other.assetSuccessCount == assetSuccessCount &&
other.error == error &&
other.success == success;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumSuccessCount.hashCode) +
(assetSuccessCount.hashCode) +
(error == null ? 0 : error!.hashCode) +
(success.hashCode);
@override
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumSuccessCount'] = this.albumSuccessCount;
json[r'assetSuccessCount'] = this.assetSuccessCount;
if (this.error != null) {
json[r'error'] = this.error;
} else {
// json[r'error'] = null;
}
json[r'success'] = this.success;
return json;
}
/// Returns a new [AlbumsAddAssetsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumsAddAssetsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumsAddAssetsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AlbumsAddAssetsResponseDto(
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
error: BulkIdErrorReason.fromJson(json[r'error']),
success: mapValueOfType<bool>(json, r'success')!,
);
}
return null;
}
static List<AlbumsAddAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumsAddAssetsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AlbumsAddAssetsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AlbumsAddAssetsResponseDto> mapFromJson(dynamic json) {
final map = <String, AlbumsAddAssetsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumsAddAssetsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AlbumsAddAssetsResponseDto-objects as value to a dart map
static Map<String, List<AlbumsAddAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AlbumsAddAssetsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AlbumsAddAssetsResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumSuccessCount',
'assetSuccessCount',
'success',
};
}
+91
View File
@@ -0,0 +1,91 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class BulkIdErrorReason {
/// Instantiate a new enum with the provided [value].
const BulkIdErrorReason._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const duplicate = BulkIdErrorReason._(r'duplicate');
static const noPermission = BulkIdErrorReason._(r'no_permission');
static const notFound = BulkIdErrorReason._(r'not_found');
static const unknown = BulkIdErrorReason._(r'unknown');
/// List of all possible values in this [enum][BulkIdErrorReason].
static const values = <BulkIdErrorReason>[
duplicate,
noPermission,
notFound,
unknown,
];
static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value);
static List<BulkIdErrorReason> listFromJson(dynamic json, {bool growable = false,}) {
final result = <BulkIdErrorReason>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = BulkIdErrorReason.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [BulkIdErrorReason] to String,
/// and [decode] dynamic data back to [BulkIdErrorReason].
class BulkIdErrorReasonTypeTransformer {
factory BulkIdErrorReasonTypeTransformer() => _instance ??= const BulkIdErrorReasonTypeTransformer._();
const BulkIdErrorReasonTypeTransformer._();
String encode(BulkIdErrorReason data) => data.value;
/// Decodes a [dynamic value][data] to a BulkIdErrorReason.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
BulkIdErrorReason? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'duplicate': return BulkIdErrorReason.duplicate;
case r'no_permission': return BulkIdErrorReason.noPermission;
case r'not_found': return BulkIdErrorReason.notFound;
case r'unknown': return BulkIdErrorReason.unknown;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [BulkIdErrorReasonTypeTransformer] instance.
static BulkIdErrorReasonTypeTransformer? _instance;
}
+9 -4
View File
@@ -15,7 +15,7 @@ function dart {
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
cd ../../ cd ../../
npx --yes @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile pnpx @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
# Post generate patches # Post generate patches
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch
@@ -27,12 +27,17 @@ function dart {
} }
function typescript { function typescript {
npx --yes oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts pnpx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
npm --prefix typescript-sdk ci && npm --prefix typescript-sdk run build pnpm --filter @immich/sdk install --frozen-lockfile
pnpm --filter @immich/sdk build
} }
# requires server to be built # requires server to be built
npm run sync:open-api --prefix=../server (
cd ..
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich build
pnpm --filter immich sync:open-api
)
if [[ $1 == 'dart' ]]; then if [[ $1 == 'dart' ]]; then
dart dart
+151
View File
@@ -940,6 +940,67 @@
"description": "This endpoint requires the `album.create` permission." "description": "This endpoint requires the `album.create` permission."
} }
}, },
"/albums/assets": {
"put": {
"operationId": "addAssetsToAlbums",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumsAddAssetsDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumsAddAssetsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Albums"
],
"x-immich-permission": "albumAsset.create",
"description": "This endpoint requires the `albumAsset.create` permission."
}
},
"/albums/statistics": { "/albums/statistics": {
"get": { "get": {
"operationId": "getAlbumStatistics", "operationId": "getAlbumStatistics",
@@ -1488,6 +1549,38 @@
"description": "This endpoint requires the `apiKey.create` permission." "description": "This endpoint requires the `apiKey.create` permission."
} }
}, },
"/api-keys/me": {
"get": {
"operationId": "getMyApiKey",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIKeyResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"API Keys"
]
}
},
"/api-keys/{id}": { "/api-keys/{id}": {
"delete": { "delete": {
"operationId": "deleteApiKey", "operationId": "deleteApiKey",
@@ -9889,6 +9982,55 @@
], ],
"type": "string" "type": "string"
}, },
"AlbumsAddAssetsDto": {
"properties": {
"albumIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"assetIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"albumIds",
"assetIds"
],
"type": "object"
},
"AlbumsAddAssetsResponseDto": {
"properties": {
"albumSuccessCount": {
"type": "integer"
},
"assetSuccessCount": {
"type": "integer"
},
"error": {
"allOf": [
{
"$ref": "#/components/schemas/BulkIdErrorReason"
}
]
},
"success": {
"type": "boolean"
}
},
"required": [
"albumSuccessCount",
"assetSuccessCount",
"success"
],
"type": "object"
},
"AlbumsResponse": { "AlbumsResponse": {
"properties": { "properties": {
"defaultAssetOrder": { "defaultAssetOrder": {
@@ -10845,6 +10987,15 @@
}, },
"type": "object" "type": "object"
}, },
"BulkIdErrorReason": {
"enum": [
"duplicate",
"no_permission",
"not_found",
"unknown"
],
"type": "string"
},
"BulkIdResponseDto": { "BulkIdResponseDto": {
"properties": { "properties": {
"error": { "error": {
+1 -1
View File
@@ -5,7 +5,7 @@ A TypeScript SDK for interfacing with the [Immich](https://immich.app/) API.
## Install ## Install
```bash ```bash
npm i --save @immich/sdk pnpm i --save @immich/sdk
``` ```
## Usage ## Usage
-57
View File
@@ -1,57 +0,0 @@
{
"name": "@immich/sdk",
"version": "1.138.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.138.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.17.0",
"typescript": "^5.3.3"
}
},
"node_modules/@oazapfts/runtime": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.4.tgz",
"integrity": "sha512-7t6C2shug/6tZhQgkCa532oTYBLEnbASV/i1SG1rH2GB4h3aQQujYciYSPT92hvN4IwTe8S2hPkN/6iiOyTlCg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.17.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz",
"integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
@@ -384,6 +384,16 @@ export type CreateAlbumDto = {
assetIds?: string[]; assetIds?: string[];
description?: string; description?: string;
}; };
export type AlbumsAddAssetsDto = {
albumIds: string[];
assetIds: string[];
};
export type AlbumsAddAssetsResponseDto = {
albumSuccessCount: number;
assetSuccessCount: number;
error?: BulkIdErrorReason;
success: boolean;
};
export type AlbumStatisticsResponseDto = { export type AlbumStatisticsResponseDto = {
notShared: number; notShared: number;
owned: number; owned: number;
@@ -1864,6 +1874,26 @@ export function createAlbum({ createAlbumDto }: {
body: createAlbumDto body: createAlbumDto
}))); })));
} }
/**
* This endpoint requires the `albumAsset.create` permission.
*/
export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: {
key?: string;
slug?: string;
albumsAddAssetsDto: AlbumsAddAssetsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AlbumsAddAssetsResponseDto;
}>(`/albums/assets${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.json({
...opts,
method: "PUT",
body: albumsAddAssetsDto
})));
}
/** /**
* This endpoint requires the `album.statistics` permission. * This endpoint requires the `album.statistics` permission.
*/ */
@@ -2027,6 +2057,14 @@ export function createApiKey({ apiKeyCreateDto }: {
body: apiKeyCreateDto body: apiKeyCreateDto
}))); })));
} }
export function getMyApiKey(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ApiKeyResponseDto;
}>("/api-keys/me", {
...opts
}));
}
/** /**
* This endpoint requires the `apiKey.delete` permission. * This endpoint requires the `apiKey.delete` permission.
*/ */
@@ -4545,6 +4583,12 @@ export enum AssetTypeEnum {
Audio = "AUDIO", Audio = "AUDIO",
Other = "OTHER" Other = "OTHER"
} }
export enum BulkIdErrorReason {
Duplicate = "duplicate",
NoPermission = "no_permission",
NotFound = "not_found",
Unknown = "unknown"
}
export enum Error { export enum Error {
Duplicate = "duplicate", Duplicate = "duplicate",
NoPermission = "no_permission", NoPermission = "no_permission",
+10
View File
@@ -0,0 +1,10 @@
{
"name": "immich-monorepo",
"version": "0.0.1",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"engines": {
"pnpm": ">=10.0.0"
}
}
+25351
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
packages:
- cli
- docs
- e2e
- open-api/typescript-sdk
- server
- web
- .github
ignoredBuiltDependencies:
- '@nestjs/core'
- '@scarf/scarf'
- '@swc/core'
- bcrypt
- canvas
- core-js
- core-js-pure
- cpu-features
- es5-ext
- esbuild
- msgpackr-extract
- postman-code-generators
- protobufjs
- ssh2
- utimes
onlyBuiltDependencies:
- sharp
- '@tailwindcss/oxide'
overrides:
canvas: 2.11.2
sharp: ^0.34.2
packageExtensions:
nestjs-kysely:
dependencies:
tslib: '*'
nestjs-otel:
dependencies:
tslib: '*'
'@photo-sphere-viewer/equirectangular-video-adapter':
dependencies:
three: '*'
'@photo-sphere-viewer/video-plugin':
dependencies:
three: '*'
sharp:
dependencies:
node-addon-api: '*'
node-gyp: '*'
'@immich/ui':
dependencies:
tailwindcss: '>=4.1'
tailwind-variants:
dependencies:
tailwindcss: '>=4.1'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true
shamefullyHoist: false
verifyDepsBeforeRun: install
+5
View File
@@ -0,0 +1,5 @@
src
tsconfig*
eslint*
pnpm*
coverage
+63 -44
View File
@@ -1,14 +1,23 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:202507291116@sha256:e38543bdd77a02ed156cd9175ed11e9c16dccf48c418d46ecda48ce684de456a AS dev FROM ghcr.io/immich-app/base-server-dev:202507291116@sha256:e38543bdd77a02ed156cd9175ed11e9c16dccf48c418d46ecda48ce684de456a AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp
RUN npm install --global corepack@latest && \
corepack enable pnpm && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc
COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/
COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/
COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/
COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/
WORKDIR /tmp/create-dep-cache
RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./server/package* ./server/
WORKDIR /usr/src/app/server
RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64
ENV PATH="${PATH}:/usr/src/app/server/bin" \ ENV PATH="${PATH}:/usr/src/app/server/bin" \
IMMICH_ENV=development \ IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_DRIVER_CAPABILITIES=all \
@@ -17,23 +26,19 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server FROM dev AS dev-container-server
RUN rm -rf /usr/src/app
RUN apt-get update --allow-releaseinfo-change && \ RUN apt-get update --allow-releaseinfo-change && \
apt-get install sudo inetutils-ping openjdk-11-jre-headless \ apt-get install sudo inetutils-ping openjdk-11-jre-headless \
vim nano \ vim nano \
-y --no-install-recommends --fix-missing -y --no-install-recommends --fix-missing
RUN usermod -aG sudo node RUN usermod -aG sudo node && \
RUN echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
RUN mkdir -p /workspaces/immich mkdir -p /workspaces/immich
RUN chown node -R /workspaces
COPY --chown=node:node --chmod=777 ../.devcontainer/server/*.sh /immich-devcontainer/
USER node RUN chown node:node -R /workspaces
COPY --chown=node:node .. /tmp/create-dep-cache/ COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
WORKDIR /tmp/create-dep-cache
RUN make ci-all && rm -rf /tmp/create-dep-cache
WORKDIR /workspaces/immich
FROM dev-container-server AS dev-container-mobile FROM dev-container-server AS dev-container-mobile
USER root USER root
@@ -62,38 +67,55 @@ RUN mkdir -p ${FLUTTER_HOME} \
&& rm flutter.tar.xz \ && rm flutter.tar.xz \
&& chown -R node ${FLUTTER_HOME} && chown -R node ${FLUTTER_HOME}
USER node
RUN sudo apt-get update \ RUN apt-get update \
&& wget -qO- https://dcm.dev/pgp-key.public | sudo gpg --dearmor -o /usr/share/keyrings/dcm.gpg \ && wget -qO- https://dcm.dev/pgp-key.public | gpg --dearmor -o /usr/share/keyrings/dcm.gpg \
&& echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list \ && echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | tee /etc/apt/sources.list.d/dart_stable.list \
&& sudo apt-get update \ && apt-get update \
&& sudo apt-get install dcm -y && apt-get install dcm -y
RUN dart --disable-analytics RUN dart --disable-analytics
FROM dev AS prod # production-builder-base image
FROM ghcr.io/immich-app/base-server-dev:202507291116@sha256:e38543bdd77a02ed156cd9175ed11e9c16dccf48c418d46ecda48ce684de456a AS prod-builder-base
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp
COPY server . RUN npm install --global corepack@latest && \
RUN npm run build corepack enable pnpm
RUN npm prune --omit=dev --omit=optional
COPY --from=dev /usr/src/app/server/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/server/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build # server production build
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS web FROM prod-builder-base AS server-prod
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./package* ./pnpm* .pnpmfile.cjs ./
COPY ./server ./server/
# SHARP_IGNORE_GLOBAL_LIBVIPS because 'deploy' will always build sharp bindings from source
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
# web production build
FROM prod-builder-base AS web-prod
WORKDIR /usr/src/app
COPY ./package* ./pnpm* .pnpmfile.cjs ./
COPY ./web ./web/ COPY ./web ./web/
COPY ./i18n ./i18n/ COPY ./i18n ./i18n/
COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/ COPY ./open-api ./open-api/
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web --frozen-lockfile --force install && \
pnpm --filter @immich/sdk --filter immich-web build
WORKDIR /usr/src/app/open-api/typescript-sdk FROM prod-builder-base AS cli-prod
RUN npm ci && npm run build
WORKDIR /usr/src/app/web COPY ./package* ./pnpm* .pnpmfile.cjs ./
RUN npm ci && npm run build COPY ./cli ./cli/
COPY ./open-api ./open-api/
RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install && \
pnpm --filter @immich/sdk --filter @immich/cli build && \
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
# prod build # prod base image
FROM ghcr.io/immich-app/base-server-prod:202507291116@sha256:6e80f884c6e4f05cefe4b4fc4cc06a15bdb6ec9bd7b6e9eadf996a13b69494b6 FROM ghcr.io/immich-app/base-server-prod:202507291116@sha256:6e80f884c6e4f05cefe4b4fc4cc06a15bdb6ec9bd7b6e9eadf996a13b69494b6
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -101,16 +123,13 @@ ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all NVIDIA_VISIBLE_DEVICES=all
COPY --from=prod /usr/src/app/server/node_modules ./server/node_modules COPY --from=server-prod /output/server-pruned ./server
COPY --from=prod /usr/src/app/server/dist ./server/dist COPY --from=web-prod /usr/src/app/web/build /build/www
COPY --from=prod /usr/src/app/server/bin ./server/bin COPY --from=cli-prod /output/cli-pruned ./cli
COPY --from=web /usr/src/app/web/build /build/www RUN ln -s ./cli/bin/immich server/bin/immich
COPY ./server/resources ./server/resources
COPY ./server/package.json server/package-lock.json ./
COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE
RUN npm install -g @immich/cli && npm cache clean --force
ENV PATH="${PATH}:/usr/src/app/server/bin" ENV PATH="${PATH}:/usr/src/app/server/bin"
ARG BUILD_ID ARG BUILD_ID
+2 -2
View File
@@ -5,5 +5,5 @@ if [ "$IMMICH_ENV" != "development" ]; then
exit 1 exit 1
fi fi
cd /usr/src/app/server || exit 1 cd /usr/src/app || exit
npm exec nest -- start --debug "0.0.0.0:9230" --watch -- "$@" pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"
-18830
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,5 +1,6 @@
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
@@ -41,7 +42,10 @@ export const SALT_ROUNDS = 10;
export const IWorker = 'IWorker'; export const IWorker = 'IWorker';
const { version } = JSON.parse(readFileSync('./package.json', 'utf8')); // eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const packageFile = join(basePath, '..', 'package.json');
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
export const serverVersion = new SemVer(version); export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
@@ -65,6 +65,13 @@ describe(AlbumController.name, () => {
}); });
}); });
describe('PUT /albums/assets', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/albums/assets`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PATCH /albums/:id', () => { describe('PATCH /albums/:id', () => {
it('should be an authenticated route', async () => { it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' }); await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
@@ -4,6 +4,8 @@ import {
AddUsersDto, AddUsersDto,
AlbumInfoDto, AlbumInfoDto,
AlbumResponseDto, AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto, AlbumStatisticsResponseDto,
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
@@ -77,6 +79,12 @@ export class AlbumController {
return this.service.addAssets(auth, id, dto); return this.service.addAssets(auth, id, dto);
} }
@Put('assets')
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
return this.service.addAssetsToAlbums(auth, dto);
}
@Delete(':id/assets') @Delete(':id/assets')
@Authenticated({ permission: Permission.AlbumAssetDelete }) @Authenticated({ permission: Permission.AlbumAssetDelete })
removeAssetFromAlbum( removeAssetFromAlbum(
@@ -1,16 +1,16 @@
import { APIKeyController } from 'src/controllers/api-key.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import request from 'supertest'; import request from 'supertest';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(APIKeyController.name, () => { describe(ApiKeyController.name, () => {
let ctx: ControllerContext; let ctx: ControllerContext;
const service = mockBaseService(ApiKeyService); const service = mockBaseService(ApiKeyService);
beforeAll(async () => { beforeAll(async () => {
ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]); ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
return () => ctx.close(); return () => ctx.close();
}); });
@@ -33,6 +33,13 @@ describe(APIKeyController.name, () => {
}); });
}); });
describe('GET /api-keys/me', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/me`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /api-keys/:id', () => { describe('GET /api-keys/:id', () => {
it('should be an authenticated route', async () => { it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`); await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
+7 -1
View File
@@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('API Keys') @ApiTags('API Keys')
@Controller('api-keys') @Controller('api-keys')
export class APIKeyController { export class ApiKeyController {
constructor(private service: ApiKeyService) {} constructor(private service: ApiKeyService) {}
@Post() @Post()
@@ -24,6 +24,12 @@ export class APIKeyController {
return this.service.getAll(auth); return this.service.getAll(auth);
} }
@Get('me')
@Authenticated({ permission: false })
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
return this.service.getMine(auth);
}
@Get(':id') @Get(':id')
@Authenticated({ permission: Permission.ApiKeyRead }) @Authenticated({ permission: Permission.ApiKeyRead })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> { getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
+2 -2
View File
@@ -1,6 +1,6 @@
import { ActivityController } from 'src/controllers/activity.controller'; import { ActivityController } from 'src/controllers/activity.controller';
import { AlbumController } from 'src/controllers/album.controller'; import { AlbumController } from 'src/controllers/album.controller';
import { APIKeyController } from 'src/controllers/api-key.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller'; import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller'; import { AssetController } from 'src/controllers/asset.controller';
@@ -34,7 +34,7 @@ import { UserController } from 'src/controllers/user.controller';
import { ViewController } from 'src/controllers/view.controller'; import { ViewController } from 'src/controllers/view.controller';
export const controllers = [ export const controllers = [
APIKeyController, ApiKeyController,
ActivityController, ActivityController,
AlbumController, AlbumController,
AppController, AppController,
+19
View File
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
import _ from 'lodash'; import _ from 'lodash';
import { AlbumUser, AuthSharedLink, User } from 'src/database'; import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
@@ -54,6 +55,24 @@ export class CreateAlbumDto {
assetIds?: string[]; assetIds?: string[];
} }
export class AlbumsAddAssetsDto {
@ValidateUUID({ each: true })
albumIds!: string[];
@ValidateUUID({ each: true })
assetIds!: string[];
}
export class AlbumsAddAssetsResponseDto {
success!: boolean;
@ApiProperty({ type: 'integer' })
albumSuccessCount!: number;
@ApiProperty({ type: 'integer' })
assetSuccessCount!: number;
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
error?: BulkIdErrorReason;
}
export class UpdateAlbumDto { export class UpdateAlbumDto {
@Optional() @Optional()
@IsString() @IsString()
+2 -2
View File
@@ -17,7 +17,7 @@ import { UAParser } from 'ua-parser-js';
type AdminRoute = { admin?: true }; type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true }; type SharedLinkRoute = { sharedLink?: true };
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute);
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => { export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [ const decorators: MethodDecorator[] = [
@@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorat
} }
if (options?.permission) { if (options?.permission) {
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission ?? Permission.All)); decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
} }
if ((options as SharedLinkRoute)?.sharedLink) { if ((options as SharedLinkRoute)?.sharedLink) {
+332
View File
@@ -776,6 +776,338 @@ describe(AlbumService.name, () => {
}); });
}); });
describe('addAssetsToAlbums', () => {
it('should allow the owner to add assets', async () => {
mocks.access.album.checkOwnerAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
id: 'album-123',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
id: 'album-321',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
});
it('should not set the thumbnail if the album has one already', async () => {
mocks.access.album.checkOwnerAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }))
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.oneAsset, albumThumbnailAssetId: 'asset-id' }));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
id: 'album-123',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-id',
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
id: 'album-321',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-id',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
});
it('should allow a shared user to add assets', async () => {
mocks.access.album.checkSharedAlbumAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.user1, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
id: 'album-123',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
id: 'album-321',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123',
recipientId: 'admin_id',
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-321',
recipientId: 'admin_id',
});
});
it('should not allow a shared user with viewer access to add assets', async () => {
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithAdmin));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.user2, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
});
expect(mocks.album.update).not.toHaveBeenCalled();
});
it('should not allow a shared link user to add assets to multiple albums', async () => {
mocks.access.album.checkSharedLinkAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set());
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.adminSharedLink, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
id: 'album-123',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123',
recipientId: 'user-id',
});
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id,
new Set(['album-123']),
);
});
it('should allow adding assets shared via partner sharing', async () => {
mocks.access.album.checkOwnerAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
id: 'album-123',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
id: 'album-321',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['asset-1', 'asset-2', 'asset-3']),
);
});
it('should skip some duplicate assets', async () => {
mocks.access.album.checkOwnerAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds
.mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3']))
.mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', {
id: 'album-321',
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
});
it('should skip all duplicate assets', async () => {
mocks.access.album.checkOwnerAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2']));
await expect(
sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2'],
}),
).resolves.toEqual({
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.DUPLICATE,
});
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
});
it('should skip assets not shared with user', async () => {
mocks.access.album.checkSharedAlbumAccess
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
});
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['asset-1', 'asset-2', 'asset-3']),
false,
);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['asset-1', 'asset-2', 'asset-3']),
);
});
it('should not allow unauthorized access to the albums', async () => {
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
await expect(
sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
});
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled();
});
it('should not allow unauthorized shared link access to the album', async () => {
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
await expect(
sut.addAssetsToAlbums(authStub.adminSharedLink, {
albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'],
}),
).resolves.toEqual({
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
});
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
});
});
describe('removeAssets', () => { describe('removeAssets', () => {
it('should allow the owner to remove assets', async () => { it('should allow the owner to remove assets', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
+40 -1
View File
@@ -3,6 +3,8 @@ import {
AddUsersDto, AddUsersDto,
AlbumInfoDto, AlbumInfoDto,
AlbumResponseDto, AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto, AlbumStatisticsResponseDto,
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
@@ -13,7 +15,7 @@ import {
UpdateAlbumDto, UpdateAlbumDto,
UpdateAlbumUserDto, UpdateAlbumUserDto,
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
@@ -186,6 +188,43 @@ export class AlbumService extends BaseService {
return results; return results;
} }
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
const results: AlbumsAddAssetsResponseDto = {
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.DUPLICATE,
};
const successfulAssetIds: Set<string> = new Set();
for (const albumId of dto.albumIds) {
try {
const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds });
let success = false;
for (const res of albumResults) {
if (res.success) {
success = true;
results.success = true;
results.error = undefined;
successfulAssetIds.add(res.id);
} else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) {
results.error = BulkIdErrorReason.UNKNOWN;
}
}
if (success) {
results.albumSuccessCount++;
}
} catch {
if (results.error) {
results.error = BulkIdErrorReason.UNKNOWN;
}
}
}
results.assetSuccessCount = successfulAssetIds.size;
return results;
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
+36 -1
View File
@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import { factory, newUuid } from 'test/small.factory'; import { factory, newUuid } from 'test/small.factory';
@@ -134,6 +134,41 @@ describe(ApiKeyService.name, () => {
}); });
}); });
describe('getMine', () => {
it('should not work with a session token', async () => {
const session = factory.session();
const auth = factory.auth({ session });
mocks.apiKey.getById.mockResolvedValue(void 0);
await expect(sut.getMine(auth)).rejects.toBeInstanceOf(ForbiddenException);
expect(mocks.apiKey.getById).not.toHaveBeenCalled();
});
it('should throw an error if the key is not found', async () => {
const apiKey = factory.authApiKey();
const auth = factory.auth({ apiKey });
mocks.apiKey.getById.mockResolvedValue(void 0);
await expect(sut.getMine(auth)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id);
});
it('should get a key by id', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
mocks.apiKey.getById.mockResolvedValue(apiKey);
await sut.getById(auth, apiKey.id);
expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id);
});
});
describe('getById', () => { describe('getById', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
const auth = factory.auth(); const auth = factory.auth();
+14 -1
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { ApiKey } from 'src/database'; import { ApiKey } from 'src/database';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@@ -46,6 +46,19 @@ export class ApiKeyService extends BaseService {
await this.apiKeyRepository.delete(auth.user.id, id); await this.apiKeyRepository.delete(auth.user.id, id);
} }
async getMine(auth: AuthDto): Promise<APIKeyResponseDto> {
if (!auth.apiKey) {
throw new ForbiddenException('Not authenticated with an API Key');
}
const key = await this.apiKeyRepository.getById(auth.user.id, auth.apiKey.id);
if (!key) {
throw new BadRequestException('API Key not found');
}
return this.map(key);
}
async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> { async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
const key = await this.apiKeyRepository.getById(auth.user.id, id); const key = await this.apiKeyRepository.getById(auth.user.id, id);
if (!key) { if (!key) {
+14
View File
@@ -518,6 +518,20 @@ describe(AuthService.name, () => {
await expect(result).rejects.toThrow('Missing required permission: all'); await expect(result).rejects.toThrow('Missing required permission: all');
}); });
it('should not require any permission when metadata is set to `false`', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] });
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
const result = sut.authenticate({
headers: { 'x-api-key': 'auth_token' },
queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: false },
});
await expect(result).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) });
});
it('should return an auth dto', async () => { it('should return an auth dto', async () => {
const authUser = factory.authUser(); const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
+7 -2
View File
@@ -48,7 +48,8 @@ export type ValidateRequest = {
metadata: { metadata: {
sharedLinkRoute: boolean; sharedLinkRoute: boolean;
adminRoute: boolean; adminRoute: boolean;
permission?: Permission; /** `false` explicitly means no permission is required, which otherwise defaults to `all` */
permission?: Permission | false;
uri: string; uri: string;
}; };
}; };
@@ -187,7 +188,11 @@ export class AuthService extends BaseService {
throw new ForbiddenException('Forbidden'); throw new ForbiddenException('Forbidden');
} }
if (authDto.apiKey && !isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })) { if (
authDto.apiKey &&
requestedPermission !== false &&
!isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })
) {
throw new ForbiddenException(`Missing required permission: ${requestedPermission}`); throw new ForbiddenException(`Missing required permission: ${requestedPermission}`);
} }
+8 -8
View File
@@ -1,17 +1,17 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e
RUN apk add --no-cache tini bash ENV CHOKIDAR_USEPOLLING=true \
PATH="${PATH}:/usr/src/app/web/bin" \
COREPACK_HOME=/tmp
USER node RUN npm install --global corepack@latest && \
WORKDIR /usr/src/app corepack enable && corepack install -g pnpm && \
apk add --no-cache tini make bash && \
COPY --chown=node:node ./web/package* ./web/ mkdir -p /buildcache/pnpm-store && \
pnpm config set store-dir /buildcache/pnpm-store
WORKDIR /usr/src/app/web WORKDIR /usr/src/app/web
RUN npm ci
ENV CHOKIDAR_USEPOLLING=true \
PATH="${PATH}:/usr/src/app/web/bin"
EXPOSE 24678 EXPOSE 24678
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
+6 -10
View File
@@ -1,14 +1,11 @@
#!/usr/bin/env sh #!/usr/bin/env sh
TYPESCRIPT_SDK=/usr/src/app/open-api/typescript-sdk echo "Build dependencies for Immich Web"
cd /usr/src/app || exit
npm --prefix "$TYPESCRIPT_SDK" install
npm --prefix "$TYPESCRIPT_SDK" run build
cd /usr/src/app/web || exit 1
COUNT=0 COUNT=0
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}" UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
UPSTREAM="${UPSTREAM%/}"
until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
if [ $((COUNT % 10)) -eq 0 ]; then if [ $((COUNT % 10)) -eq 0 ]; then
echo "Waiting for $UPSTREAM to start..." echo "Waiting for $UPSTREAM to start..."
@@ -16,7 +13,6 @@ until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
COUNT=$((COUNT + 1)) COUNT=$((COUNT + 1))
sleep 1 sleep 1
done done
echo "Connected to $UPSTREAM, starting Immich Web..."
echo "Connected to $UPSTREAM" pnpm --filter @immich/sdk build
pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port 3000
npx vite dev --host 0.0.0.0 --port 3000
-10514
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -19,7 +19,7 @@
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",
"format": "prettier --check .", "format": "prettier --check .",
"format:fix": "prettier --write . && npm run format:i18n", "format:fix": "prettier --write . && npm run format:i18n",
"format:i18n": "npx --yes sort-json ../i18n/*.json", "format:i18n": "pnpx sort-json ../i18n/*.json",
"test": "vitest --run", "test": "vitest --run",
"test:cov": "vitest --coverage", "test:cov": "vitest --coverage",
"test:watch": "vitest dev", "test:watch": "vitest dev",
@@ -50,13 +50,13 @@
"justified-layout": "^4.1.0", "justified-layout": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"maplibre-gl": "^5.3.0", "maplibre-gl": "^5.6.2",
"pmtiles": "^4.3.0", "pmtiles": "^4.3.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"socket.io-client": "~4.8.0", "socket.io-client": "~4.8.0",
"svelte-gestures": "^5.1.3", "svelte-gestures": "^5.1.3",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
"svelte-maplibre": "^1.0.0", "svelte-maplibre": "^1.2.0",
"svelte-persisted-store": "^0.12.0", "svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0", "tabbable": "^6.2.0",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"
@@ -4,7 +4,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
@@ -20,14 +20,23 @@
let { asset, onAction, shared = false }: Props = $props(); let { asset, onAction, shared = false }: Props = $props();
const onClick = async () => { const onClick = async () => {
const album = await modalManager.show(AlbumPickerModal, { shared }); const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!album) { if (!albums || albums.length === 0) {
return; return;
} }
await addAssetsToAlbum(album.id, [asset.id]); if (albums.length === 1) {
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album }); const album = albums[0];
await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
} else {
await addAssetsToAlbums(
albums.map((a) => a.id),
[asset.id],
);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] });
}
}; };
</script> </script>
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils'; import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getAssetThumbnailUrl } from '$lib/utils'; import { getAssetThumbnailUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js'; import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk'; import { type AlbumResponseDto } from '@immich/sdk';
import { mdiCheckCircle } from '@mdi/js';
import type { Action } from 'svelte/action'; import type { Action } from 'svelte/action';
import AlbumListItemDetails from './album-list-item-details.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte';
@@ -10,10 +13,19 @@
album: AlbumResponseDto; album: AlbumResponseDto;
searchQuery?: string; searchQuery?: string;
selected: boolean; selected: boolean;
multiSelected?: boolean;
onAlbumClick: () => void; onAlbumClick: () => void;
onMultiSelect: () => void;
} }
let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props(); let {
album,
searchQuery = '',
selected = false,
multiSelected = false,
onAlbumClick,
onMultiSelect,
}: Props = $props();
const scrollIntoViewIfSelected: Action = (node) => { const scrollIntoViewIfSelected: Action = (node) => {
$effect(() => { $effect(() => {
@@ -37,33 +49,127 @@
albumName.slice(findIndex + findLength), albumName.slice(findIndex + findLength),
]; ];
}); });
const handleMultiSelectClicked = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
onMultiSelect();
};
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let mouseOver = $state(false);
const onMouseEnter = () => {
if (usingMobileDevice) {
return;
}
mouseOver = true;
};
const onMouseLeave = () => {
mouseOver = false;
};
let timer: ReturnType<typeof setTimeout> | null = null;
const preventContextMenu = (evt: Event) => evt.preventDefault();
const disposeables: (() => void)[] = [];
const clearLongPressTimer = () => {
if (!timer) {
return;
}
clearTimeout(timer);
timer = null;
for (const dispose of disposeables) {
dispose();
}
disposeables.length = 0;
};
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false;
const start = () => {
didPress = false;
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => {
onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true });
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
didPress = true;
}, 350);
};
const click = (e: MouseEvent) => {
if (!didPress) {
return;
}
e.stopPropagation();
e.preventDefault();
};
element.addEventListener('click', click);
element.addEventListener('pointerdown', start, true);
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
return {
destroy: () => {
element.removeEventListener('click', click);
element.removeEventListener('pointerdown', start, true);
element.removeEventListener('pointerup', clearLongPressTimer, true);
},
};
}
</script> </script>
<button <div
type="button" role="group"
onclick={onAlbumClick} class={[
use:scrollIntoViewIfSelected 'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
class="flex w-full gap-4 px-6 py-2 text-start transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" { 'bg-primary/10 hover:bg-primary/10': multiSelected },
class:bg-gray-200={selected} ]}
class:dark:bg-gray-700={selected} onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
> >
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300"> <button
{#if album.albumThumbnailAssetId} type="button"
<img onclick={onAlbumClick}
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)} use:scrollIntoViewIfSelected
alt={album.albumName} class="flex gap-4 px-2 py-2 text-start"
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg" class:bg-gray-200={selected}
data-testid="album-image" class:dark:bg-gray-700={selected}
draggable="false" use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
/> >
{/if} <span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
</span> {#if album.albumThumbnailAssetId}
<span class="flex h-12 flex-col items-start justify-center overflow-hidden"> <img
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap" src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span alt={album.albumName}
> class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
<span class="flex gap-1 text-sm"> data-testid="album-image"
<AlbumListItemDetails {album} /> draggable="false"
/>
{/if}
</span> </span>
</span> <span class="flex h-full flex-col items-start justify-center overflow-hidden">
</button> <span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
>
<span class="flex gap-1 text-sm">
<AlbumListItemDetails {album} />
</span>
</span>
</button>
{#if mouseOver || multiSelected}
<button
type="button"
onclick={handleMultiSelectClicked}
class="p-3 focus:outline-none hover:cursor-pointer"
role="checkbox"
tabindex={-1}
aria-checked={selected}
>
{#if multiSelected}
<div class="rounded-full">
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
{/if}
</button>
{/if}
</div>
@@ -415,9 +415,9 @@
<p> <p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP {getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p> </p>
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if} {/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if} {/if}
{#if asset.exifInfo?.fileSizeInByte} {#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p> <p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
@@ -2,7 +2,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import type { OnAddToAlbum } from '$lib/utils/actions'; import type { OnAddToAlbum } from '$lib/utils/actions';
import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -18,15 +18,23 @@
const { getAssets } = getAssetControlContext(); const { getAssets } = getAssetControlContext();
const onClick = async () => { const onClick = async () => {
const album = await modalManager.show(AlbumPickerModal, { shared }); const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!albums || albums.length === 0) {
if (!album) {
return; return;
} }
const assetIds = [...getAssets()].map(({ id }) => id); const assetIds = [...getAssets()].map(({ id }) => id);
await addAssetsToAlbum(album.id, assetIds); if (albums.length === 1) {
onAddToAlbum(assetIds, album.id); const album = albums[0];
await addAssetsToAlbum(album.id, assetIds);
onAddToAlbum(assetIds, album.id);
} else {
await addAssetsToAlbums(
albums.map(({ id }) => id),
assetIds,
);
onAddToAlbum(assetIds, albums[0].id);
}
}; };
</script> </script>
@@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
type: AlbumModalRowType.ALBUM_ITEM, type: AlbumModalRowType.ALBUM_ITEM,
album, album,
selected, selected,
multiSelected: false,
}); });
describe('Album Modal', () => { describe('Album Modal', () => {
it('non-shared with no albums configured yet shows message and new', () => { it('non-shared with no albums configured yet shows message and new', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const modalRows = converter.toModalRows('', [], [], -1); const modalRows = converter.toModalRows('', [], [], -1, []);
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]);
}); });
it('non-shared with no matching albums shows message and new', () => { it('non-shared with no matching albums shows message and new', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1); const modalRows = converter.toModalRows(
'matches_nothing',
[],
[albumFactory.build({ albumName: 'Holidays' })],
-1,
[],
);
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
}); });
@@ -44,7 +51,7 @@ describe('Album Modal', () => {
it('non-shared displays single albums', () => { it('non-shared displays single albums', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1); const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
expect(modalRows).toStrictEqual([ expect(modalRows).toStrictEqual([
createNewAlbumRow(false), createNewAlbumRow(false),
@@ -64,6 +71,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1, -1,
[],
); );
expect(modalRows).toStrictEqual([ expect(modalRows).toStrictEqual([
@@ -90,6 +98,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1, -1,
[],
); );
expect(modalRows).toStrictEqual([ expect(modalRows).toStrictEqual([
@@ -112,6 +121,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1, -1,
[],
); );
expect(modalRows).toStrictEqual([ expect(modalRows).toStrictEqual([
@@ -125,7 +135,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []);
expect(modalRows).toStrictEqual([ expect(modalRows).toStrictEqual([
createNewAlbumRow(true), createNewAlbumRow(true),
@@ -141,7 +151,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []);
expect(modalRows).toStrictEqual([ expect(modalRows).toStrictEqual([
createNewAlbumRow(false), createNewAlbumRow(false),
@@ -157,7 +167,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []);
expect(modalRows).toStrictEqual([ expect(modalRows).toStrictEqual([
createNewAlbumRow(false), createNewAlbumRow(false),
@@ -16,6 +16,7 @@ export enum AlbumModalRowType {
export type AlbumModalRow = { export type AlbumModalRow = {
type: AlbumModalRowType; type: AlbumModalRowType;
selected?: boolean; selected?: boolean;
multiSelected?: boolean;
text?: string; text?: string;
album?: AlbumResponseDto; album?: AlbumResponseDto;
}; };
@@ -41,6 +42,7 @@ export class AlbumModalRowConverter {
recentAlbums: AlbumResponseDto[], recentAlbums: AlbumResponseDto[],
albums: AlbumResponseDto[], albums: AlbumResponseDto[],
selectedRowIndex: number, selectedRowIndex: number,
multiSelectedAlbumIds: string[],
): AlbumModalRow[] { ): AlbumModalRow[] {
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
@@ -64,6 +66,7 @@ export class AlbumModalRowConverter {
rows.push({ rows.push({
type: AlbumModalRowType.ALBUM_ITEM, type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow, selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album, album,
}); });
} }
@@ -81,6 +84,7 @@ export class AlbumModalRowConverter {
rows.push({ rows.push({
type: AlbumModalRowType.ALBUM_ITEM, type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents, selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album, album,
}); });
} }
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { Action } from 'svelte/action';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils'; import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { Action } from 'svelte/action';
interface Props { interface Props {
searchQuery?: string; searchQuery?: string;
@@ -54,7 +54,7 @@
<div <div
class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors {isSelected class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors {isSelected
? 'bg-green-400/90' ? 'bg-green-400/90'
: 'bg-red-300/90'}" : 'bg-red-300/90'} text-black"
> >
{isSelected ? $t('keep') : $t('to_trash')} {isSelected ? $t('keep') : $t('to_trash')}
</div> </div>
@@ -112,7 +112,7 @@
]} ]}
/> />
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-16"> <div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-4">
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between"> <div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
<!-- MARK ALL BUTTONS --> <!-- MARK ALL BUTTONS -->
<div class="flex text-xs text-black"> <div class="flex text-xs text-black">
+64 -7
View File
@@ -6,8 +6,8 @@
isSelectableRowType, isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils'; } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { albumViewSettings } from '$lib/stores/preferences.store'; import { albumViewSettings } from '$lib/stores/preferences.store';
import { type AlbumResponseDto, createAlbum, getAllAlbums } from '@immich/sdk'; import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui'; import { Button, Modal, ModalBody } from '@immich/ui';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte'; import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
@@ -21,7 +21,7 @@
interface Props { interface Props {
shared: boolean; shared: boolean;
onClose: (album?: AlbumResponseDto) => void; onClose: (albums?: AlbumResponseDto[]) => void;
} }
let { shared, onClose }: Props = $props(); let { shared, onClose }: Props = $props();
@@ -32,13 +32,54 @@
loading = false; loading = false;
}); });
const multiSelectedAlbumIds: string[] = $state([]);
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder); const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder);
const albumModalRows = $derived(rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex)); const albumModalRows = $derived(
rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds),
);
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length); const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
const onNewAlbum = async (name: string) => { const onNewAlbum = async (name: string) => {
const album = await createAlbum({ createAlbumDto: { albumName: name } }); const album = await createAlbum({ createAlbumDto: { albumName: name } });
onClose(album); onClose([album]);
};
const handleAlbumClick = (album?: AlbumResponseDto) => {
if (multiSelectActive) {
handleMultiSelect(album);
return;
}
if (album) {
onClose([album]);
return;
}
onClose();
};
const handleMultiSelect = (album?: AlbumResponseDto) => {
const selectedAlbum = album ?? albumModalRows.find(({ selected }) => selected)?.album;
if (!selectedAlbum) {
return;
}
const index = multiSelectedAlbumIds.indexOf(selectedAlbum.id);
if (index === -1) {
multiSelectedAlbumIds.push(selectedAlbum.id);
return;
}
multiSelectedAlbumIds.splice(index, 1);
};
const handleMultiSubmit = () => {
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
if (albums.size > 0) {
onClose([...albums]);
} else {
onClose();
}
}; };
const onEnter = async () => { const onEnter = async () => {
@@ -53,8 +94,12 @@
break; break;
} }
case AlbumModalRowType.ALBUM_ITEM: { case AlbumModalRowType.ALBUM_ITEM: {
if (multiSelectActive) {
handleMultiSubmit();
break;
}
if (item.album) { if (item.album) {
onClose(item.album); onClose([item.album]);
} }
break; break;
} }
@@ -88,6 +133,11 @@
await onEnter(); await onEnter();
break; break;
} }
case 'm': {
e.preventDefault();
handleMultiSelect();
break;
}
default: { default: {
selectedRowIndex = -1; selectedRowIndex = -1;
} }
@@ -133,13 +183,20 @@
<AlbumListItem <AlbumListItem
album={row.album} album={row.album}
selected={row.selected || false} selected={row.selected || false}
multiSelected={row.multiSelected}
searchQuery={search} searchQuery={search}
onAlbumClick={() => onClose(row.album)} onAlbumClick={() => handleAlbumClick(row.album)}
onMultiSelect={() => handleMultiSelect(row.album)}
/> />
{/if} {/if}
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
{#if multiSelectActive}
<Button size="small" shape="round" fullWidth onclick={handleMultiSubmit}
>{$t('add_to_albums_count', { values: { count: multiSelectedAlbumIds.length } })}</Button
>
{/if}
</ModalBody> </ModalBody>
</Modal> </Modal>
+48
View File
@@ -16,7 +16,9 @@ import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links'; import { asQueryString } from '$lib/utils/shared-links';
import { import {
addAssetsToAlbum as addAssets, addAssetsToAlbum as addAssets,
addAssetsToAlbums as addToAlbums,
AssetVisibility, AssetVisibility,
BulkIdErrorReason,
bulkTagAssets, bulkTagAssets,
createStack, createStack,
deleteAssets, deleteAssets,
@@ -74,6 +76,52 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
} }
}; };
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => {
const result = await addToAlbums({
...authManager.params,
albumsAddAssetsDto: {
albumIds,
assetIds,
},
});
if (!showNotification) {
return result;
}
if (showNotification) {
const $t = get(t);
if (result.error === BulkIdErrorReason.Duplicate) {
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }),
});
return result;
}
if (result.error) {
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }),
});
return result;
}
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_added_to_albums_count', {
values: {
albumTotal: albumIds.length,
assetTotal: assetIds.length,
},
}),
});
return result;
}
};
export const tagAssets = async ({ export const tagAssets = async ({
assetIds, assetIds,
tagIds, tagIds,
@@ -1,10 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { import {
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { AppRoute } from '$lib/constants';
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
@@ -15,7 +19,16 @@
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk'; import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
import { Button, HStack, IconButton, modalManager, Text } from '@immich/ui'; import { Button, HStack, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js'; import {
mdiCheckOutline,
mdiChevronLeft,
mdiChevronRight,
mdiInformationOutline,
mdiKeyboard,
mdiPageFirst,
mdiPageLast,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -47,6 +60,19 @@
}; };
let duplicates = $state(data.duplicates); let duplicates = $state(data.duplicates);
const correctDuplicatesIndex = (index: number) => {
return Math.max(0, Math.min(index, duplicates.length - 1));
};
let duplicatesIndex = $derived(
(() => {
const indexParam = page.url.searchParams.get('index') ?? '0';
const parsedIndex = Number.parseInt(indexParam, 10);
return correctDuplicatesIndex(Number.isNaN(parsedIndex) ? 0 : parsedIndex);
})(),
);
let hasDuplicates = $derived(duplicates.length > 0); let hasDuplicates = $derived(duplicates.length > 0);
const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => { const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => {
if (prompt && confirmText) { if (prompt && confirmText) {
@@ -85,6 +111,7 @@
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
deletedNotification(trashIds.length); deletedNotification(trashIds.length);
await correctDuplicatesIndexAndGo(duplicatesIndex);
}, },
trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined, trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined,
trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined, trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined,
@@ -96,6 +123,7 @@
const duplicateAssetIds = assets.map((asset) => asset.id); const duplicateAssetIds = assets.map((asset) => asset.id);
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
await correctDuplicatesIndexAndGo(duplicatesIndex);
}; };
const handleDeduplicateAll = async () => { const handleDeduplicateAll = async () => {
@@ -126,6 +154,9 @@
duplicates = []; duplicates = [];
deletedNotification(idsToDelete.length); deletedNotification(idsToDelete.length);
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
}, },
prompt, prompt,
confirmText, confirmText,
@@ -144,13 +175,39 @@
message: $t('resolved_all_duplicates'), message: $t('resolved_all_duplicates'),
type: NotificationType.Info, type: NotificationType.Info,
}); });
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
}, },
$t('bulk_keep_duplicates_confirmation', { values: { count: ids.length } }), $t('bulk_keep_duplicates_confirmation', { values: { count: ids.length } }),
$t('confirm'), $t('confirm'),
); );
}; };
const handleFirst = async () => {
await correctDuplicatesIndexAndGo(0);
};
const handlePrevious = async () => {
await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0));
};
const handleNext = async () => {
await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1));
};
const handleLast = async () => {
await correctDuplicatesIndexAndGo(duplicates.length - 1);
};
const correctDuplicatesIndexAndGo = async (index: number) => {
page.url.searchParams.set('index', correctDuplicatesIndex(index).toString());
await goto(`${AppRoute.DUPLICATES}?${page.url.searchParams.toString()}`);
};
</script> </script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}> <UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
{#snippet buttons()} {#snippet buttons()}
<HStack gap={0}> <HStack gap={0}>
@@ -203,13 +260,61 @@
/> />
</div> </div>
{#key duplicates[0].duplicateId} {#key duplicates[duplicatesIndex].duplicateId}
<DuplicatesCompareControl <DuplicatesCompareControl
assets={duplicates[0].assets} assets={duplicates[duplicatesIndex].assets}
onResolve={(duplicateAssetIds, trashIds) => onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)} handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)} onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)}
/> />
<div class="max-w-216 mx-auto mb-16">
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
<div class="flex text-xs text-black">
<Button
size="small"
leadingIcon={mdiPageFirst}
color="primary"
class="flex place-items-center rounded-s-full gap-2 px-2 sm:px-4"
onclick={handleFirst}
disabled={duplicatesIndex === 0}
>
{$t('first')}
</Button>
<Button
size="small"
leadingIcon={mdiChevronLeft}
color="primary"
class="flex place-items-center rounded-e-full gap-2 px-2 sm:px-4"
onclick={handlePrevious}
disabled={duplicatesIndex === 0}
>
{$t('previous')}
</Button>
</div>
<div class="flex text-xs text-black">
<Button
size="small"
trailingIcon={mdiChevronRight}
color="primary"
class="flex place-items-center rounded-s-full gap-2 px-2 sm:px-4"
onclick={handleNext}
disabled={duplicatesIndex === duplicates.length - 1}
>
{$t('next')}
</Button>
<Button
size="small"
trailingIcon={mdiPageLast}
color="primary"
class="flex place-items-center rounded-e-full gap-2 px-2 sm:px-4"
onclick={handleLast}
disabled={duplicatesIndex === duplicates.length - 1}
>
{$t('last')}
</Button>
</div>
</div>
</div>
{/key} {/key}
{:else} {:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center"> <p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
+4 -1
View File
@@ -69,8 +69,11 @@ function isIgnoredFileType(pathname: string): boolean {
} }
function isIgnoredPath(pathname: string): boolean { function isIgnoredPath(pathname: string): boolean {
return /^\/(src|api)(\/.*)?$/.test(pathname) || /^\/(node_modules|@vite|@id)(\/.*)?$/.test(pathname); return (
/^\/(src|api)(\/.*)?$/.test(pathname) || /node_modules/.test(pathname) || /^\/@(vite|id)(\/.*)?$/.test(pathname)
);
} }
function isAssetRequest(pathname: string): boolean { function isAssetRequest(pathname: string): boolean {
return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname); return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname);
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite';
import { svelteTesting } from '@testing-library/svelte/vite'; import { svelteTesting } from '@testing-library/svelte/vite';
import path from 'node:path'; import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite'; import { defineConfig, type UserConfig } from 'vite';
const upstream = { const upstream = {
target: process.env.IMMICH_SERVER_URL || 'http://immich-server:2283/', target: process.env.IMMICH_SERVER_URL || 'http://immich-server:2283/',
@@ -59,4 +59,4 @@ export default defineConfig({
hooks: 'list', hooks: 'list',
}, },
}, },
}); } as UserConfig);