Compare commits

..

14 Commits

Author SHA1 Message Date
midzelis
12e31fafa1 use rolldown for vite/svelte 2025-08-19 09:09:28 -04:00
midzelis
0167ec1668 feat: experimental swc support 2025-08-19 09:08:05 -04:00
Min Idzelis
845b0f2073 chore: use pnpm for builds (#19752)
* 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

* 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

* 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

* Use 'server/.nvmrc' for fix-format.yml GHA

* Delete npm locks

* 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

* 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

* Fix typescript-sdk package issues

• Remove unknown "build" dependency that was incorrectly added to package.json
• Update pnpm-lock.yaml to reflect dependency removal

* 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

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-08-19 08:55:24 -04:00
Alex
acb1e513a7 chore: post release tasks (#21042) 2025-08-19 08:37:17 -04:00
renovate[bot]
4d4e54967d chore(deps): update grafana/grafana docker tag to v12.1.1 (#21048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:02:57 +01:00
renovate[bot]
e2dcebfe6c chore(deps): update dependency @types/node to ^22.17.1 (#21047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:13:49 +02: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
87 changed files with 28379 additions and 62624 deletions

View File

@@ -49,10 +49,11 @@ fix_permissions() {
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
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}/e2e/node_modules" \
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \

View File

@@ -8,21 +8,13 @@ services:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
- ..:/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:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
- /etc/localtime:/etc/localtime:ro
immich-web:
env_file: !reset []
immich-machine-learning:
env_file: !reset []
database:
env_file: !reset []
environment: !override
@@ -33,17 +25,10 @@ services:
POSTGRES_HOST_AUTH_METHOD: md5
volumes:
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
redis:
env_file: !reset []
volumes:
# 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:
upload2-devcontainer-volume:
postgres-devcontainer-volume:

View File

@@ -3,15 +3,20 @@
# shellcheck disable=SC1091
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 ""
cd "${IMMICH_WORKSPACE}/server" || (
log "Immich workspace not found"
log "Immich workspace not found"jj
exit 1
)
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 ..."
sleep 3
done

View File

@@ -3,6 +3,13 @@
# shellcheck disable=SC1091
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 ""
cd "${IMMICH_WORKSPACE}/web" || (
@@ -16,7 +23,7 @@ until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_
done
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 ..."
sleep 3
done

View File

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

28
.github/package-lock.json generated vendored
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"
}
}
}
}

View File

@@ -33,21 +33,24 @@ jobs:
with:
persist-credentials: false
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Prepare SDK
run: npm ci --prefix ../open-api/typescript-sdk/
- name: Build SDK
run: npm run build --prefix ../open-api/typescript-sdk/
- run: npm ci
- run: npm run build
- run: npm publish
- name: Setup typescript-sdk
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm publish
if: ${{ github.event_name == 'release' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -55,21 +55,24 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './docs/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run npm install
run: npm ci
- name: Run install
run: pnpm install
- name: Check formatting
run: npm run format
run: pnpm format
- name: Run build
run: npm run build
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

View File

@@ -32,8 +32,8 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Fix formatting
run: make install-all && make format-all

View File

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

View File

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

39
.pnpmfile.cjs Normal file
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;
},
},
};

View File

@@ -56,7 +56,8 @@
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.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,
"typescript.preferences.importModuleSpecifier": "non-relative"

View File

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

View File

@@ -1,19 +1,14 @@
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
COPY cli/package.json cli/package-lock.json ./
RUN npm ci
COPY cli .
RUN npm run build
COPY package* pnpm* .pnpmfile.cjs ./
COPY ./cli ./cli/
COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/
RUN corepack enable pnpm && \
pnpm install --filter @immich/sdk --filter @immich/cli --frozen-lockfile && \
pnpm --filter @immich/sdk build && \
pnpm --filter @immich/cli build
WORKDIR /import
ENTRYPOINT ["node", "/usr/src/app/dist"]
ENTRYPOINT ["node", "/usr/src/app/cli/dist"]

