Compare commits

..

1 Commits

Author SHA1 Message Date
Zack Pollard 2390ad0fab ci(release): support patch releases from release/* branches
- prepare-release: add `branch` input; validate branch/bump combination;
  skip Weblate merge, mobile build, and APK asset when not on main; point
  checkout, release target, and tag at the selected branch; backport the
  archived-versions.json entry to main via PR.
- build-mobile: gate Android release build and iOS TestFlight upload on
  `environment == production` instead of the branch name, so patch
  releases still produce production artifacts if ever re-enabled.
- docker: build on pushes to release/**; restrict retag-from-main jobs
  to PRs and main-branch pushes.
- docs-build: build on pushes to release/**; include release/** in the
  pre-job force-branches list.
2026-04-24 17:57:29 +01:00
453 changed files with 3826 additions and 6634 deletions
+6 -6
View File
@@ -143,9 +143,9 @@ jobs:
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }}
run: |
if [[ $IS_MAIN == 'true' ]]; then
if [[ $IS_RELEASE == 'true' ]]; then
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
@@ -268,20 +268,20 @@ jobs:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }}
IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }}
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
working-directory: ./mobile/ios
run: |
# Only upload to TestFlight on main branch
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
# Upload to TestFlight on main or when explicitly invoked as a production release.
if [[ "$IS_RELEASE" == "true" ]]; then
if [[ "$ENVIRONMENT" == "development" ]]; then
bundle exec fastlane gha_testflight_dev
else
bundle exec fastlane gha_release_prod
fi
else
# Build only, no TestFlight upload for non-main branches
# Build only, no TestFlight upload
bundle exec fastlane gha_build_only
fi
+5 -3
View File
@@ -3,7 +3,7 @@ name: Docker
on:
workflow_dispatch:
push:
branches: [main]
branches: [main, 'release/**']
pull_request:
release:
types: [published]
@@ -53,7 +53,8 @@ jobs:
permissions:
contents: read
packages: write
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
# Retag sources from the :main image, so only retag for PRs and main-branch pushes.
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
strategy:
matrix:
@@ -83,7 +84,8 @@ jobs:
permissions:
contents: read
packages: write
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
# Retag sources from the :main image, so only retag for PRs and main-branch pushes.
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
strategy:
matrix:
+2 -2
View File
@@ -1,7 +1,7 @@
name: Docs build
on:
push:
branches: [main]
branches: [main, 'release/**']
pull_request:
release:
types: [published]
@@ -39,7 +39,7 @@ jobs:
force-filters: |
- '.github/workflows/docs-build.yml'
force-events: 'release'
force-branches: 'main'
force-branches: 'main,release/**'
build:
name: Docs Build
+100 -11
View File
@@ -3,8 +3,13 @@ name: Prepare new release
on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to release from (must be main or release/*)'
required: true
default: 'main'
type: string
serverBump:
description: 'Bump server version'
description: 'Bump server version (only patch allowed on release/* branches)'
required: true
default: 'false'
type: choice
@@ -29,10 +34,31 @@ concurrency:
permissions: {}
jobs:
validate_inputs:
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Validate branch and bump combination
env:
BRANCH: ${{ inputs.branch }}
SERVER_BUMP: ${{ inputs.serverBump }}
run: |
set -euo pipefail
if [[ "$BRANCH" != "main" && "$BRANCH" != release/* ]]; then
echo "::error::branch must be 'main' or start with 'release/' (got '$BRANCH')"
exit 1
fi
if [[ "$BRANCH" != "main" && "$SERVER_BUMP" != "false" && "$SERVER_BUMP" != "patch" ]]; then
echo "::error::only 'patch' (or 'false') serverBump is allowed on '$BRANCH'"
exit 1
fi
merge_translations:
needs: [validate_inputs]
uses: ./.github/workflows/merge-translations.yml
with:
skip: ${{ inputs.skipTranslations }}
# Weblate tracks main only, so skip translations when releasing from a release/* branch.
skip: ${{ inputs.skipTranslations || inputs.branch != 'main' }}
permissions:
pull-requests: write
secrets:
@@ -60,7 +86,7 @@ jobs:
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
ref: ${{ inputs.branch }}
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
@@ -94,6 +120,10 @@ jobs:
push: true
build_mobile:
# Mobile build numbers are monotonic per store; releasing from a release/* branch
# would collide with build numbers already shipped from main. Skip mobile on patch
# releases — handle mobile patches on main instead.
if: ${{ inputs.branch == 'main' }}
uses: ./.github/workflows/build-mobile.yml
needs: bump_version
permissions:
@@ -118,6 +148,8 @@ jobs:
prepare_release:
runs-on: ubuntu-latest
needs: [build_mobile, bump_version]
# Run even when build_mobile is skipped (patch release from release/* branch).
if: ${{ always() && needs.bump_version.result == 'success' && (needs.build_mobile.result == 'success' || needs.build_mobile.result == 'skipped') }}
permissions:
actions: read # To download the app artifact
# No content permissions are needed because it uses the app-token
@@ -134,26 +166,83 @@ jobs:
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: ${{ needs.bump_version.outputs.ref }}
- name: Download APK
if: ${{ needs.build_mobile.result == 'success' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Assemble release assets
id: assets
env:
HAS_APK: ${{ needs.build_mobile.result == 'success' }}
run: |
{
echo 'files<<EOF'
echo 'docker/docker-compose.yml'
echo 'docker/docker-compose.rootless.yml'
echo 'docker/example.env'
echo 'docker/hwaccel.ml.yml'
echo 'docker/hwaccel.transcoding.yml'
echo 'docker/prometheus.yml'
if [[ "$HAS_APK" == "true" ]]; then
echo '*.apk'
fi
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create draft release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
target_commitish: ${{ inputs.branch }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true
body_path: misc/release/notes.tmpl
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
files: ${{ steps.assets.outputs.files }}
backport_archived_versions:
# When releasing from a release/* branch, the archived-versions.json update
# lives on that branch only. Open a PR to mirror the new entry onto main so
# main's docs keep a complete archive list.
if: ${{ inputs.branch != 'main' && needs.bump_version.result == 'success' }}
runs-on: ubuntu-latest
needs: [bump_version, prepare_release]
permissions: {} # uses the app token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Update archived versions on main
env:
VERSION: ${{ needs.bump_version.outputs.version }}
run: ./misc/release/archive-version.js "${VERSION#v}"
- name: Open backport PR
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.generate-token.outputs.token }}
branch: backport/archived-versions-${{ needs.bump_version.outputs.version }}
base: main
commit-message: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}'
title: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}'
body: |
Backports the `archived-versions.json` entry for ${{ needs.bump_version.outputs.version }},
released from `${{ inputs.branch }}`, so main's docs archive list stays complete.
add-paths: docs/static/archived-versions.json
delete-branch: true
-2
View File
@@ -392,8 +392,6 @@ jobs:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Run medium tests
+1 -2
View File
@@ -26,8 +26,7 @@
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true,
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
terragrunt = "1.0.2"
terragrunt = "1.0.1"
opentofu = "1.11.6"
[tasks."tg:fmt"]
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -81,7 +81,7 @@ VectorChord is the successor extension to pgvecto.rs, allowing for higher perfor
### Migrating from pgvecto.rs
Support for pgvecto.rs has been dropped as of 3.0, hence all users currently using pgvecto.rs should migrate to VectorChord. There are two primary approaches to do so.
Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so.
The easiest option is to have both extensions installed during the migration:
+1 -1
View File
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
### Cons
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
- Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy
+1 -1
View File
@@ -81,7 +81,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`]) | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
+4
View File
@@ -130,3 +130,7 @@ These storage mediums have different performance characteristics. As a result, t
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
Its a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
+42 -4
View File
@@ -2,44 +2,82 @@ import { expect } from 'vitest';
export const errorDto = {
unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password',
correlationId: expect.any(String),
},
invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token',
correlationId: expect.any(String),
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
correlationId: expect.any(String),
},
passwordRequired: {
error: 'Unauthorized',
statusCode: 401,
message: 'Password required',
correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
}),
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
message: 'Validation failed',
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
correlationId: expect.any(String),
}),
noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
},
incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password',
correlationId: expect.any(String),
},
alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin',
correlationId: expect.any(String),
},
invalidEmail: {
error: 'Bad Request',
statusCode: 400,
message: ['email must be an email'],
correlationId: expect.any(String),
},
};
+7 -21
View File
@@ -110,9 +110,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
});
it('should not create an external library with duplicate exclusion patterns', async () => {
@@ -127,9 +125,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
});
});
@@ -161,9 +157,7 @@ describe('/libraries', () => {
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]),
);
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
});
it('should change the import paths', async () => {
@@ -187,9 +181,7 @@ describe('/libraries', () => {
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]),
);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
});
it('should reject duplicate import paths', async () => {
@@ -199,9 +191,7 @@ describe('/libraries', () => {
.send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
});
it('should change the exclusion pattern', async () => {
@@ -225,9 +215,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
});
it('should reject an empty exclusion pattern', async () => {
@@ -237,9 +225,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]),
);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
});
});
+4 -12
View File
@@ -109,9 +109,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lon=123')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
});
it('should throw an error if a lat is not a number', async () => {
@@ -119,9 +117,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
});
it('should throw an error if a lat is out of range', async () => {
@@ -129,9 +125,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
);
expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90']));
});
it('should throw an error if a lon is not provided', async () => {
@@ -139,9 +133,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
);
expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN']));
});
const reverseGeocodeTestCases = [
+9 -18
View File
@@ -105,11 +105,7 @@ describe(`/oauth`, () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
]),
);
expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined']));
});
it('should return a redirect uri', async () => {
@@ -168,17 +164,13 @@ describe(`/oauth`, () => {
it(`should throw an error if a url is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]),
);
expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined']));
});
it(`should throw an error if the url is empty`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]),
);
expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters']));
});
it(`should throw an error if the state is not provided`, async () => {
@@ -340,7 +332,9 @@ describe(`/oauth`, () => {
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(500);
expect(body).toMatchObject({
error: 'Internal Server Error',
message: 'Failed to finish oauth',
statusCode: 500,
});
});
@@ -359,7 +353,7 @@ describe(`/oauth`, () => {
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('OAuth authentication failed'));
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
});
it('should link to an existing user by email', async () => {
@@ -383,11 +377,7 @@ describe(`/oauth`, () => {
it(`should throw an error if the logout_token is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
]),
);
expect(body).toEqual(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined']));
});
it(`should throw an error if an invalid logout token is provided`, async () => {
@@ -505,10 +495,11 @@ describe(`/oauth`, () => {
});
it('should reject OAuth discovery over HTTP', async () => {
const { status } = await request(app)
const { status, body } = await request(app)
.post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(500);
expect(body).toMatchObject({ statusCode: 500 });
});
});
});
@@ -341,9 +341,7 @@ describe('/shared-links', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
expect(body).toEqual(errorDto.badRequest());
});
it('should require an asset/album id', async () => {
+2 -9
View File
@@ -41,9 +41,7 @@ describe('/stacks', () => {
.send({ assetIds: [asset.id] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]),
);
expect(body).toEqual(errorDto.badRequest());
});
it('should require a valid id', async () => {
@@ -53,12 +51,7 @@ describe('/stacks', () => {
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['assetIds', 0], message: 'Invalid UUID' },
{ path: ['assetIds', 1], message: 'Invalid UUID' },
]),
);
expect(body).toEqual(errorDto.badRequest());
});
it('should require access', async () => {
+2 -2
View File
@@ -309,7 +309,7 @@ describe('/tags', () => {
.get(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should get tag details', async () => {
@@ -427,7 +427,7 @@ describe('/tags', () => {
.delete(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should delete a tag', async () => {
@@ -108,20 +108,14 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden);
});
for (const [key, message] of [
['password', 'Invalid input: expected string, received null'],
['email', 'Invalid input: expected email, received object'],
['name', 'Invalid input: expected string, received null'],
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
['notify', 'Invalid input: expected boolean, received null'],
] as const) {
for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
expect(body).toEqual(errorDto.badRequest());
});
}
@@ -159,19 +153,14 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden);
});
for (const [key, message] of [
['password', 'Invalid input: expected string, received null'],
['email', 'Invalid input: expected email, received object'],
['name', 'Invalid input: expected string, received null'],
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
] as const) {
for (const key of ['password', 'email', 'name', 'shouldChangePassword']) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
expect(body).toEqual(errorDto.badRequest());
});
}
+3 -7
View File
@@ -120,7 +120,7 @@ describe('/users', () => {
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(400);
expect(body).toMatchObject(errorDto.badRequest('Email is not available'));
expect(body).toMatchObject(errorDto.badRequest('Email already in use by another account'));
});
it('should update my email', async () => {
@@ -179,9 +179,7 @@ describe('/users', () => {
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
]),
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
);
});
@@ -209,9 +207,7 @@ describe('/users', () => {
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' },
]),
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
);
});
@@ -32,12 +32,8 @@ export function generateThumbhash(rng: SeededRandom): string {
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
}
export function generateDuration(rng: SeededRandom): number {
return (
rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) *
1000 +
rng.nextInt(0, 1000)
);
export function generateDuration(rng: SeededRandom): string {
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
}
export function generateUUID(): string {
@@ -43,7 +43,7 @@ export type MockTimelineAsset = {
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
duration: number | null;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
@@ -304,7 +304,7 @@ test.describe('Timeline', () => {
await page.keyboard.down('Shift');
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
await expect(
thumbnailUtils.locator(page).locator('.absolute.top-0.size-full.bg-immich-primary.opacity-40'),
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
).toHaveCount(3);
await thumbnailUtils.selectButton(page, assets[2].id).click();
await page.keyboard.up('Shift');
-2
View File
@@ -1761,7 +1761,6 @@
"play_original_video": "Play original video",
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
"play_transcoded_video": "Play transcoded video",
"playback_speed": "Playback speed",
"please_auth_to_access": "Please authenticate to access",
"port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences",
@@ -2437,7 +2436,6 @@
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code",
"x_of_total": "{x}/{total}",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",
+6 -6
View File
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
@@ -68,7 +68,7 @@ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
RUN apt-get update && \
# Pascal support was dropped in 9.11
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 tzdata && \
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -112,7 +112,7 @@ ARG RKNN_TOOLKIT_VERSION="v2.3.0"
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
FROM prod-${DEVICE} AS prod
+1 -4
View File
@@ -183,10 +183,7 @@ async def predict(
text: str | None = Form(default=None),
) -> Any:
if image is not None:
decoded = await run(lambda: decode_pil(image))
if decoded.width == 0 or decoded.height == 0:
raise HTTPException(400, "Image has zero width or height")
inputs: Image | str = decoded
inputs: Image | str = await run(lambda: decode_pil(image))
elif text is not None:
inputs = text
else:
+2 -2
View File
@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=1.0,<2.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
"numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<13",
"pillow>=12.2,<12.3",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",
-13
View File
@@ -1198,19 +1198,6 @@ class TestLoad:
mock_model.model_format = ModelFormat.ONNX
@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
response = deployed_app.post(
"http://localhost:3003/predict",
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
files={"image": b"fake image bytes"},
)
assert response.status_code == 400
assert "zero" in response.json()["detail"].lower()
def test_root_endpoint(deployed_app: TestClient) -> None:
response = deployed_app.get("http://localhost:3003")
+4 -13
View File
@@ -15,26 +15,17 @@ config_roots = [
[tools]
node = "24.15.0"
flutter = "3.41.7"
pnpm = "10.33.1"
terragrunt = "1.0.2"
flutter = "3.41.6"
pnpm = "10.33.0"
terragrunt = "1.0.1"
opentofu = "1.11.6"
java = "21.0.2"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
version = "1.35.1"
bin = "dcm"
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
[tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6"
[tools."github:jellyfin/jellyfin-ffmpeg".platforms]
linux-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" }
linux-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" }
macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
[settings]
experimental = true
pin = true
+1 -1
View File
@@ -1,5 +1,5 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.41.9",
"dart.flutterSdkPath": ".fvm/versions/3.41.7",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [
+1 -1
View File
@@ -1 +1 @@
version: '>=1.29.0 <=1.37.0'
version: '>=1.29.0 <=1.36.0'
+1 -1
View File
@@ -1,5 +1,5 @@
app_identifier "app.alextran.immich" # The bundle identifier of your app
apple_id "altran@futo.org" # Your Apple email address
apple_id "alex.tran1502@gmail.com" # Your Apple email address
# For more information about the Appfile, see:
+41 -41
View File
@@ -17,11 +17,10 @@ default_platform(:ios)
platform :ios do
# Constants
TEAM_ID = "2W7AC6T8T5"
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
TEAM_ID = "2F67MQ8R79"
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
# Helper method to get App Store Connect API key
def get_api_key
app_store_connect_api_key(
@@ -45,45 +44,47 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app)
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: base_bundle_id,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: profile_name_main,
targets: ["Runner"]
)
# ShareExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{base_bundle_id}.ShareExtension",
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: profile_name_share,
targets: ["ShareExtension"]
)
# WidgetExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{base_bundle_id}.Widget",
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
# Helper method to build and upload to TestFlight
def build_and_upload(
api_key:,
base_bundle_id:,
bundle_id_suffix: "",
configuration: "Release",
distribute_external: true,
version_number: nil,
@@ -91,8 +92,9 @@ end
profile_name_share:,
profile_name_widget:
)
app_identifier = base_bundle_id
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
@@ -136,31 +138,31 @@ end
desc "iOS Development Build to TestFlight (requires separate bundle ID)"
lane :gha_testflight_dev do
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
base_bundle_id: DEV_BUNDLE_ID,
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload
build_and_upload(
api_key: api_key,
base_bundle_id: DEV_BUNDLE_ID,
bundle_id_suffix: "development",
configuration: "Profile",
distribute_external: false,
profile_name_main: main_profile_name,
@@ -187,7 +189,6 @@ end
# Configure code signing for production bundle IDs
configure_code_signing(
base_bundle_id: BASE_BUNDLE_ID,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
@@ -196,7 +197,6 @@ end
# Build and upload with version number
build_and_upload(
api_key: api_key,
base_bundle_id: BASE_BUNDLE_ID,
version_number: get_version_from_pubspec,
distribute_external: false,
profile_name_main: main_profile_name,
@@ -243,30 +243,30 @@ end
desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do
# Use the same build process as the dev TestFlight lane, just skip the upload
# This ensures PR builds validate the same way as dev TestFlight builds
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(
base_bundle_id: DEV_BUNDLE_ID,
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build the app (same as gha_testflight_dev but without upload)
build_app(
scheme: "Runner",
@@ -277,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
DEV_BUNDLE_ID => main_profile_name,
"#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
"#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -3,7 +3,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -193,22 +192,17 @@ class SyncStreamService {
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetV2:
final remoteSyncAssets = data.cast<SyncAssetV2>();
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
await _applyRemoteRestoreToLocal();
} else {
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
return;
case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetEditV1:
@@ -221,12 +215,8 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.partnerAssetBackfillV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.partnerAssetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner");
case SyncEntityType.partnerAssetExifV1:
@@ -247,16 +237,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
case SyncEntityType.albumAssetCreateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create');
case SyncEntityType.albumAssetCreateV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset create');
case SyncEntityType.albumAssetUpdateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update');
case SyncEntityType.albumAssetUpdateV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset update');
case SyncEntityType.albumAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill');
case SyncEntityType.albumAssetBackfillV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset backfill');
case SyncEntityType.albumAssetExifCreateV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create');
case SyncEntityType.albumAssetExifUpdateV1:
@@ -358,47 +342,6 @@ class SyncStreamService {
}
}
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetUploadReadyV2 events');
final List<SyncAssetV2> assets = [];
final List<SyncAssetExifV1> exifs = [];
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
final exifData = payload['exif'];
if (assetData == null || exifData == null) {
continue;
}
final asset = SyncAssetV2.fromJson(assetData);
final exif = SyncAssetExifV1.fromJson(exifData);
if (asset != null && exif != null) {
assets.add(asset);
exifs.add(exif);
}
}
if (assets.isNotEmpty && exifs.isNotEmpty) {
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
_logger.info('Successfully processed ${assets.length} assets in batch');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetUploadReadyV2 websocket batch events", error, stackTrace);
}
}
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
_logger.info('Processing AssetEditReadyV1 event');
@@ -439,67 +382,28 @@ class SyncStreamService {
}
}
Future<void> handleWsAssetEditReadyV2(dynamic data) async {
_logger.info('Processing AssetEditReadyV2 event');
try {
if (data is! Map<String, dynamic>) {
throw ArgumentError("Invalid data format for AssetEditReadyV2 event");
}
final payload = data;
if (payload['asset'] == null) {
throw ArgumentError("Missing 'asset' field in AssetEditReadyV2 event data");
}
final asset = SyncAssetV2.fromJson(payload['asset']);
if (asset == null) {
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV2 event data");
}
final assetEdits = (payload['edit'] as List<dynamic>)
.map((e) => SyncAssetEditV1.fromJson(e))
.whereType<SyncAssetEditV1>()
.toList();
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
_logger.info(
'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits',
);
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
}
}
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) {
await _trashLocalAssets(localAssetsToTrash);
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
}
}
}
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
@@ -509,23 +413,4 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
await _applyRemoteRestoreToLocal();
}
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
}
}
+2 -32
View File
@@ -186,7 +186,7 @@ class BackgroundSyncManager {
});
}
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
Future<void> syncWebsocketBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
@@ -196,17 +196,7 @@ class BackgroundSyncManager {
});
}
Future<void> syncWebsocketBatchV2(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetUploadReadyV2Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncWebsocketEditV1(dynamic data) {
Future<void> syncWebsocketEdit(dynamic data) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
@@ -216,16 +206,6 @@ class BackgroundSyncManager {
});
}
Future<void> syncWebsocketEditV2(dynamic data) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV2(data);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future;
@@ -262,17 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV2Batch(batchData),
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
debugLabel: 'websocket-edit',
);
Cancelable<void> _handleWsAssetEditReadyV2(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV2(data),
debugLabel: 'websocket-edit',
);
+3 -2
View File
@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:openapi/api.dart' as api;
@@ -13,7 +14,7 @@ extension DTOToAsset on api.AssetResponseDto {
updatedAt: updatedAt,
ownerId: ownerId,
visibility: visibility.toAssetVisibility(),
durationMs: duration,
durationMs: duration?.toDuration()?.inMilliseconds ?? 0,
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
@@ -35,7 +36,7 @@ extension DTOToAsset on api.AssetResponseDto {
updatedAt: updatedAt,
ownerId: ownerId,
visibility: visibility.toAssetVisibility(),
durationMs: duration,
durationMs: duration?.toDuration()?.inMilliseconds ?? 0,
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
@@ -109,40 +109,31 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return {};
}
final result = <String, List<LocalAsset>>{};
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice),
_db.localAssetEntity.checksum.isIn(slice),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
return result;
}
@@ -46,25 +46,19 @@ class SyncApiRepository {
types: [
SyncRequestType.authUsersV1,
SyncRequestType.usersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.assetsV2
: SyncRequestType.assetsV1,
SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.partnerAssetsV2
: SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1,
if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
SyncRequestType.albumsV1
else
SyncRequestType.albumsV2,
SyncRequestType.albumUsersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.albumAssetsV2
: SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1,
SyncRequestType.albumToAssetsV1,
SyncRequestType.memoriesV1,
@@ -73,9 +67,8 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1,
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
? SyncRequestType.assetFacesV2
: SyncRequestType.assetFacesV1,
if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2,
],
reset: shouldReset,
).toJson(),
@@ -160,7 +153,6 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.partnerV1: SyncPartnerV1.fromJson,
SyncEntityType.partnerDeleteV1: SyncPartnerDeleteV1.fromJson,
SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetV2: SyncAssetV2.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
@@ -168,9 +160,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetV2: SyncAssetV2.fromJson,
SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetBackfillV2: SyncAssetV2.fromJson,
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson,
@@ -181,11 +171,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson,
SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson,
SyncEntityType.albumAssetCreateV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetCreateV2: SyncAssetV2.fromJson,
SyncEntityType.albumAssetUpdateV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetUpdateV2: SyncAssetV2.fromJson,
SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetBackfillV2: SyncAssetV2.fromJson,
SyncEntityType.albumAssetExifCreateV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumAssetExifUpdateV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson,
@@ -220,44 +220,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetsV2(Iterable<SyncAssetV2> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final asset in data) {
final companion = RemoteAssetEntityCompanion(
name: Value(asset.originalFileName),
type: Value(asset.type.toAssetType()),
createdAt: Value.absentIfNull(asset.fileCreatedAt),
updatedAt: Value.absentIfNull(asset.fileModifiedAt),
durationMs: Value(asset.duration),
checksum: Value(asset.checksum),
isFavorite: Value(asset.isFavorite),
ownerId: Value(asset.ownerId),
localDateTime: Value(asset.localDateTime),
thumbHash: Value(asset.thumbhash),
deletedAt: Value(asset.deletedAt),
visibility: Value(asset.visibility.toAssetVisibility()),
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
isEdited: Value(asset.isEdited),
);
batch.insert(
_db.remoteAssetEntity,
companion.copyWith(id: Value(asset.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetsV2 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> updateAssetsExifV1(Iterable<SyncAssetExifV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
@@ -364,8 +363,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
BaseAsset displayAsset = asset;
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex);
}
@@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class AssetStackRow extends ConsumerWidget {
final List<RemoteAsset> stack;
@@ -17,11 +15,6 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink();
}
final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
if (hideAssetStack) {
return const SizedBox.shrink();
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
@@ -21,7 +21,6 @@ class ThumbnailTile extends ConsumerStatefulWidget {
this.showStorageIndicator = false,
this.lockSelection = false,
this.heroOffset,
this.showStackIndicator = false,
super.key,
});
@@ -31,7 +30,6 @@ class ThumbnailTile extends ConsumerStatefulWidget {
final bool showStorageIndicator;
final bool lockSelection;
final int? heroOffset;
final bool showStackIndicator;
@override
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
@@ -141,14 +139,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
duration: Durations.short4,
child: Align(
alignment: Alignment.topRight,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_AssetTypeIcons(asset: asset),
if (widget.showStackIndicator) _StackIndicator(asset: asset),
],
),
child: _AssetTypeIcons(asset: asset),
),
),
if (storageIndicator && asset != null)
@@ -295,8 +286,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override
Widget build(BuildContext context) {
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -304,6 +295,11 @@ class _AssetTypeIcons extends StatelessWidget {
children: [
if (asset.isVideo)
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)),
if (hasStack)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
),
if (isLivePhoto)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
@@ -316,24 +312,6 @@ class _AssetTypeIcons extends StatelessWidget {
}
}
class _StackIndicator extends StatelessWidget {
final BaseAsset asset;
const _StackIndicator({required this.asset});
@override
Widget build(BuildContext context) {
if (asset is! RemoteAsset || (asset as RemoteAsset).stackId == null) {
return const SizedBox.shrink();
}
return const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
);
}
}
class _UploadProgressOverlay extends StatelessWidget {
final double progress;
@@ -244,7 +244,6 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
return RepaintBoundary(
child: GestureDetector(
@@ -254,7 +253,6 @@ class _AssetTileWidget extends ConsumerWidget {
asset,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
showStackIndicator: showStackIndicator,
heroOffset: heroOffset,
),
),
+8 -40
View File
@@ -94,10 +94,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state = const WebsocketState(isConnected: false, socket: null);
});
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1);
socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1);
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
@@ -165,25 +163,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_ref.read(serverInfoProvider.notifier).handleReleaseInfo(serverVersion, releaseVersion);
}
void _handleSyncAssetUploadReadyV1(dynamic data) {
void _handleSyncAssetUploadReady(dynamic data) {
_batchedAssetUploadReady.add(data);
_batchDebouncer.run(_processBatchedAssetUploadReadyV1);
_batchDebouncer.run(_processBatchedAssetUploadReady);
}
void _handleSyncAssetUploadReadyV2(dynamic data) {
_batchedAssetUploadReady.add(data);
_batchDebouncer.run(_processBatchedAssetUploadReadyV2);
void _handleSyncAssetEditReady(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data));
}
void _handleSyncAssetEditReadyV1(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
}
void _handleSyncAssetEditReadyV2(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
}
void _processBatchedAssetUploadReadyV1() {
void _processBatchedAssetUploadReady() {
if (_batchedAssetUploadReady.isEmpty) {
return;
}
@@ -191,7 +180,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
try {
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) {
if (isSyncAlbumEnabled) {
_ref.read(backgroundSyncProvider).syncLinkedAlbum();
}
@@ -203,27 +192,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchedAssetUploadReady.clear();
}
void _processBatchedAssetUploadReadyV2() {
if (_batchedAssetUploadReady.isEmpty) {
return;
}
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
try {
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
if (isSyncAlbumEnabled) {
_ref.read(backgroundSyncProvider).syncLinkedAlbum();
}
}),
);
} catch (error) {
_log.severe("Error processing batched AssetUploadReadyV2 events: $error");
}
_batchedAssetUploadReady.clear();
}
}
final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
@@ -148,7 +148,6 @@ enum ActionButtonType {
context.selectedCount == 1,
ActionButtonType.unstack =>
context.isOwner && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
flutter = "3.41.9"
flutter = "3.41.7"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"
+1 -2
View File
@@ -146,7 +146,7 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
@@ -579,7 +579,6 @@ Class | Method | HTTP request | Description
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
- [SyncAssetV1](doc//SyncAssetV1.md)
- [SyncAssetV2](doc//SyncAssetV2.md)
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
- [SyncEntityType](doc//SyncEntityType.md)
- [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md)
-1
View File
@@ -327,7 +327,6 @@ part 'model/sync_asset_face_v2.dart';
part 'model/sync_asset_metadata_delete_v1.dart';
part 'model/sync_asset_metadata_v1.dart';
part 'model/sync_asset_v1.dart';
part 'model/sync_asset_v2.dart';
part 'model/sync_auth_user_v1.dart';
part 'model/sync_entity_type.dart';
part 'model/sync_memory_asset_delete_v1.dart';
+6 -6
View File
@@ -1234,8 +1234,8 @@ class AssetsApi {
/// * [String] xImmichChecksum:
/// sha1 checksum that can be used for duplicate detection before the file is uploaded
///
/// * [int] duration:
/// Duration in milliseconds (for videos)
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
@@ -1253,7 +1253,7 @@ class AssetsApi {
/// Sidecar file data
///
/// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@@ -1358,8 +1358,8 @@ class AssetsApi {
/// * [String] xImmichChecksum:
/// sha1 checksum that can be used for duplicate detection before the file is uploaded
///
/// * [int] duration:
/// Duration in milliseconds (for videos)
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
@@ -1377,7 +1377,7 @@ class AssetsApi {
/// Sidecar file data
///
/// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+4 -4
View File
@@ -16,9 +16,9 @@ class DuplicatesApi {
final ApiClient apiClient;
/// Dismiss a duplicate group
/// Delete a duplicate
///
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
/// Delete a single duplicate asset specified by its ID.
///
/// Note: This method returns the HTTP [Response].
///
@@ -51,9 +51,9 @@ class DuplicatesApi {
);
}
/// Dismiss a duplicate group
/// Delete a duplicate
///
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
/// Delete a single duplicate asset specified by its ID.
///
/// Parameters:
///
+6 -6
View File
@@ -183,15 +183,15 @@ class PeopleApi {
/// * [String] closestPersonId:
/// Closest person ID for similarity search
///
/// * [int] page:
/// * [num] page:
/// Page number for pagination
///
/// * [int] size:
/// * [num] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/people';
@@ -244,15 +244,15 @@ class PeopleApi {
/// * [String] closestPersonId:
/// Closest person ID for similarity search
///
/// * [int] page:
/// * [num] page:
/// Page number for pagination
///
/// * [int] size:
/// * [num] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+6 -6
View File
@@ -404,10 +404,10 @@ class SearchApi {
/// * [List<String>] personIds:
/// Filter by person IDs
///
/// * [int] rating:
/// * [num] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [int] size:
/// * [num] size:
/// Number of results to return
///
/// * [String] state:
@@ -443,7 +443,7 @@ class SearchApi {
///
/// * [bool] withExif:
/// Include EXIF data in response
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/search/large-assets';
@@ -619,10 +619,10 @@ class SearchApi {
/// * [List<String>] personIds:
/// Filter by person IDs
///
/// * [int] rating:
/// * [num] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [int] size:
/// * [num] size:
/// Number of results to return
///
/// * [String] state:
@@ -658,7 +658,7 @@ class SearchApi {
///
/// * [bool] withExif:
/// Include EXIF data in response
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, ocr: ocr, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
-2
View File
@@ -700,8 +700,6 @@ class ApiClient {
return SyncAssetMetadataV1.fromJson(value);
case 'SyncAssetV1':
return SyncAssetV1.fromJson(value);
case 'SyncAssetV2':
return SyncAssetV2.fromJson(value);
case 'SyncAuthUserV1':
return SyncAuthUserV1.fromJson(value);
case 'SyncEntityType':
+2 -5
View File
@@ -37,15 +37,12 @@ class AssetBulkUpdateDto {
/// Relative time offset in seconds
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// 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.
///
int? dateTimeRelative;
num? dateTimeRelative;
/// Asset description
///
@@ -216,7 +213,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable
@@ -24,26 +24,22 @@ class AssetEditActionItemDtoParameters {
/// Height of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int height;
num height;
/// Width of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int width;
num width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int x;
num x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int y;
num y;
/// Rotation angle in degrees
num angle;
@@ -92,10 +88,10 @@ class AssetEditActionItemDtoParameters {
final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters(
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!,
);
+11 -12
View File
@@ -57,11 +57,8 @@ class AssetResponseDto {
/// Duplicate group ID
String? duplicateId;
/// Video/gif duration in milliseconds (null for static images)
///
/// Minimum value: 0
/// Maximum value: 2147483647
int? duration;
/// Video/gif duration in hh:mm:ss.SSS format (null for static images)
String? duration;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -83,8 +80,7 @@ class AssetResponseDto {
/// Asset height
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? height;
num? height;
/// Asset ID
String id;
@@ -169,8 +165,7 @@ class AssetResponseDto {
/// Asset width
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? width;
num? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -346,12 +341,14 @@ class AssetResponseDto {
checksum: mapValueOfType<String>(json, r'checksum')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
duration: mapValueOfType<int>(json, r'duration'),
duration: mapValueOfType<String>(json, r'duration'),
exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: mapValueOfType<int>(json, r'height'),
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
@@ -375,7 +372,9 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
);
}
return null;
+8 -12
View File
@@ -22,26 +22,22 @@ class CropParameters {
/// Height of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int height;
num height;
/// Width of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int width;
num width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int x;
num x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int y;
num y;
@override
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
@@ -79,10 +75,10 @@ class CropParameters {
final json = value.cast<String, dynamic>();
return CropParameters(
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
);
}
return null;
+2 -3
View File
@@ -27,8 +27,7 @@ class DatabaseBackupConfig {
/// Keep last amount
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int keepLastAmount;
num keepLastAmount;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
@@ -65,7 +64,7 @@ class DatabaseBackupConfig {
return DatabaseBackupConfig(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
keepLastAmount: num.parse('${json[r'keepLastAmount']}'),
);
}
return null;
+2 -5
View File
@@ -22,10 +22,7 @@ class DatabaseBackupDto {
String filename;
/// Backup file size
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int filesize;
num filesize;
/// Backup timezone
String timezone;
@@ -64,7 +61,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: mapValueOfType<int>(json, r'filesize')!,
filesize: num.parse('${json[r'filesize']}'),
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}
+16 -16
View File
@@ -52,14 +52,12 @@ class ExifResponseDto {
/// Image height in pixels
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? exifImageHeight;
num? exifImageHeight;
/// Image width in pixels
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? exifImageWidth;
num? exifImageWidth;
/// Exposure time
String? exposureTime;
@@ -77,10 +75,7 @@ class ExifResponseDto {
num? focalLength;
/// ISO sensitivity
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? iso;
num? iso;
/// GPS latitude
num? latitude;
@@ -107,10 +102,7 @@ class ExifResponseDto {
String? projectionType;
/// Rating
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? rating;
num? rating;
/// State/province name
String? state;
@@ -300,8 +292,12 @@ class ExifResponseDto {
country: mapValueOfType<String>(json, r'country'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
description: mapValueOfType<String>(json, r'description'),
exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
exifImageHeight: json[r'exifImageHeight'] == null
? null
: num.parse('${json[r'exifImageHeight']}'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse('${json[r'exifImageWidth']}'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
fNumber: json[r'fNumber'] == null
? null
@@ -310,7 +306,9 @@ class ExifResponseDto {
focalLength: json[r'focalLength'] == null
? null
: num.parse('${json[r'focalLength']}'),
iso: mapValueOfType<int>(json, r'iso'),
iso: json[r'iso'] == null
? null
: num.parse('${json[r'iso']}'),
latitude: json[r'latitude'] == null
? null
: num.parse('${json[r'latitude']}'),
@@ -323,7 +321,9 @@ class ExifResponseDto {
modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'),
projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: mapValueOfType<int>(json, r'rating'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
);
@@ -21,13 +21,9 @@ class MachineLearningAvailabilityChecksDto {
/// Enabled
bool enabled;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int interval;
num interval;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int timeout;
num timeout;
@override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
@@ -63,8 +59,8 @@ class MachineLearningAvailabilityChecksDto {
return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: mapValueOfType<int>(json, r'interval')!,
timeout: mapValueOfType<int>(json, r'timeout')!,
interval: num.parse('${json[r'interval']}'),
timeout: num.parse('${json[r'timeout']}'),
);
}
return null;
@@ -20,10 +20,7 @@ class MaintenanceDetectInstallStorageFolderDto {
});
/// Number of files in the folder
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int files;
num files;
StorageFolder folder;
@@ -69,7 +66,7 @@ class MaintenanceDetectInstallStorageFolderDto {
final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto(
files: mapValueOfType<int>(json, r'files')!,
files: num.parse('${json[r'files']}'),
folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!,
@@ -32,15 +32,13 @@ class MaintenanceStatusResponseDto {
///
String? error;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// 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.
///
int? progress;
num? progress;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -104,7 +102,7 @@ class MaintenanceStatusResponseDto {
action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'),
progress: mapValueOfType<int>(json, r'progress'),
progress: num.parse('${json[r'progress']}'),
task: mapValueOfType<String>(json, r'task'),
);
}
+8 -7
View File
@@ -215,14 +215,13 @@ class MetadataSearchDto {
/// Page number
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// 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.
///
int? page;
num? page;
/// Filter by person IDs
List<String> personIds;
@@ -240,7 +239,7 @@ class MetadataSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Number of results to return
///
@@ -252,7 +251,7 @@ class MetadataSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? size;
num? size;
/// Filter by state/province name
String? state;
@@ -725,13 +724,15 @@ class MetadataSearchDto {
order: AssetOrder.fromJson(json[r'order']),
originalFileName: mapValueOfType<String>(json, r'originalFileName'),
originalPath: mapValueOfType<String>(json, r'originalPath'),
page: mapValueOfType<int>(json, r'page'),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+6 -4
View File
@@ -147,7 +147,7 @@ class RandomSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Number of results to return
///
@@ -159,7 +159,7 @@ class RandomSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? size;
num? size;
/// Filter by state/province name
String? state;
@@ -549,8 +549,10 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+2 -3
View File
@@ -39,14 +39,13 @@ class SessionCreateDto {
/// Session duration in seconds
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// 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.
///
int? duration;
num? duration;
@override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
@@ -95,7 +94,7 @@ class SessionCreateDto {
return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: mapValueOfType<int>(json, r'duration'),
duration: num.parse('${json[r'duration']}'),
);
}
return null;
+8 -7
View File
@@ -154,14 +154,13 @@ class SmartSearchDto {
/// Page number
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// 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.
///
int? page;
num? page;
/// Filter by person IDs
List<String> personIds;
@@ -188,7 +187,7 @@ class SmartSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Number of results to return
///
@@ -200,7 +199,7 @@ class SmartSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? size;
num? size;
/// Filter by state/province name
String? state;
@@ -584,14 +583,16 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'),
page: mapValueOfType<int>(json, r'page'),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+4 -2
View File
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Filter by state/province name
String? state;
@@ -479,7 +479,9 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: mapValueOfType<int>(json, r'rating'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
-321
View File
@@ -1,321 +0,0 @@
//
// 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 SyncAssetV2 {
/// Returns a new [SyncAssetV2] instance.
SyncAssetV2({
required this.checksum,
required this.deletedAt,
required this.duration,
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.height,
required this.id,
required this.isEdited,
required this.isFavorite,
required this.libraryId,
required this.livePhotoVideoId,
required this.localDateTime,
required this.originalFileName,
required this.ownerId,
required this.stackId,
required this.thumbhash,
required this.type,
required this.visibility,
required this.width,
});
/// Checksum
String checksum;
/// Deleted at
DateTime? deletedAt;
/// Duration
///
/// Minimum value: 0
/// Maximum value: 2147483647
int? duration;
/// File created at
DateTime? fileCreatedAt;
/// File modified at
DateTime? fileModifiedAt;
/// Asset height
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? height;
/// Asset ID
String id;
/// Is edited
bool isEdited;
/// Is favorite
bool isFavorite;
/// Library ID
String? libraryId;
/// Live photo video ID
String? livePhotoVideoId;
/// Local date time
DateTime? localDateTime;
/// Original file name
String originalFileName;
/// Owner ID
String ownerId;
/// Stack ID
String? stackId;
/// Thumbhash
String? thumbhash;
AssetTypeEnum type;
AssetVisibility visibility;
/// Asset width
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV2 &&
other.checksum == checksum &&
other.deletedAt == deletedAt &&
other.duration == duration &&
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.height == height &&
other.id == id &&
other.isEdited == isEdited &&
other.isFavorite == isFavorite &&
other.libraryId == libraryId &&
other.livePhotoVideoId == livePhotoVideoId &&
other.localDateTime == localDateTime &&
other.originalFileName == originalFileName &&
other.ownerId == ownerId &&
other.stackId == stackId &&
other.thumbhash == thumbhash &&
other.type == type &&
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checksum.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isEdited.hashCode) +
(isFavorite.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(localDateTime == null ? 0 : localDateTime!.hashCode) +
(originalFileName.hashCode) +
(ownerId.hashCode) +
(stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'SyncAssetV2[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checksum'] = this.checksum;
if (this.deletedAt != null) {
json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.deletedAt!.millisecondsSinceEpoch
: this.deletedAt!.toUtc().toIso8601String();
} else {
// json[r'deletedAt'] = null;
}
if (this.duration != null) {
json[r'duration'] = this.duration;
} else {
// json[r'duration'] = null;
}
if (this.fileCreatedAt != null) {
json[r'fileCreatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.fileCreatedAt!.millisecondsSinceEpoch
: this.fileCreatedAt!.toUtc().toIso8601String();
} else {
// json[r'fileCreatedAt'] = null;
}
if (this.fileModifiedAt != null) {
json[r'fileModifiedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.fileModifiedAt!.millisecondsSinceEpoch
: this.fileModifiedAt!.toUtc().toIso8601String();
} else {
// json[r'fileModifiedAt'] = null;
}
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isEdited'] = this.isEdited;
json[r'isFavorite'] = this.isFavorite;
if (this.libraryId != null) {
json[r'libraryId'] = this.libraryId;
} else {
// json[r'libraryId'] = null;
}
if (this.livePhotoVideoId != null) {
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
} else {
// json[r'livePhotoVideoId'] = null;
}
if (this.localDateTime != null) {
json[r'localDateTime'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.localDateTime!.millisecondsSinceEpoch
: this.localDateTime!.toUtc().toIso8601String();
} else {
// json[r'localDateTime'] = null;
}
json[r'originalFileName'] = this.originalFileName;
json[r'ownerId'] = this.ownerId;
if (this.stackId != null) {
json[r'stackId'] = this.stackId;
} else {
// json[r'stackId'] = null;
}
if (this.thumbhash != null) {
json[r'thumbhash'] = this.thumbhash;
} else {
// json[r'thumbhash'] = null;
}
json[r'type'] = this.type;
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
/// Returns a new [SyncAssetV2] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetV2? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetV2");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetV2(
checksum: mapValueOfType<String>(json, r'checksum')!,
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
duration: mapValueOfType<int>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
libraryId: mapValueOfType<String>(json, r'libraryId'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
localDateTime: mapDateTime(json, r'localDateTime', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
stackId: mapValueOfType<String>(json, r'stackId'),
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
);
}
return null;
}
static List<SyncAssetV2> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetV2>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetV2.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetV2> mapFromJson(dynamic json) {
final map = <String, SyncAssetV2>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetV2.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetV2-objects as value to a dart map
static Map<String, List<SyncAssetV2>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetV2>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetV2.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checksum',
'deletedAt',
'duration',
'fileCreatedAt',
'fileModifiedAt',
'height',
'id',
'isEdited',
'isFavorite',
'libraryId',
'livePhotoVideoId',
'localDateTime',
'originalFileName',
'ownerId',
'stackId',
'thumbhash',
'type',
'visibility',
'width',
};
}
-18
View File
@@ -27,7 +27,6 @@ class SyncEntityType {
static const userV1 = SyncEntityType._(r'UserV1');
static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1');
static const assetV1 = SyncEntityType._(r'AssetV1');
static const assetV2 = SyncEntityType._(r'AssetV2');
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
static const assetEditV1 = SyncEntityType._(r'AssetEditV1');
@@ -37,9 +36,7 @@ class SyncEntityType {
static const partnerV1 = SyncEntityType._(r'PartnerV1');
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
static const partnerAssetV2 = SyncEntityType._(r'PartnerAssetV2');
static const partnerAssetBackfillV1 = SyncEntityType._(r'PartnerAssetBackfillV1');
static const partnerAssetBackfillV2 = SyncEntityType._(r'PartnerAssetBackfillV2');
static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1');
static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1');
static const partnerAssetExifBackfillV1 = SyncEntityType._(r'PartnerAssetExifBackfillV1');
@@ -53,11 +50,8 @@ class SyncEntityType {
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
static const albumAssetCreateV1 = SyncEntityType._(r'AlbumAssetCreateV1');
static const albumAssetCreateV2 = SyncEntityType._(r'AlbumAssetCreateV2');
static const albumAssetUpdateV1 = SyncEntityType._(r'AlbumAssetUpdateV1');
static const albumAssetUpdateV2 = SyncEntityType._(r'AlbumAssetUpdateV2');
static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1');
static const albumAssetBackfillV2 = SyncEntityType._(r'AlbumAssetBackfillV2');
static const albumAssetExifCreateV1 = SyncEntityType._(r'AlbumAssetExifCreateV1');
static const albumAssetExifUpdateV1 = SyncEntityType._(r'AlbumAssetExifUpdateV1');
static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1');
@@ -87,7 +81,6 @@ class SyncEntityType {
userV1,
userDeleteV1,
assetV1,
assetV2,
assetDeleteV1,
assetExifV1,
assetEditV1,
@@ -97,9 +90,7 @@ class SyncEntityType {
partnerV1,
partnerDeleteV1,
partnerAssetV1,
partnerAssetV2,
partnerAssetBackfillV1,
partnerAssetBackfillV2,
partnerAssetDeleteV1,
partnerAssetExifV1,
partnerAssetExifBackfillV1,
@@ -113,11 +104,8 @@ class SyncEntityType {
albumUserBackfillV1,
albumUserDeleteV1,
albumAssetCreateV1,
albumAssetCreateV2,
albumAssetUpdateV1,
albumAssetUpdateV2,
albumAssetBackfillV1,
albumAssetBackfillV2,
albumAssetExifCreateV1,
albumAssetExifUpdateV1,
albumAssetExifBackfillV1,
@@ -182,7 +170,6 @@ class SyncEntityTypeTypeTransformer {
case r'UserV1': return SyncEntityType.userV1;
case r'UserDeleteV1': return SyncEntityType.userDeleteV1;
case r'AssetV1': return SyncEntityType.assetV1;
case r'AssetV2': return SyncEntityType.assetV2;
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
case r'AssetExifV1': return SyncEntityType.assetExifV1;
case r'AssetEditV1': return SyncEntityType.assetEditV1;
@@ -192,9 +179,7 @@ class SyncEntityTypeTypeTransformer {
case r'PartnerV1': return SyncEntityType.partnerV1;
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
case r'PartnerAssetV2': return SyncEntityType.partnerAssetV2;
case r'PartnerAssetBackfillV1': return SyncEntityType.partnerAssetBackfillV1;
case r'PartnerAssetBackfillV2': return SyncEntityType.partnerAssetBackfillV2;
case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1;
case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1;
case r'PartnerAssetExifBackfillV1': return SyncEntityType.partnerAssetExifBackfillV1;
@@ -208,11 +193,8 @@ class SyncEntityTypeTypeTransformer {
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
case r'AlbumAssetCreateV1': return SyncEntityType.albumAssetCreateV1;
case r'AlbumAssetCreateV2': return SyncEntityType.albumAssetCreateV2;
case r'AlbumAssetUpdateV1': return SyncEntityType.albumAssetUpdateV1;
case r'AlbumAssetUpdateV2': return SyncEntityType.albumAssetUpdateV2;
case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1;
case r'AlbumAssetBackfillV2': return SyncEntityType.albumAssetBackfillV2;
case r'AlbumAssetExifCreateV1': return SyncEntityType.albumAssetExifCreateV1;
case r'AlbumAssetExifUpdateV1': return SyncEntityType.albumAssetExifUpdateV1;
case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1;
-9
View File
@@ -28,10 +28,8 @@ class SyncRequestType {
static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1');
static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1');
static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1');
static const albumAssetsV2 = SyncRequestType._(r'AlbumAssetsV2');
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
static const assetsV1 = SyncRequestType._(r'AssetsV1');
static const assetsV2 = SyncRequestType._(r'AssetsV2');
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
@@ -40,7 +38,6 @@ class SyncRequestType {
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
static const partnersV1 = SyncRequestType._(r'PartnersV1');
static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1');
static const partnerAssetsV2 = SyncRequestType._(r'PartnerAssetsV2');
static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1');
static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1');
static const stacksV1 = SyncRequestType._(r'StacksV1');
@@ -57,10 +54,8 @@ class SyncRequestType {
albumUsersV1,
albumToAssetsV1,
albumAssetsV1,
albumAssetsV2,
albumAssetExifsV1,
assetsV1,
assetsV2,
assetExifsV1,
assetEditsV1,
assetMetadataV1,
@@ -69,7 +64,6 @@ class SyncRequestType {
memoryToAssetsV1,
partnersV1,
partnerAssetsV1,
partnerAssetsV2,
partnerAssetExifsV1,
partnerStacksV1,
stacksV1,
@@ -121,10 +115,8 @@ class SyncRequestTypeTypeTransformer {
case r'AlbumUsersV1': return SyncRequestType.albumUsersV1;
case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1;
case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1;
case r'AlbumAssetsV2': return SyncRequestType.albumAssetsV2;
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
case r'AssetsV1': return SyncRequestType.assetsV1;
case r'AssetsV2': return SyncRequestType.assetsV2;
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
@@ -133,7 +125,6 @@ class SyncRequestTypeTypeTransformer {
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
case r'PartnersV1': return SyncRequestType.partnersV1;
case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1;
case r'PartnerAssetsV2': return SyncRequestType.partnerAssetsV2;
case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1;
case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1;
case r'StacksV1': return SyncRequestType.stacksV1;
+4 -3
View File
@@ -57,8 +57,7 @@ class SystemConfigOAuthDto {
/// Default storage quota
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? defaultStorageQuota;
num? defaultStorageQuota;
/// Enabled
bool enabled;
@@ -201,7 +200,9 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: mapValueOfType<int>(json, r'defaultStorageQuota'),
defaultStorageQuota: json[r'defaultStorageQuota'] == null
? null
: num.parse('${json[r'defaultStorageQuota']}'),
enabled: mapValueOfType<bool>(json, r'enabled')!,
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
///
/// Minimum value: 0
/// Maximum value: 65535
int port;
num port;
/// Whether to use secure connection (TLS/SSL)
bool secure;
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
host: mapValueOfType<String>(json, r'host')!,
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!,
port: mapValueOfType<int>(json, r'port')!,
port: num.parse('${json[r'port']}'),
secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!,
);
@@ -39,8 +39,8 @@ class TimeBucketAssetResponseDto {
/// Array of country names extracted from EXIF GPS data
List<String?> country;
/// Array of video/gif durations in milliseconds (null for static images)
List<int?> duration;
/// Array of video/gif durations in hh:mm:ss.SSS format (null for static images)
List<String?> duration;
/// Array of file creation timestamps in UTC
List<String> fileCreatedAt;
@@ -172,7 +172,7 @@ class TimeBucketAssetResponseDto {
? (json[r'country'] as Iterable).cast<String>().toList(growable: false)
: const [],
duration: json[r'duration'] is Iterable
? (json[r'duration'] as Iterable).cast<int>().toList(growable: false)
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
: const [],
fileCreatedAt: json[r'fileCreatedAt'] is Iterable
? (json[r'fileCreatedAt'] as Iterable).cast<String>().toList(growable: false)
+2 -5
View File
@@ -26,10 +26,7 @@ class WorkflowActionResponseDto {
String id;
/// Action order
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
num order;
/// Plugin action ID
String pluginActionId;
@@ -82,7 +79,7 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!,
order: num.parse('${json[r'order']}'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
+2 -5
View File
@@ -26,10 +26,7 @@ class WorkflowFilterResponseDto {
String id;
/// Filter order
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
num order;
/// Plugin filter ID
String pluginFilterId;
@@ -82,7 +79,7 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!,
order: num.parse('${json[r'order']}'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
+1 -1
View File
@@ -1989,4 +1989,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: "3.41.9"
flutter: "3.41.7"
+1 -1
View File
@@ -6,7 +6,7 @@ version: 2.7.5+3046
environment:
sdk: '>=3.11.0 <4.0.0'
flutter: 3.41.9
flutter: 3.41.7
dependencies:
async: ^2.13.1
@@ -419,8 +419,8 @@ void main() {
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
return assetsByAlbum;
});
@@ -482,18 +482,12 @@ void main() {
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
return {};
});
test("does not request local deletions for permanent remote delete events", () async {
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
+43 -221
View File
@@ -5172,7 +5172,7 @@
},
"/duplicates/{id}": {
"delete": {
"description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.",
"description": "Delete a single duplicate asset specified by its ID.",
"operationId": "deleteDuplicate",
"parameters": [
{
@@ -5202,7 +5202,7 @@
"api_key": []
}
],
"summary": "Dismiss a duplicate group",
"summary": "Delete a duplicate",
"tags": [
"Duplicates"
],
@@ -7964,9 +7964,8 @@
"description": "Page number for pagination",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"default": 1,
"type": "integer"
"type": "number"
}
},
{
@@ -7978,7 +7977,7 @@
"minimum": 1,
"maximum": 1000,
"default": 500,
"type": "integer"
"type": "number"
}
},
{
@@ -9373,7 +9372,7 @@
],
"x-immich-state": "Stable",
"schema": {
"type": "integer",
"type": "number",
"minimum": -1,
"maximum": 5,
"nullable": true
@@ -9387,7 +9386,7 @@
"schema": {
"minimum": 1,
"maximum": 1000,
"type": "integer"
"type": "number"
}
},
{
@@ -15637,9 +15636,7 @@
},
"dateTimeRelative": {
"description": "Relative time offset in seconds",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"description": {
"description": "Asset description",
@@ -16261,10 +16258,8 @@
"type": "string"
},
"duration": {
"description": "Duration in milliseconds (for videos)",
"maximum": 2147483647,
"minimum": 0,
"type": "integer"
"description": "Duration (for videos)",
"type": "string"
},
"fileCreatedAt": {
"description": "File creation date",
@@ -16632,11 +16627,9 @@
"type": "string"
},
"duration": {
"description": "Video/gif duration in milliseconds (null for static images)",
"maximum": 2147483647,
"minimum": 0,
"description": "Video/gif duration in hh:mm:ss.SSS format (null for static images)",
"nullable": true,
"type": "integer"
"type": "string"
},
"exifInfo": {
"$ref": "#/components/schemas/ExifResponseDto"
@@ -16657,10 +16650,9 @@
},
"height": {
"description": "Asset height",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"id": {
"description": "Asset ID",
@@ -16803,10 +16795,9 @@
},
"width": {
"description": "Asset width",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -17223,27 +17214,23 @@
"properties": {
"height": {
"description": "Height of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"width": {
"description": "Width of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"x": {
"description": "Top-Left X coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
"type": "number"
},
"y": {
"description": "Top-Left Y coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -17267,9 +17254,8 @@
},
"keepLastAmount": {
"description": "Keep last amount",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -17302,9 +17288,7 @@
},
"filesize": {
"description": "Backup file size",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"timezone": {
"description": "Backup timezone",
@@ -17643,18 +17627,16 @@
"exifImageHeight": {
"default": null,
"description": "Image height in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"exifImageWidth": {
"default": null,
"description": "Image width in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"exposureTime": {
"default": null,
@@ -17685,10 +17667,8 @@
"iso": {
"default": null,
"description": "ISO sensitivity",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
"type": "number"
},
"latitude": {
"default": null,
@@ -17742,10 +17722,8 @@
"rating": {
"default": null,
"description": "Rating",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
"type": "number"
},
"state": {
"default": null,
@@ -18172,14 +18150,10 @@
"type": "boolean"
},
"interval": {
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"timeout": {
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -18229,9 +18203,7 @@
"properties": {
"files": {
"description": "Number of files in the folder",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"folder": {
"$ref": "#/components/schemas/StorageFolder"
@@ -18274,9 +18246,7 @@
"type": "string"
},
"progress": {
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"task": {
"type": "string"
@@ -18753,9 +18723,8 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"personIds": {
"description": "Filter by person IDs",
@@ -18775,7 +18744,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -18797,7 +18766,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "integer"
"type": "number"
},
"state": {
"description": "Filter by state/province name",
@@ -20628,7 +20597,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -20650,7 +20619,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "integer"
"type": "number"
},
"state": {
"description": "Filter by state/province name",
@@ -21468,9 +21437,8 @@
},
"duration": {
"description": "Session duration in seconds",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
}
},
"type": "object"
@@ -21984,9 +21952,8 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"personIds": {
"description": "Filter by person IDs",
@@ -22012,7 +21979,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -22034,7 +22001,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "integer"
"type": "number"
},
"state": {
"description": "Filter by state/province name",
@@ -22272,7 +22239,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -23179,135 +23146,6 @@
],
"type": "object"
},
"SyncAssetV2": {
"properties": {
"checksum": {
"description": "Checksum",
"type": "string"
},
"deletedAt": {
"description": "Deleted at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"duration": {
"description": "Duration",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"type": "integer"
},
"fileCreatedAt": {
"description": "File created at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"fileModifiedAt": {
"description": "File modified at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"height": {
"description": "Asset height",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
},
"id": {
"description": "Asset ID",
"type": "string"
},
"isEdited": {
"description": "Is edited",
"type": "boolean"
},
"isFavorite": {
"description": "Is favorite",
"type": "boolean"
},
"libraryId": {
"description": "Library ID",
"nullable": true,
"type": "string"
},
"livePhotoVideoId": {
"description": "Live photo video ID",
"nullable": true,
"type": "string"
},
"localDateTime": {
"description": "Local date time",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"originalFileName": {
"description": "Original file name",
"type": "string"
},
"ownerId": {
"description": "Owner ID",
"type": "string"
},
"stackId": {
"description": "Stack ID",
"nullable": true,
"type": "string"
},
"thumbhash": {
"description": "Thumbhash",
"nullable": true,
"type": "string"
},
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"visibility": {
"$ref": "#/components/schemas/AssetVisibility"
},
"width": {
"description": "Asset width",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
}
},
"required": [
"checksum",
"deletedAt",
"duration",
"fileCreatedAt",
"fileModifiedAt",
"height",
"id",
"isEdited",
"isFavorite",
"libraryId",
"livePhotoVideoId",
"localDateTime",
"originalFileName",
"ownerId",
"stackId",
"thumbhash",
"type",
"visibility",
"width"
],
"type": "object"
},
"SyncAuthUserV1": {
"properties": {
"avatarColor": {
@@ -23408,7 +23246,6 @@
"UserV1",
"UserDeleteV1",
"AssetV1",
"AssetV2",
"AssetDeleteV1",
"AssetExifV1",
"AssetEditV1",
@@ -23418,9 +23255,7 @@
"PartnerV1",
"PartnerDeleteV1",
"PartnerAssetV1",
"PartnerAssetV2",
"PartnerAssetBackfillV1",
"PartnerAssetBackfillV2",
"PartnerAssetDeleteV1",
"PartnerAssetExifV1",
"PartnerAssetExifBackfillV1",
@@ -23434,11 +23269,8 @@
"AlbumUserBackfillV1",
"AlbumUserDeleteV1",
"AlbumAssetCreateV1",
"AlbumAssetCreateV2",
"AlbumAssetUpdateV1",
"AlbumAssetUpdateV2",
"AlbumAssetBackfillV1",
"AlbumAssetBackfillV2",
"AlbumAssetExifCreateV1",
"AlbumAssetExifUpdateV1",
"AlbumAssetExifBackfillV1",
@@ -23730,10 +23562,8 @@
"AlbumUsersV1",
"AlbumToAssetsV1",
"AlbumAssetsV1",
"AlbumAssetsV2",
"AlbumAssetExifsV1",
"AssetsV1",
"AssetsV2",
"AssetExifsV1",
"AssetEditsV1",
"AssetMetadataV1",
@@ -23742,7 +23572,6 @@
"MemoryToAssetsV1",
"PartnersV1",
"PartnerAssetsV1",
"PartnerAssetsV2",
"PartnerAssetExifsV1",
"PartnerStacksV1",
"StacksV1",
@@ -24542,10 +24371,9 @@
},
"defaultStorageQuota": {
"description": "Default storage quota",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"enabled": {
"description": "Enabled",
@@ -24720,7 +24548,7 @@
"description": "SMTP server port",
"maximum": 65535,
"minimum": 0,
"type": "integer"
"type": "number"
},
"secure": {
"description": "Whether to use secure connection (TLS/SSL)",
@@ -25136,12 +24964,10 @@
"type": "array"
},
"duration": {
"description": "Array of video/gif durations in milliseconds (null for static images)",
"description": "Array of video/gif durations in hh:mm:ss.SSS format (null for static images)",
"items": {
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "string"
},
"type": "array"
},
@@ -26140,9 +25966,7 @@
},
"order": {
"description": "Action order",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"pluginActionId": {
"description": "Plugin action ID",
@@ -26241,9 +26065,7 @@
},
"order": {
"description": "Filter order",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"pluginFilterId": {
"description": "Plugin filter ID",
+7 -54
View File
@@ -614,8 +614,8 @@ export type AssetMetadataUpsertItemDto = {
export type AssetMediaCreateDto = {
/** Asset file data */
assetData: Blob;
/** Duration in milliseconds (for videos) */
duration?: number;
/** Duration (for videos) */
duration?: string;
/** File creation date */
fileCreatedAt: string;
/** File modification date */
@@ -854,8 +854,8 @@ export type AssetResponseDto = {
createdAt: string;
/** Duplicate group ID */
duplicateId?: string | null;
/** Video/gif duration in milliseconds (null for static images) */
duration: number | null;
/** Video/gif duration in hh:mm:ss.SSS format (null for static images) */
duration: string | null;
exifInfo?: ExifResponseDto;
/** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */
fileCreatedAt: string;
@@ -2673,8 +2673,8 @@ export type TimeBucketAssetResponseDto = {
city: (string | null)[];
/** Array of country names extracted from EXIF GPS data */
country: (string | null)[];
/** Array of video/gif durations in milliseconds (null for static images) */
duration: (number | null)[];
/** Array of video/gif durations in hh:mm:ss.SSS format (null for static images) */
duration: (string | null)[];
/** Array of file creation timestamps in UTC */
fileCreatedAt: string[];
/** Array of asset IDs in the time bucket */
@@ -3075,44 +3075,6 @@ export type SyncAssetV1 = {
/** Asset width */
width: number | null;
};
export type SyncAssetV2 = {
/** Checksum */
checksum: string;
/** Deleted at */
deletedAt: string | null;
/** Duration */
duration: number | null;
/** File created at */
fileCreatedAt: string | null;
/** File modified at */
fileModifiedAt: string | null;
/** Asset height */
height: number | null;
/** Asset ID */
id: string;
/** Is edited */
isEdited: boolean;
/** Is favorite */
isFavorite: boolean;
/** Library ID */
libraryId: string | null;
/** Live photo video ID */
livePhotoVideoId: string | null;
/** Local date time */
localDateTime: string | null;
/** Original file name */
originalFileName: string;
/** Owner ID */
ownerId: string;
/** Stack ID */
stackId: string | null;
/** Thumbhash */
thumbhash: string | null;
"type": AssetTypeEnum;
visibility: AssetVisibility;
/** Asset width */
width: number | null;
};
export type SyncAuthUserV1 = {
avatarColor?: (UserAvatarColor) | null;
/** User deleted at */
@@ -4480,7 +4442,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
})));
}
/**
* Dismiss a duplicate group
* Delete a duplicate
*/
export function deleteDuplicate({ id }: {
id: string;
@@ -7147,7 +7109,6 @@ export enum SyncEntityType {
UserV1 = "UserV1",
UserDeleteV1 = "UserDeleteV1",
AssetV1 = "AssetV1",
AssetV2 = "AssetV2",
AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1",
AssetEditV1 = "AssetEditV1",
@@ -7157,9 +7118,7 @@ export enum SyncEntityType {
PartnerV1 = "PartnerV1",
PartnerDeleteV1 = "PartnerDeleteV1",
PartnerAssetV1 = "PartnerAssetV1",
PartnerAssetV2 = "PartnerAssetV2",
PartnerAssetBackfillV1 = "PartnerAssetBackfillV1",
PartnerAssetBackfillV2 = "PartnerAssetBackfillV2",
PartnerAssetDeleteV1 = "PartnerAssetDeleteV1",
PartnerAssetExifV1 = "PartnerAssetExifV1",
PartnerAssetExifBackfillV1 = "PartnerAssetExifBackfillV1",
@@ -7173,11 +7132,8 @@ export enum SyncEntityType {
AlbumUserBackfillV1 = "AlbumUserBackfillV1",
AlbumUserDeleteV1 = "AlbumUserDeleteV1",
AlbumAssetCreateV1 = "AlbumAssetCreateV1",
AlbumAssetCreateV2 = "AlbumAssetCreateV2",
AlbumAssetUpdateV1 = "AlbumAssetUpdateV1",
AlbumAssetUpdateV2 = "AlbumAssetUpdateV2",
AlbumAssetBackfillV1 = "AlbumAssetBackfillV1",
AlbumAssetBackfillV2 = "AlbumAssetBackfillV2",
AlbumAssetExifCreateV1 = "AlbumAssetExifCreateV1",
AlbumAssetExifUpdateV1 = "AlbumAssetExifUpdateV1",
AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1",
@@ -7207,10 +7163,8 @@ export enum SyncRequestType {
AlbumUsersV1 = "AlbumUsersV1",
AlbumToAssetsV1 = "AlbumToAssetsV1",
AlbumAssetsV1 = "AlbumAssetsV1",
AlbumAssetsV2 = "AlbumAssetsV2",
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1",
AssetsV2 = "AssetsV2",
AssetExifsV1 = "AssetExifsV1",
AssetEditsV1 = "AssetEditsV1",
AssetMetadataV1 = "AssetMetadataV1",
@@ -7219,7 +7173,6 @@ export enum SyncRequestType {
MemoryToAssetsV1 = "MemoryToAssetsV1",
PartnersV1 = "PartnersV1",
PartnerAssetsV1 = "PartnerAssetsV1",
PartnerAssetsV2 = "PartnerAssetsV2",
PartnerAssetExifsV1 = "PartnerAssetExifsV1",
PartnerStacksV1 = "PartnerStacksV1",
StacksV1 = "StacksV1",
@@ -1,16 +1,9 @@
import { HttpError } from '@oazapfts/runtime';
export interface ApiValidationError {
code: string;
path: (string | number)[];
message: string;
}
export interface ApiExceptionResponse {
message: string;
error?: string;
statusCode: number;
errors?: ApiValidationError[];
}
export interface ApiHttpError extends HttpError {
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.7.5",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"engines": {
"pnpm": ">=10.0.0"
}
+1000 -1062
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -48,7 +48,6 @@ export default typescriptEslint.config([
'unicorn/import-style': 'off',
'unicorn/prefer-structured-clone': 'off',
'unicorn/no-for-loop': 'off',
'unicorn/no-array-sort': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'require-await': 'off',
+4 -4
View File
@@ -46,15 +46,15 @@
"@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/swagger": "11.2.6",
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.67.0",
"@opentelemetry/instrumentation-ioredis": "^0.62.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0",
"@opentelemetry/instrumentation-pg": "^0.66.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.215.0",
+7 -3
View File
@@ -14,6 +14,7 @@ export const ErrorMessages = {
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@@ -23,10 +24,15 @@ export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
cube: 'cube',
earthdistance: 'earthdistance',
vector: 'pgvector',
vectors: 'pgvecto.rs',
vchord: 'VectorChord',
} as const;
export const VECTOR_EXTENSIONS = [DatabaseExtension.VectorChord, DatabaseExtension.Vector] as const;
export const VECTOR_EXTENSIONS = [
DatabaseExtension.VectorChord,
DatabaseExtension.Vectors,
DatabaseExtension.Vector,
] as const;
export const VECTOR_INDEX_TABLES = {
[VectorIndex.Clip]: 'smart_search',
@@ -36,8 +42,6 @@ export const VECTOR_INDEX_TABLES = {
export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2;
export const SALT_ROUNDS = 10;
// Syntactically valid bcrypt hash used in login() preventing timing-based user enumeration.
export const LOGIN_DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZabcde';
export const IWorker = 'IWorker';
@@ -28,16 +28,14 @@ describe(ActivityController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['albumId'], message: 'Invalid input: expected string, received undefined' },
]),
factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']),
);
});
it('should reject an invalid albumId', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
});
it('should reject an invalid assetId', async () => {
@@ -45,7 +43,7 @@ describe(ActivityController.name, () => {
.get('/activities')
.query({ albumId: factory.uuid(), assetId: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
});
});
@@ -60,7 +58,7 @@ describe(ActivityController.name, () => {
.post('/activities')
.send({ albumId: '123', type: 'like' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
});
it('should require a comment when type is comment', async () => {
@@ -68,11 +66,7 @@ describe(ActivityController.name, () => {
.post('/activities')
.send({ albumId: factory.uuid(), type: 'comment', comment: null });
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['comment'], message: 'Invalid input: expected string, received null' },
]),
);
expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null']));
});
});
@@ -85,7 +79,7 @@ describe(ActivityController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
});
@@ -27,17 +27,13 @@ describe(AlbumController.name, () => {
it('should reject an invalid shared param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' },
]),
);
expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"']));
});
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
});
});
@@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
@@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => {
.put(`/api-keys/123`)
.send({ name: 'new name', permissions: [Permission.All] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
it('should allow updating just the name', async () => {
@@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
});
@@ -80,9 +80,7 @@ describe(AssetMediaController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['metadata'], message: 'Invalid input: expected JSON string, received string' },
]),
factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']),
);
});
@@ -93,8 +91,8 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
factory.responses.badRequest([
'[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
]),
);
});
@@ -106,8 +104,8 @@ describe(AssetMediaController.name, () => {
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
factory.responses.badRequest([
'[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
]),
);
});
@@ -119,9 +117,7 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' },
]),
factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']),
);
});
@@ -132,9 +128,7 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
]),
factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
);
});
+51 -87
View File
@@ -31,7 +31,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
});
it('should require duplicateId to be a string', async () => {
@@ -42,9 +42,7 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' },
]),
factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']),
);
});
@@ -72,7 +70,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
});
});
@@ -85,7 +83,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
@@ -99,10 +97,12 @@ describe(AssetController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['sourceId'], message: 'Invalid input: expected string, received undefined' },
{ path: ['targetId'], message: 'Invalid input: expected string, received undefined' },
]),
factory.responses.badRequest(
expect.arrayContaining([
'[sourceId] Invalid input: expected string, received undefined',
'[targetId] Invalid input: expected string, received undefined',
]),
),
);
});
@@ -125,9 +125,7 @@ describe(AssetController.name, () => {
.put('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
});
it('should require a key', async () => {
@@ -136,9 +134,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
factory.responses.badRequest(
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
);
});
@@ -161,9 +159,7 @@ describe(AssetController.name, () => {
.delete('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test' }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
});
it('should require a key', async () => {
@@ -172,9 +168,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid() }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
factory.responses.badRequest(
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
);
});
@@ -195,56 +191,33 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: [], message: 'Invalid input: expected object, received undefined' },
]),
);
expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined']));
});
it('should reject invalid gps coordinates', async () => {
for (const [test, errors] of [
[{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
[{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
[
{ latitude: 12, longitude: 'abc' },
[{ path: ['longitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: 'abc', longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: null, longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received null' }],
],
[
{ latitude: 12, longitude: null },
[{ path: ['longitude'], message: 'Invalid input: expected number, received null' }],
],
[{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]],
[{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]],
[
{ latitude: 12, longitude: -181 },
[{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }],
],
[{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]],
] as const) {
for (const test of [
{ latitude: 12 },
{ longitude: 12 },
{ latitude: 12, longitude: 'abc' },
{ latitude: 'abc', longitude: 12 },
{ latitude: null, longitude: 12 },
{ latitude: 12, longitude: null },
{ latitude: 91, longitude: 12 },
{ latitude: -91, longitude: 12 },
{ latitude: 12, longitude: -181 },
{ latitude: 12, longitude: 181 },
]) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError(errors));
expect(body).toEqual(factory.responses.badRequest());
}
});
it('should reject invalid rating', async () => {
for (const [test, errors] of [
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
] as const) {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError(errors));
expect(body).toEqual(factory.responses.badRequest());
}
});
@@ -288,17 +261,13 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
});
it('should require items to be an array', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['items'], message: 'Invalid input: expected array, received undefined' },
]),
);
expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined']));
});
it('should require each item to have a valid key', async () => {
@@ -307,9 +276,7 @@ describe(AssetController.name, () => {
.send({ items: [{ value: { some: 'value' } }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']),
);
});
@@ -319,9 +286,9 @@ describe(AssetController.name, () => {
.send({ items: [{ key: 'mobile-app', value: null }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' },
]),
factory.responses.badRequest(
expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']),
),
);
});
@@ -359,7 +326,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
});
});
@@ -409,7 +376,7 @@ describe(AssetController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
});
it('should check the action and parameters discriminator', async () => {
@@ -431,12 +398,13 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{
path: ['edits', 0, 'parameters'],
message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"),
},
]),
factory.responses.badRequest(
expect.arrayContaining([
expect.stringContaining(
"[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle",
),
]),
),
);
});
@@ -445,11 +413,7 @@ describe(AssetController.name, () => {
.put(`/assets/${factory.uuid()}/edits`)
.send({ edits: [] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['edits'], message: 'Too small: expected array to have >=1 items' },
]),
);
expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items']));
});
});
@@ -462,7 +426,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
});
+13 -41
View File
@@ -28,27 +28,19 @@ describe(AuthController.name, () => {
it('should require an email address', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received undefined' }]),
);
expect(body).toEqual(errorDto.badRequest());
});
it('should require a password', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
]),
);
expect(body).toEqual(errorDto.badRequest());
});
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
expect(body).toEqual(errorDto.badRequest());
});
it('should require a valid email', async () => {
@@ -56,9 +48,7 @@ describe(AuthController.name, () => {
.post('/auth/admin-sign-up')
.send({ name, email: 'immich', password });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received string' }]),
);
expect(body).toEqual(errorDto.badRequest());
});
it('should transform email to lower case', async () => {
@@ -83,9 +73,9 @@ describe(AuthController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['email'], message: 'Invalid input: expected email, received undefined' },
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
errorDto.badRequest([
'[email] Invalid input: expected email, received undefined',
'[password] Invalid input: expected string, received undefined',
]),
);
});
@@ -95,9 +85,7 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: null, password: 'password' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
);
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
});
it(`should not allow null password`, async () => {
@@ -105,9 +93,7 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: 'admin@immich.cloud', password: null });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['password'], message: 'Invalid input: expected string, received null' }]),
);
expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null']));
});
it('should reject an invalid email', async () => {
@@ -118,9 +104,7 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: [], password: 'password' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
);
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
});
it('should transform the email to all lowercase', async () => {
@@ -211,31 +195,19 @@ describe(AuthController.name, () => {
it('should reject 5 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
});
it('should reject 7 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
});
it('should reject non-numbers', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
});
});
@@ -41,7 +41,7 @@ describe(DuplicateController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
});
@@ -41,8 +41,8 @@ export class DuplicateController {
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Dismiss a duplicate group',
description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.',
summary: 'Delete a duplicate',
description: 'Delete a single duplicate asset specified by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
@@ -31,9 +31,7 @@ describe(MaintenanceController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' },
]),
errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']),
);
expect(ctx.authenticate).toHaveBeenCalled();
});
@@ -47,11 +47,7 @@ describe(MemoryController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['data', 'year'], message: 'Invalid input: expected number, received undefined' },
]),
);
expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined']));
});
it('should accept showAt and hideAt', async () => {
@@ -85,7 +81,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
});
@@ -98,15 +94,13 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
});
it('should require at least one field', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
expect(body).toEqual(errorDto.badRequest(['At least one field must be provided']));
});
});
@@ -126,7 +120,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should require a valid asset id', async () => {
@@ -134,7 +128,7 @@ describe(MemoryController.name, () => {
.put(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
});
});
@@ -147,7 +141,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should require a valid asset id', async () => {
@@ -155,7 +149,7 @@ describe(MemoryController.name, () => {
.delete(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
});
});
});
@@ -31,11 +31,7 @@ describe(NotificationController.name, () => {
.query({ level: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['level'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')]));
});
});
@@ -49,9 +45,7 @@ describe(NotificationController.name, () => {
it('should require a list', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]),
);
expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean']));
});
it('should require uuids', async () => {
@@ -59,9 +53,7 @@ describe(NotificationController.name, () => {
.put(`/notifications`)
.send({ ids: [true] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]),
);
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean']));
});
it('should accept valid uuids', async () => {
@@ -83,7 +75,7 @@ describe(NotificationController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
});

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