4600
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.17.0",
"@types/node": "^22.17.1",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -21,17 +21,16 @@ services:
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
user: "${UID:-1000}:${GID:-1000}"
build:
context: ../
dockerfile: server/Dockerfile
target: dev
restart: unless-stopped
volumes:
- ../server:/usr/src/app/server
- ../open-api:/usr/src/app/open-api
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /usr/src/app/server/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@@ -58,8 +57,12 @@ services:
- 9231:9231
- 2283:2283
depends_on:
- redis
- database
redis:
condition: service_started
database:
condition: service_started
init:
condition: service_completed_successfully
healthcheck:
disable: false
@@ -68,6 +71,7 @@ services:
image: immich-web-dev:latest
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0
user: "${UID:-1000}:${GID:-1000}"
build:
context: ../
dockerfile: web/Dockerfile
@@ -78,18 +82,17 @@ services:
- 3000:3000
- 24678:24678
volumes:
- ../web:/usr/src/app/web
- ../i18n:/usr/src/app/i18n
- ../open-api/:/usr/src/app/open-api/
# - ../../ui:/usr/ui
- /usr/src/app/web/node_modules
- ..:/usr/src/app
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
- immich-server
immich-server:
condition: service_started
init:
condition: service_completed_successfully
immich-machine-learning:
container_name: immich_machine_learning
@@ -157,6 +160,14 @@ services:
# volumes:
# - 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:
model-cache:
prometheus-data:

View File

@@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.1.0-ubuntu@sha256:397aa30dd1af16cb6c5c9879498e467973a7f87eacf949f6d5a29407a3843809
image: grafana/grafana:12.1.1-ubuntu@sha256:d1da838234ff2de93e0065ee1bf0e66d38f948dcc5d718c25fa6237e14b4424a
volumes:
- grafana-data:/var/lib/grafana

20545
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

1
e2e/.gitignore vendored
View File

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

7419
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.17.0",
"@types/node": "^22.17.1",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",

View File

@@ -79,7 +79,7 @@ export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
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[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];

View File

@@ -28,6 +28,9 @@
"add_to_album": "Add to album",
"add_to_album_bottom_sheet_added": "Added to {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_url": "Add URL",
"added_to_archive": "Added to archive",
@@ -497,7 +500,9 @@
"assets": "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_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_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
"assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
"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_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_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums",
"authorized_devices": "Authorized Devices",
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
"automatic_endpoint_switching_title": "Automatic URL switching",
@@ -1056,6 +1062,7 @@
"filter_people": "Filter people",
"filter_places": "Filter places",
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
"folder": "Folder",
"folder_not_found": "Folder not found",
@@ -1177,6 +1184,7 @@
"language_search_hint": "Search languages...",
"language_setting_description": "Select your preferred language",
"large_files": "Large Files",
"last": "Last",
"last_seen": "Last seen",
"latest_version": "Latest Version",
"latitude": "Latitude",

View File

@@ -1,6 +1,6 @@
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
@@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
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 \
--mount=type=bind,source=uv.lock,target=uv.lock \
--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; \
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
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 && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

160
machine-learning/uv.lock generated
View File

@@ -1341,7 +1341,7 @@ wheels = [
[[package]]
name = "locust"
version = "2.38.0"
version = "2.38.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "configargparse" },
@@ -1361,9 +1361,9 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ 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 = [
{ 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]]
@@ -1838,79 +1838,79 @@ wheels = [
[[package]]
name = "orjson"
version = "3.11.1"
version = "3.11.2"
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
@@ -2919,23 +2919,23 @@ wheels = [
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250516"
version = "6.0.12.20250809"
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 = [
{ 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]]
name = "types-requests"
version = "2.32.4.20250611"
version = "2.32.4.20250809"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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 = [
{ 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]]

View File

@@ -649,7 +649,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -793,7 +793,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -823,7 +823,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -857,7 +857,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -900,7 +900,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -940,7 +940,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -979,7 +979,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1023,7 +1023,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1064,7 +1064,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 213;
CURRENT_PROJECT_VERSION = 215;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

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

View File

@@ -8,7 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount }
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -40,6 +40,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum),
SortLocalAlbumsBy.name => OrderingTerm.asc(_db.localAlbumEntity.name),
SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount),
SortLocalAlbumsBy.newestAsset => OrderingTerm.desc(_db.localAlbumEntity.updatedAt),
});
}
query.orderBy(orderings);
@@ -319,7 +320,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)])
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(1);
final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();

View File

@@ -18,7 +18,7 @@ final localAlbumServiceProvider = Provider<LocalAlbumService>(
);
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>(

View File

@@ -77,12 +77,14 @@ Class | Method | HTTP request | Description
*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} |
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
*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} |
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} |
*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities |
*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics |
*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* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
@@ -299,6 +301,8 @@ Class | Method | HTTP request | Description
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
- [AlbumUserRole](doc//AlbumUserRole.md)
- [AlbumsAddAssetsDto](doc//AlbumsAddAssetsDto.md)
- [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md)
- [AlbumsResponse](doc//AlbumsResponse.md)
- [AlbumsUpdate](doc//AlbumsUpdate.md)
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
@@ -333,6 +337,7 @@ Class | Method | HTTP request | Description
- [AudioCodec](doc//AudioCodec.md)
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
- [AvatarUpdate](doc//AvatarUpdate.md)
- [BulkIdErrorReason](doc//BulkIdErrorReason.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md)
- [CLIPConfig](doc//CLIPConfig.md)

View File

@@ -79,6 +79,8 @@ part 'model/album_user_add_dto.dart';
part 'model/album_user_create_dto.dart';
part 'model/album_user_response_dto.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_update.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/auth_status_response_dto.dart';
part 'model/avatar_update.dart';
part 'model/bulk_id_error_reason.dart';
part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart';
part 'model/clip_config.dart';

View File

@@ -91,6 +91,73 @@ class AlbumsApi {
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.
///
/// Note: This method returns the HTTP [Response].

View File

@@ -213,6 +213,47 @@ class APIKeysApi {
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.
///
/// Note: This method returns the HTTP [Response].

View File

@@ -212,6 +212,10 @@ class ApiClient {
return AlbumUserResponseDto.fromJson(value);
case 'AlbumUserRole':
return AlbumUserRoleTypeTransformer().decode(value);
case 'AlbumsAddAssetsDto':
return AlbumsAddAssetsDto.fromJson(value);
case 'AlbumsAddAssetsResponseDto':
return AlbumsAddAssetsResponseDto.fromJson(value);
case 'AlbumsResponse':
return AlbumsResponse.fromJson(value);
case 'AlbumsUpdate':
@@ -280,6 +284,8 @@ class ApiClient {
return AuthStatusResponseDto.fromJson(value);
case 'AvatarUpdate':
return AvatarUpdate.fromJson(value);
case 'BulkIdErrorReason':
return BulkIdErrorReasonTypeTransformer().decode(value);
case 'BulkIdResponseDto':
return BulkIdResponseDto.fromJson(value);
case 'BulkIdsDto':

View File

@@ -79,6 +79,9 @@ String parameterToString(dynamic value) {
if (value is AudioCodec) {
return AudioCodecTypeTransformer().encode(value).toString();
}
if (value is BulkIdErrorReason) {
return BulkIdErrorReasonTypeTransformer().encode(value).toString();
}
if (value is CQMode) {
return CQModeTypeTransformer().encode(value).toString();
}

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',
};
}

View File

@@ -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',
};
}

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;
}

View File

@@ -15,7 +15,7 @@ function dart {
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
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
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 {
npx --yes 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
pnpx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm --filter @immich/sdk install --frozen-lockfile
pnpm --filter @immich/sdk build
}
# 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
dart

View File

@@ -940,6 +940,67 @@
"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": {
"get": {
"operationId": "getAlbumStatistics",
@@ -1488,6 +1549,38 @@
"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}": {
"delete": {
"operationId": "deleteApiKey",
@@ -9889,6 +9982,55 @@
],
"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": {
"properties": {
"defaultAssetOrder": {
@@ -10845,6 +10987,15 @@
},
"type": "object"
},
"BulkIdErrorReason": {
"enum": [
"duplicate",
"no_permission",
"not_found",
"unknown"
],
"type": "string"
},
"BulkIdResponseDto": {
"properties": {
"error": {

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"
}
}
}

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.17.0",
"@types/node": "^22.17.1",
"typescript": "^5.3.3"
},
"repository": {

View File

@@ -384,6 +384,16 @@ export type CreateAlbumDto = {
assetIds?: string[];
description?: string;
};
export type AlbumsAddAssetsDto = {
albumIds: string[];
assetIds: string[];
};
export type AlbumsAddAssetsResponseDto = {
albumSuccessCount: number;
assetSuccessCount: number;
error?: BulkIdErrorReason;
success: boolean;
};
export type AlbumStatisticsResponseDto = {
notShared: number;
owned: number;
@@ -1864,6 +1874,26 @@ export function createAlbum({ 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.
*/
@@ -2027,6 +2057,14 @@ export function createApiKey({ 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.
*/
@@ -4545,6 +4583,12 @@ export enum AssetTypeEnum {
Audio = "AUDIO",
Other = "OTHER"
}
export enum BulkIdErrorReason {
Duplicate = "duplicate",
NoPermission = "no_permission",
NotFound = "not_found",
Unknown = "unknown"
}
export enum Error {
Duplicate = "duplicate",
NoPermission = "no_permission",

10
package.json Normal file
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"
}
}

26158
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

58
pnpm-workspace.yaml Normal file
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
server/.npmignore Normal file
View File

@@ -0,0 +1,5 @@
src
tsconfig*
eslint*
pnpm*
coverage

View File

@@ -1,14 +1,23 @@
# dev build
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
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" \
IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \
@@ -17,23 +26,19 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server
RUN rm -rf /usr/src/app
RUN apt-get update --allow-releaseinfo-change && \
apt-get install sudo inetutils-ping openjdk-11-jre-headless \
vim nano \
-y --no-install-recommends --fix-missing
RUN usermod -aG sudo node
RUN echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN mkdir -p /workspaces/immich
RUN chown node -R /workspaces
COPY --chown=node:node --chmod=777 ../.devcontainer/server/*.sh /immich-devcontainer/
RUN usermod -aG sudo node && \
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
mkdir -p /workspaces/immich
USER node
COPY --chown=node:node .. /tmp/create-dep-cache/
WORKDIR /tmp/create-dep-cache
RUN make ci-all && rm -rf /tmp/create-dep-cache
RUN chown node:node -R /workspaces
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
WORKDIR /workspaces/immich
FROM dev-container-server AS dev-container-mobile
USER root
@@ -62,38 +67,55 @@ RUN mkdir -p ${FLUTTER_HOME} \
&& rm flutter.tar.xz \
&& chown -R node ${FLUTTER_HOME}
USER node
RUN sudo apt-get update \
&& wget -qO- https://dcm.dev/pgp-key.public | sudo 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 \
&& sudo apt-get update \
&& sudo apt-get install dcm -y
RUN apt-get update \
&& 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' | tee /etc/apt/sources.list.d/dart_stable.list \
&& apt-get update \
&& apt-get install dcm -y
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 run build
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
RUN npm install --global corepack@latest && \
corepack enable pnpm
# web build
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS web
# server production build
FROM prod-builder-base AS server-prod
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 ./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
RUN npm ci && npm run build
FROM prod-builder-base AS cli-prod
WORKDIR /usr/src/app/web
RUN npm ci && npm run build
COPY ./package* ./pnpm* .pnpmfile.cjs ./
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
WORKDIR /usr/src/app
@@ -101,16 +123,13 @@ ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
COPY --from=prod /usr/src/app/server/node_modules ./server/node_modules
COPY --from=prod /usr/src/app/server/dist ./server/dist
COPY --from=prod /usr/src/app/server/bin ./server/bin
COPY --from=web /usr/src/app/web/build /build/www
COPY ./server/resources ./server/resources
COPY ./server/package.json server/package-lock.json ./
COPY --from=server-prod /output/server-pruned ./server
COPY --from=web-prod /usr/src/app/web/build /build/www
COPY --from=cli-prod /output/cli-pruned ./cli
RUN ln -s ./cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
RUN npm install -g @immich/cli && npm cache clean --force
ENV PATH="${PATH}:/usr/src/app/server/bin"
ARG BUILD_ID

View File

@@ -5,5 +5,5 @@ if [ "$IMMICH_ENV" != "development" ]; then
exit 1
fi
cd /usr/src/app/server || exit 1
npm exec nest -- start --debug "0.0.0.0:9230" --watch -- "$@"
cd /usr/src/app || exit
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"

View File

@@ -5,6 +5,12 @@
"compilerOptions": {
"deleteOutDir": true,
"webpack": false,
"builder": {
"type": "swc",
"options": {
"extensions": [".js", ".ts", ".jsx", ".tsx"]
}
},
"plugins": [
{
"name": "@nestjs/swagger",

18830
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
"migrations:run": "node ./dist/bin/migrations.js run",
"schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
"schema:reset": "npm run schema:drop && npm run migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:open-api": "nest build -b tsc && node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
"email:dev": "email dev -p 3050 --dir src/emails"
},
@@ -119,7 +119,8 @@
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.4",
"@swc/core": "^1.4.14",
"@swc/cli": "^0.7.8",
"@swc/core": "^1.13.0",
"@testcontainers/postgresql": "^11.0.0",
"@testcontainers/redis": "^11.0.0",
"@types/archiver": "^6.0.0",
@@ -135,7 +136,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^22.17.0",
"@types/node": "^22.17.1",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -1,5 +1,6 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
@@ -41,7 +42,10 @@ export const SALT_ROUNDS = 10;
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 AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });

View File

@@ -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', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });

View File

@@ -4,6 +4,8 @@ import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto,
CreateAlbumDto,
GetAlbumsDto,
@@ -77,6 +79,12 @@ export class AlbumController {
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')
@Authenticated({ permission: Permission.AlbumAssetDelete })
removeAssetFromAlbum(

View File

@@ -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 { ApiKeyService } from 'src/services/api-key.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(APIKeyController.name, () => {
describe(ApiKeyController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(ApiKeyService);
beforeAll(async () => {
ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]);
ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
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', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);

View File

@@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('API Keys')
@Controller('api-keys')
export class APIKeyController {
export class ApiKeyController {
constructor(private service: ApiKeyService) {}
@Post()
@@ -24,6 +24,12 @@ export class APIKeyController {
return this.service.getAll(auth);
}
@Get('me')
@Authenticated({ permission: false })
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
return this.service.getMine(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.ApiKeyRead })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {

View File

@@ -1,6 +1,6 @@
import { ActivityController } from 'src/controllers/activity.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 { AssetMediaController } from 'src/controllers/asset-media.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';
export const controllers = [
APIKeyController,
ApiKeyController,
ActivityController,
AlbumController,
AppController,

View File

@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
import _ from 'lodash';
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 { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
@@ -54,6 +55,24 @@ export class CreateAlbumDto {
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 {
@Optional()
@IsString()

View File

@@ -17,7 +17,7 @@ import { UAParser } from 'ua-parser-js';
type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true };
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute);
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [
@@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorat
}
if (options?.permission) {
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission ?? Permission.All));
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
}
if ((options as SharedLinkRoute)?.sharedLink) {

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', () => {
it('should allow the owner to remove assets', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));

View File

@@ -3,6 +3,8 @@ import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto,
CreateAlbumDto,
GetAlbumsDto,
@@ -13,7 +15,7 @@ import {
UpdateAlbumDto,
UpdateAlbumUserDto,
} 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 { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
@@ -186,6 +188,43 @@ export class AlbumService extends BaseService {
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[]> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service';
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', () => {
it('should throw an error if the key is not found', async () => {
const auth = factory.auth();

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 { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.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);
}
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> {
const key = await this.apiKeyRepository.getById(auth.user.id, id);
if (!key) {

View File

@@ -518,6 +518,20 @@ describe(AuthService.name, () => {
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 () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });

View File

@@ -48,7 +48,8 @@ export type ValidateRequest = {
metadata: {
sharedLinkRoute: boolean;
adminRoute: boolean;
permission?: Permission;
/** `false` explicitly means no permission is required, which otherwise defaults to `all` */
permission?: Permission | false;
uri: string;
};
};
@@ -187,7 +188,11 @@ export class AuthService extends BaseService {
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}`);
}

View File

@@ -12,7 +12,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError, useSwagger } from 'src/utils/misc';
import { isStartUpError } from 'src/utils/misc';
async function bootstrap() {
process.title = 'immich-api';
@@ -37,7 +37,6 @@ async function bootstrap() {
app.enableCors();
}
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() });
app.setGlobalPrefix('api', { exclude: excludePaths });
if (existsSync(resourcePaths.web.root)) {

View File

@@ -1,17 +1,17 @@
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
WORKDIR /usr/src/app
COPY --chown=node:node ./web/package* ./web/
RUN npm install --global corepack@latest && \
corepack enable && corepack install -g pnpm && \
apk add --no-cache tini make bash && \
mkdir -p /buildcache/pnpm-store && \
pnpm config set store-dir /buildcache/pnpm-store
WORKDIR /usr/src/app/web
RUN npm ci
ENV CHOKIDAR_USEPOLLING=true \
PATH="${PATH}:/usr/src/app/web/bin"
EXPOSE 24678
EXPOSE 3000
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]

View File

@@ -1,14 +1,11 @@
#!/usr/bin/env sh
TYPESCRIPT_SDK=/usr/src/app/open-api/typescript-sdk
npm --prefix "$TYPESCRIPT_SDK" install
npm --prefix "$TYPESCRIPT_SDK" run build
cd /usr/src/app/web || exit 1
echo "Build dependencies for Immich Web"
cd /usr/src/app || exit
COUNT=0
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
UPSTREAM="${UPSTREAM%/}"
until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
if [ $((COUNT % 10)) -eq 0 ]; then
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))
sleep 1
done
echo "Connected to $UPSTREAM"
npx vite dev --host 0.0.0.0 --port 3000
echo "Connected to $UPSTREAM, starting Immich Web..."
pnpm --filter @immich/sdk build
pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port 3000

10514
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@
"lint:fix": "npm run lint -- --fix",
"format": "prettier --check .",
"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:cov": "vitest --coverage",
"test:watch": "vitest dev",
@@ -50,13 +50,13 @@
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.3.0",
"maplibre-gl": "^5.6.2",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.1.3",
"svelte-i18n": "^4.0.1",
"svelte-maplibre": "^1.0.0",
"svelte-maplibre": "^1.2.0",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
"thumbhash": "^0.1.1"
@@ -105,9 +105,12 @@
"tslib": "^2.6.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.5",
"vite": "npm:rolldown-vite@latest",
"vitest": "^3.0.0"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"
},
"volta": {
"node": "22.18.0"
}

View File

@@ -4,7 +4,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
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 type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
@@ -20,14 +20,23 @@
let { asset, onAction, shared = false }: Props = $props();
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;
}
await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
if (albums.length === 1) {
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>

View File

@@ -1,8 +1,11 @@
<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 { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk';
import { mdiCheckCircle } from '@mdi/js';
import type { Action } from 'svelte/action';
import AlbumListItemDetails from './album-list-item-details.svelte';
@@ -10,10 +13,19 @@
album: AlbumResponseDto;
searchQuery?: string;
selected: boolean;
multiSelected?: boolean;
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) => {
$effect(() => {
@@ -37,33 +49,127 @@
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>
<button
type="button"
onclick={onAlbumClick}
use:scrollIntoViewIfSelected
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"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
<div
role="group"
class={[
'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',
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
]}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
>
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
alt={album.albumName}
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
data-testid="album-image"
draggable="false"
/>
{/if}
</span>
<span class="flex h-12 flex-col items-start justify-center overflow-hidden">
<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} />
<button
type="button"
onclick={onAlbumClick}
use:scrollIntoViewIfSelected
class="flex gap-4 px-2 py-2 text-start"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
>
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
alt={album.albumName}
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
data-testid="album-image"
draggable="false"
/>
{/if}
</span>
</span>
</button>
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
<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>

View File

@@ -415,9 +415,9 @@
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>

View File

@@ -2,7 +2,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
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 { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -18,15 +18,23 @@
const { getAssets } = getAssetControlContext();
const onClick = async () => {
const album = await modalManager.show(AlbumPickerModal, { shared });
if (!album) {
const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!albums || albums.length === 0) {
return;
}
const assetIds = [...getAssets()].map(({ id }) => id);
await addAssetsToAlbum(album.id, assetIds);
onAddToAlbum(assetIds, album.id);
if (albums.length === 1) {
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>

View File

@@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
type: AlbumModalRowType.ALBUM_ITEM,
album,
selected,
multiSelected: false,
});
describe('Album Modal', () => {
it('non-shared with no albums configured yet shows message and new', () => {
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')]);
});
it('non-shared with no matching albums shows message and new', () => {
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')]);
});
@@ -44,7 +51,7 @@ describe('Album Modal', () => {
it('non-shared displays single albums', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1);
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
@@ -64,6 +71,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
@@ -90,6 +98,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
@@ -112,6 +121,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
@@ -125,7 +135,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
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([
createNewAlbumRow(true),
@@ -141,7 +151,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
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([
createNewAlbumRow(false),
@@ -157,7 +167,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
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([
createNewAlbumRow(false),

View File

@@ -16,6 +16,7 @@ export enum AlbumModalRowType {
export type AlbumModalRow = {
type: AlbumModalRowType;
selected?: boolean;
multiSelected?: boolean;
text?: string;
album?: AlbumResponseDto;
};
@@ -41,6 +42,7 @@ export class AlbumModalRowConverter {
recentAlbums: AlbumResponseDto[],
albums: AlbumResponseDto[],
selectedRowIndex: number,
multiSelectedAlbumIds: string[],
): AlbumModalRow[] {
// 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 : [];
@@ -64,6 +66,7 @@ export class AlbumModalRowConverter {
rows.push({
type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album,
});
}
@@ -81,6 +84,7 @@ export class AlbumModalRowConverter {
rows.push({
type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album,
});
}

View File

@@ -1,9 +1,9 @@
<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 { 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 {
searchQuery?: string;

View File

@@ -54,7 +54,7 @@
<div
class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors {isSelected
? 'bg-green-400/90'
: 'bg-red-300/90'}"
: 'bg-red-300/90'} text-black"
>
{isSelected ? $t('keep') : $t('to_trash')}
</div>

View File

@@ -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">
<!-- MARK ALL BUTTONS -->
<div class="flex text-xs text-black">

View File

@@ -6,8 +6,8 @@
isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { type AlbumResponseDto, createAlbum, getAllAlbums } from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
@@ -21,7 +21,7 @@
interface Props {
shared: boolean;
onClose: (album?: AlbumResponseDto) => void;
onClose: (albums?: AlbumResponseDto[]) => void;
}
let { shared, onClose }: Props = $props();
@@ -32,13 +32,54 @@
loading = false;
});
const multiSelectedAlbumIds: string[] = $state([]);
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
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 onNewAlbum = async (name: string) => {
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 () => {
@@ -53,8 +94,12 @@
break;
}
case AlbumModalRowType.ALBUM_ITEM: {
if (multiSelectActive) {
handleMultiSubmit();
break;
}
if (item.album) {
onClose(item.album);
onClose([item.album]);
}
break;
}
@@ -88,6 +133,11 @@
await onEnter();
break;
}
case 'm': {
e.preventDefault();
handleMultiSelect();
break;
}
default: {
selectedRowIndex = -1;
}
@@ -133,13 +183,20 @@
<AlbumListItem
album={row.album}
selected={row.selected || false}
multiSelected={row.multiSelected}
searchQuery={search}
onAlbumClick={() => onClose(row.album)}
onAlbumClick={() => handleAlbumClick(row.album)}
onMultiSelect={() => handleMultiSelect(row.album)}
/>
{/if}
{/each}
</div>
{/if}
</div>
{#if multiSelectActive}
<Button size="small" shape="round" fullWidth onclick={handleMultiSubmit}
>{$t('add_to_albums_count', { values: { count: multiSelectedAlbumIds.length } })}</Button
>
{/if}
</ModalBody>
</Modal>

View File

@@ -16,7 +16,9 @@ import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links';
import {
addAssetsToAlbum as addAssets,
addAssetsToAlbums as addToAlbums,
AssetVisibility,
BulkIdErrorReason,
bulkTagAssets,
createStack,
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 ({
assetIds,
tagIds,

View File

@@ -1,10 +1,14 @@
<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 {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
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 ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { locale } from '$lib/stores/preferences.store';
@@ -15,7 +19,16 @@
import type { AssetResponseDto } from '@immich/sdk';
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
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 type { PageData } from './$types';
@@ -47,6 +60,19 @@
};
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);
const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => {
if (prompt && confirmText) {
@@ -85,6 +111,7 @@
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
deletedNotification(trashIds.length);
await correctDuplicatesIndexAndGo(duplicatesIndex);
},
trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined,
trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined,
@@ -96,6 +123,7 @@
const duplicateAssetIds = assets.map((asset) => asset.id);
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
await correctDuplicatesIndexAndGo(duplicatesIndex);
};
const handleDeduplicateAll = async () => {
@@ -126,6 +154,9 @@
duplicates = [];
deletedNotification(idsToDelete.length);
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
},
prompt,
confirmText,
@@ -144,13 +175,39 @@
message: $t('resolved_all_duplicates'),
type: NotificationType.Info,
});
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
},
$t('bulk_keep_duplicates_confirmation', { values: { count: ids.length } }),
$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>
<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}>
{#snippet buttons()}
<HStack gap={0}>
@@ -203,13 +260,61 @@
/>
</div>
{#key duplicates[0].duplicateId}
{#key duplicates[duplicatesIndex].duplicateId}
<DuplicatesCompareControl
assets={duplicates[0].assets}
assets={duplicates[duplicatesIndex].assets}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)}
handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)}
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}
{:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">

View File

@@ -69,8 +69,11 @@ function isIgnoredFileType(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 {
return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname);
}

View File

@@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import { defineConfig, type UserConfig } from 'vite';
const upstream = {
target: process.env.IMMICH_SERVER_URL || 'http://immich-server:2283/',
@@ -59,4 +59,4 @@ export default defineConfig({
hooks: 'list',
},
},
});
} as UserConfig);