Compare commits

..

27 Commits

Author SHA1 Message Date
Daniel Dietzler e8504391ba chore: svelte enable svelte_referenced_localy 2026-04-30 22:49:28 +02:00
白隐Hakuin c1051c7ed2 fix(docs): Update Tailscale free tier user and device limits (#28151)
* docs: Update Tailscale free tier user and device limits

* chore: generalize

Updated the description of the Tailscale free tier for clarity.

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-30 10:19:49 +00:00
Snowknight26 65bd0a9320 fix(web): timeline scroll when pressing back from stacked asset (#28163) 2026-04-30 11:39:34 +02:00
Mert bf32864644 feat(server): video streaming table definitions (#28147)
* video streaming table definitions

Co-authored-by: Copilot <copilot@github.com>

* update sql

* tetris

* use enum

Co-authored-by: Copilot <copilot@github.com>

* fix column name

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:48:15 +00:00
renovate[bot] 7ef7ecec5b chore(deps): update dependency flutter to v3.41.7 (#28124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-29 10:15:40 -05:00
Alex bc4abd18e4 feat: update iOS CI/CD with FUTO build credential (#28146)
* update email

* Update fastfile

* use different apple id

* debug build

* build only
2026-04-29 09:06:35 -05:00
Peter Ombodi b74cfd4424 fix(mobile): suppress asset stack UI in trash timeline (#26536)
* fix(mobile): suppress asset stack UI in trash timeline

* refactor(mobile): apply review suggestions

* fix(mobile): hide unstack action in the trash timeline

* fix(mobile): move stack indicator out of asset type icons

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-04-29 17:49:47 +07:00
Yaros 7dc84f56c0 fix(web): double video playback on map timeline (#28090) 2026-04-29 12:11:33 +02:00
Timon 92634f923b refactor(server)!: remove redundant error and statusCode fields from error responses (#28140)
* refactor(server)!: remove redundant error and statusCode fields from error responses

* use enum

* enhance response management

* chore: clean up header

* fix: chaining

* refactor: handle error

* fix e2e tests

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-04-28 17:54:54 -04:00
Timon 96b6165bd3 refactor(server)!: move correlationId to X-Correlation-ID response header (#28139) 2026-04-28 13:07:39 -04:00
Mees Frensel 2624f3884f fix(web): large files: better handling of asset deletions (#28117) 2026-04-28 18:18:39 +02:00
Timon f9b7ce9407 fix(web): convert shared link expiry to UTC before serialising (#28135) 2026-04-28 16:10:08 +00:00
Timon 013ea37a0d refactor!: change number to integer types (#27912)
* refactor!: change number to integer types

* fix oversight
2026-04-28 11:25:03 -04:00
Mees Frensel b2b4385271 chore(web): refactor people panel (#28136) 2026-04-28 11:22:22 -04:00
Mees Frensel 081c75bb21 fix(web): refresh memories hourly (#28114) 2026-04-28 11:18:51 -04:00
renovate[bot] da337578fb fix(deps): update typescript-projects (#28132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-04-28 13:18:29 +02:00
renovate[bot] acf4109171 chore(deps): update dependency exiftool-vendored to v35.18.0 (#28133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 12:46:54 +02:00
renovate[bot] 66601a1fdc chore(deps): update dependency terragrunt to v1.0.2 (#28125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 12:00:44 +02:00
renovate[bot] 02ff077367 chore(deps): update prom/prometheus docker digest to e425440 (#28120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 12:00:11 +02:00
renovate[bot] 94bb6c1a5e chore(deps): update dependency @immich/ui to v0.76.2 (#28121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 21:54:12 -05:00
Daniel Dietzler fe9e5afcf4 fix: do not emit AlbumInvite event for owner (#28110) 2026-04-27 17:59:46 +00:00
Yosi Taguri 5e89efba64 fix(ml): handle empty/corrupt images in face detection (#27391)
* fix(ml): handle empty/corrupt images in face detection

When a corrupt or degenerate image with zero-dimension (0 width or 0 height)
reaches the face detection pipeline, insightface's RetinaFace.detect() calls
cv2.resize() with a target size of 0, triggering an OpenCV assertion failure:

  error: (-215:Assertion failed) inv_scale_x > 0 in function 'resize'

This crashes the ML worker and returns a 500 error to the server.

Add an early return in FaceDetector._predict() that checks for zero-dimension
images after decoding and returns empty detection results instead of passing
them to the insightface model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ml): move empty image validation to request level

Per review feedback, validate image dimensions in the predict endpoint
(returning 400) rather than in each model's _predict method. This
catches all zero-dimension images before they reach any model task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ml): resolve mypy strict type error in predict endpoint

Use intermediate `decoded` variable so mypy knows `.width` and `.height`
are accessed on `Image`, not on `Image | str`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:14:34 -04:00
Peter Ombodi 5a457d72c9 fix(mobile): delete assets on trash empty, Android (#26070)
* fix(mobile): improve trash sync flow
- trash local assets on remote delete events
- unify remote trash handling and support assetDelete cleanup by remote asset id
- update sync stream tests

* fix(mobile): revert pubspec.lock

* refactor(mobile): remove helper
remove unused columns from results

* refactor(mobile): use remoteIds in getAssetsFromBackupAlbums and remove getAssetsFromBackupAlbumsByRemoteIds
refactor tests

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-04-27 18:46:49 +05:30
Min Idzelis 45ccdb37fb refactor(web): replace asset-viewer listener based face hover with overlay elements (#27400) 2026-04-27 12:08:34 +02:00
Savely Krasovsky 9263e2f2e1 feat(ml): update Intel graphics compiler and compute runtime (#28076)
feat(ml): update Intel graphics compiler and compute runtime to latest versions
2026-04-25 08:49:57 -04:00
Aaron Liu a3ee615c5b chore(ml): update huggingfacehub and pillow (#27552) 2026-04-24 19:44:01 -04:00
Yaros 39cfad7136 feat(mobile): action bottom sheet on map timeline (#27515) 2026-04-24 09:30:10 -05:00
156 changed files with 2220 additions and 1925 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
terragrunt = "1.0.1"
terragrunt = "1.0.2"
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:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+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/) that permits up to 3 users and up to 100 devices.
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
- Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy
-39
View File
@@ -2,82 +2,43 @@ 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(),
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),
},
};
+1 -4
View File
@@ -332,9 +332,7 @@ 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,
});
});
@@ -495,11 +493,10 @@ describe(`/oauth`, () => {
});
it('should reject OAuth discovery over HTTP', async () => {
const { status, body } = await request(app)
const { status } = 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 });
});
});
});
+4 -4
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.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/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/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.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
+4 -1
View File
@@ -183,7 +183,10 @@ async def predict(
text: str | None = Form(default=None),
) -> Any:
if image is not None:
inputs: Image | str = await run(lambda: decode_pil(image))
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
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>=0.20.1,<1.0",
"huggingface-hub>=1.0,<2.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,<12.3",
"pillow>=12.2,<13",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",
+13
View File
@@ -1198,6 +1198,19 @@ 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")
+3 -3
View File
@@ -15,9 +15,9 @@ config_roots = [
[tools]
node = "24.15.0"
flutter = "3.41.6"
pnpm = "10.33.0"
terragrunt = "1.0.1"
flutter = "3.41.7"
pnpm = "10.33.1"
terragrunt = "1.0.2"
opentofu = "1.11.6"
java = "21.0.2"
+1 -1
View File
@@ -1,5 +1,5 @@
app_identifier "app.alextran.immich" # The bundle identifier of your app
apple_id "alex.tran1502@gmail.com" # Your Apple email address
apple_id "altran@futo.org" # Your Apple email address
# For more information about the Appfile, see:
+41 -41
View File
@@ -17,10 +17,11 @@ default_platform(:ios)
platform :ios do
# Constants
TEAM_ID = "2F67MQ8R79"
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
TEAM_ID = "2W7AC6T8T5"
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{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(
@@ -44,47 +45,45 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
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}"
def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
# 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_suffix}",
bundle_identifier: base_bundle_id,
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}#{bundle_suffix}.ShareExtension",
bundle_identifier: "#{base_bundle_id}.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}#{bundle_suffix}.Widget",
bundle_identifier: "#{base_bundle_id}.Widget",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
# Helper method to build and upload to TestFlight
def build_and_upload(
api_key:,
bundle_id_suffix: "",
base_bundle_id:,
configuration: "Release",
distribute_external: true,
version_number: nil,
@@ -92,9 +91,8 @@ end
profile_name_share:,
profile_name_widget:
)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
app_identifier = base_bundle_id
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
@@ -138,31 +136,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: "#{BASE_BUNDLE_ID}.development", force: true)
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.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(
bundle_id_suffix: "development",
base_bundle_id: DEV_BUNDLE_ID,
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,
bundle_id_suffix: "development",
base_bundle_id: DEV_BUNDLE_ID,
configuration: "Profile",
distribute_external: false,
profile_name_main: main_profile_name,
@@ -189,6 +187,7 @@ 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
@@ -197,6 +196,7 @@ 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 production, just skip the upload
# This ensures PR builds validate the same way as production builds
# 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
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: "#{BASE_BUNDLE_ID}.development", force: true)
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(
bundle_id_suffix: "development",
base_bundle_id: DEV_BUNDLE_ID,
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: {
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
DEV_BUNDLE_ID => main_profile_name,
"#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
"#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -3,6 +3,7 @@
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';
@@ -191,17 +192,22 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
await _runWithManageMediaPermission(
logContext: "Trashed Assets",
action: () async {
await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id));
await _applyRemoteRestoreToLocal();
} else {
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
},
);
return;
case SyncEntityType.assetDeleteV1:
await _runWithManageMediaPermission(
logContext: "Deleted Assets",
action: () async {
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId));
},
);
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -382,28 +388,32 @@ class SyncStreamService {
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
if (localAssetsToTrash.isNotEmpty) {
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);
}
await _trashLocalAssets(localAssetsToTrash);
} else {
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
}
}
}
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) {
@@ -413,4 +423,21 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _runWithManageMediaPermission({
required String logContext,
required Future<void> Function() action,
}) async {
if (!CurrentPlatform.isAndroid || !Store.get(StoreKey.manageLocalMediaAndroid, false)) {
return;
}
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await action();
}
}
@@ -109,31 +109,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (checksums.isEmpty) {
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return {};
}
final result = <String, List<LocalAsset>>{};
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
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.localAssetEntity.checksum.isIn(slice),
_db.remoteAssetEntity.id.isIn(slice),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto();
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
return result;
}
@@ -7,6 +7,7 @@ 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';
@@ -363,7 +364,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
BaseAsset displayAsset = asset;
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex);
}
@@ -1,8 +1,10 @@
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;
@@ -15,6 +17,11 @@ 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);
@@ -2,17 +2,21 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key});
final Key? sheetKey;
const MapBottomSheet({super.key, this.sheetKey});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
key: sheetKey,
initialChildSize: 0.25,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
@@ -49,7 +53,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
child: const Timeline(appBar: null, bottomSheet: GeneralBottomSheet(minChildSize: 0.23), withScrubber: false),
);
}
}
@@ -21,6 +21,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
this.showStorageIndicator = false,
this.lockSelection = false,
this.heroOffset,
this.showStackIndicator = false,
super.key,
});
@@ -30,6 +31,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
final bool showStorageIndicator;
final bool lockSelection;
final int? heroOffset;
final bool showStackIndicator;
@override
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
@@ -139,7 +141,14 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
duration: Durations.short4,
child: Align(
alignment: Alignment.topRight,
child: _AssetTypeIcons(asset: asset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_AssetTypeIcons(asset: asset),
if (widget.showStackIndicator) _StackIndicator(asset: asset),
],
),
),
),
if (storageIndicator && asset != null)
@@ -286,8 +295,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -295,11 +304,6 @@ 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),
@@ -312,6 +316,24 @@ 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;
@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
@@ -53,6 +54,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
final GlobalKey _bottomSheetKey = GlobalKey();
StreamSubscription? _eventSubscription;
@override
@@ -184,7 +186,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
);
@@ -224,8 +226,9 @@ class _Map extends StatelessWidget {
class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset;
final GlobalKey sheetKey;
const _DynamicBottomSheet({required this.bottomSheetOffset});
const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
@@ -236,10 +239,13 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
widget.bottomSheetOffset.value = notification.extent;
return true;
final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
if (sheet?.key == widget.sheetKey) {
widget.bottomSheetOffset.value = notification.extent;
}
return false;
},
child: const MapBottomSheet(),
child: MapBottomSheet(sheetKey: widget.sheetKey),
);
}
}
@@ -244,6 +244,7 @@ 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(
@@ -253,6 +254,7 @@ class _AssetTileWidget extends ConsumerWidget {
asset,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
showStackIndicator: showStackIndicator,
heroOffset: heroOffset,
),
),
@@ -469,6 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)
@@ -148,6 +148,7 @@ enum ActionButtonType {
context.selectedCount == 1,
ActionButtonType.unstack =>
context.isOwner && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
+6 -6
View File
@@ -183,15 +183,15 @@ class PeopleApi {
/// * [String] closestPersonId:
/// Closest person ID for similarity search
///
/// * [num] page:
/// * [int] page:
/// Page number for pagination
///
/// * [num] size:
/// * [int] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, int? page, int? 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
///
/// * [num] page:
/// * [int] page:
/// Page number for pagination
///
/// * [num] size:
/// * [int] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, int? page, int? 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
///
/// * [num] rating:
/// * [int] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// * [int] 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, 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 {
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 {
// ignore: prefer_const_declarations
final apiPath = r'/search/large-assets';
@@ -619,10 +619,10 @@ class SearchApi {
/// * [List<String>] personIds:
/// Filter by person IDs
///
/// * [num] rating:
/// * [int] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// * [int] 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, 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 {
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 {
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));
+10 -20
View File
@@ -157,14 +157,10 @@ class AlbumResponseDto {
json[r'albumUsers'] = this.albumUsers;
json[r'assetCount'] = this.assetCount;
json[r'contributorCounts'] = this.contributorCounts;
json[r'createdAt'] = _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.createdAt.millisecondsSinceEpoch
: this.createdAt.toUtc().toIso8601String();
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'description'] = this.description;
if (this.endDate != null) {
json[r'endDate'] = _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.endDate!.millisecondsSinceEpoch
: this.endDate!.toUtc().toIso8601String();
json[r'endDate'] = this.endDate!.toUtc().toIso8601String();
} else {
// json[r'endDate'] = null;
}
@@ -172,9 +168,7 @@ class AlbumResponseDto {
json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled;
if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = _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.lastModifiedAssetTimestamp!.millisecondsSinceEpoch
: this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else {
// json[r'lastModifiedAssetTimestamp'] = null;
}
@@ -185,15 +179,11 @@ class AlbumResponseDto {
}
json[r'shared'] = this.shared;
if (this.startDate != null) {
json[r'startDate'] = _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.startDate!.millisecondsSinceEpoch
: this.startDate!.toUtc().toIso8601String();
json[r'startDate'] = this.startDate!.toUtc().toIso8601String();
} else {
// json[r'startDate'] = null;
}
json[r'updatedAt'] = _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.updatedAt.millisecondsSinceEpoch
: this.updatedAt.toUtc().toIso8601String();
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
return json;
}
@@ -211,17 +201,17 @@ class AlbumResponseDto {
albumUsers: AlbumUserResponseDto.listFromJson(json[r'albumUsers']),
assetCount: mapValueOfType<int>(json, r'assetCount')!,
contributorCounts: ContributorCountResponseDto.listFromJson(json[r'contributorCounts']),
createdAt: mapDateTime(json, r'createdAt', 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))$/')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
description: mapValueOfType<String>(json, r'description')!,
endDate: mapDateTime(json, r'endDate', 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))$/'),
endDate: mapDateTime(json, r'endDate', r''),
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', 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))$/'),
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
order: AssetOrder.fromJson(json[r'order']),
shared: mapValueOfType<bool>(json, r'shared')!,
startDate: mapDateTime(json, r'startDate', 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))$/'),
updatedAt: mapDateTime(json, r'updatedAt', 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))$/')!,
startDate: mapDateTime(json, r'startDate', r''),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
}
return null;
+5 -2
View File
@@ -37,12 +37,15 @@ 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.
///
num? dateTimeRelative;
int? dateTimeRelative;
/// Asset description
///
@@ -213,7 +216,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable
@@ -24,22 +24,26 @@ class AssetEditActionItemDtoParameters {
/// Height of the crop
///
/// Minimum value: 1
num height;
/// Maximum value: 9007199254740991
int height;
/// Width of the crop
///
/// Minimum value: 1
num width;
/// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
num x;
/// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
num y;
/// Maximum value: 9007199254740991
int y;
/// Rotation angle in degrees
num angle;
@@ -88,10 +92,10 @@ class AssetEditActionItemDtoParameters {
final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters(
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!,
);
+16 -28
View File
@@ -80,7 +80,8 @@ class AssetResponseDto {
/// Asset height
///
/// Minimum value: 0
num? height;
/// Maximum value: 9007199254740991
int? height;
/// Asset ID
String id;
@@ -165,7 +166,8 @@ class AssetResponseDto {
/// Asset width
///
/// Minimum value: 0
num? width;
/// Maximum value: 9007199254740991
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -246,9 +248,7 @@ class AssetResponseDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checksum'] = this.checksum;
json[r'createdAt'] = _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.createdAt.millisecondsSinceEpoch
: this.createdAt.toUtc().toIso8601String();
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
if (this.duplicateId != null) {
json[r'duplicateId'] = this.duplicateId;
} else {
@@ -264,12 +264,8 @@ class AssetResponseDto {
} else {
// json[r'exifInfo'] = 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();
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();
json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
json[r'hasMetadata'] = this.hasMetadata;
if (this.height != null) {
json[r'height'] = this.height;
@@ -292,9 +288,7 @@ class AssetResponseDto {
} else {
// json[r'livePhotoVideoId'] = 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();
json[r'localDateTime'] = this.localDateTime.toUtc().toIso8601String();
json[r'originalFileName'] = this.originalFileName;
if (this.originalMimeType != null) {
json[r'originalMimeType'] = this.originalMimeType;
@@ -327,9 +321,7 @@ class AssetResponseDto {
}
json[r'type'] = this.type;
json[r'unassignedFaces'] = this.unassignedFaces;
json[r'updatedAt'] = _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.updatedAt.millisecondsSinceEpoch
: this.updatedAt.toUtc().toIso8601String();
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
@@ -349,16 +341,14 @@ class AssetResponseDto {
return AssetResponseDto(
checksum: mapValueOfType<String>(json, r'checksum')!,
createdAt: mapDateTime(json, r'createdAt', 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))$/')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
duration: mapValueOfType<String>(json, r'duration'),
exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
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))$/')!,
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
@@ -367,7 +357,7 @@ class AssetResponseDto {
isTrashed: mapValueOfType<bool>(json, r'isTrashed')!,
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))$/')!,
localDateTime: mapDateTime(json, r'localDateTime', r'')!,
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
originalMimeType: mapValueOfType<String>(json, r'originalMimeType'),
originalPath: mapValueOfType<String>(json, r'originalPath')!,
@@ -380,11 +370,9 @@ class AssetResponseDto {
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', 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))$/')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
width: mapValueOfType<int>(json, r'width'),
);
}
return null;
+12 -8
View File
@@ -22,22 +22,26 @@ class CropParameters {
/// Height of the crop
///
/// Minimum value: 1
num height;
/// Maximum value: 9007199254740991
int height;
/// Width of the crop
///
/// Minimum value: 1
num width;
/// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
num x;
/// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
num y;
/// Maximum value: 9007199254740991
int y;
@override
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
@@ -75,10 +79,10 @@ class CropParameters {
final json = value.cast<String, dynamic>();
return CropParameters(
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
);
}
return null;
+3 -2
View File
@@ -27,7 +27,8 @@ class DatabaseBackupConfig {
/// Keep last amount
///
/// Minimum value: 1
num keepLastAmount;
/// Maximum value: 9007199254740991
int keepLastAmount;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
@@ -64,7 +65,7 @@ class DatabaseBackupConfig {
return DatabaseBackupConfig(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
keepLastAmount: num.parse('${json[r'keepLastAmount']}'),
keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
);
}
return null;
+5 -2
View File
@@ -22,7 +22,10 @@ class DatabaseBackupDto {
String filename;
/// Backup file size
num filesize;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int filesize;
/// Backup timezone
String timezone;
@@ -61,7 +64,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'),
filesize: mapValueOfType<int>(json, r'filesize')!,
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}
+20 -24
View File
@@ -52,12 +52,14 @@ class ExifResponseDto {
/// Image height in pixels
///
/// Minimum value: 0
num? exifImageHeight;
/// Maximum value: 9007199254740991
int? exifImageHeight;
/// Image width in pixels
///
/// Minimum value: 0
num? exifImageWidth;
/// Maximum value: 9007199254740991
int? exifImageWidth;
/// Exposure time
String? exposureTime;
@@ -75,7 +77,10 @@ class ExifResponseDto {
num? focalLength;
/// ISO sensitivity
num? iso;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? iso;
/// GPS latitude
num? latitude;
@@ -102,7 +107,10 @@ class ExifResponseDto {
String? projectionType;
/// Rating
num? rating;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? rating;
/// State/province name
String? state;
@@ -177,9 +185,7 @@ class ExifResponseDto {
// json[r'country'] = null;
}
if (this.dateTimeOriginal != null) {
json[r'dateTimeOriginal'] = _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.dateTimeOriginal!.millisecondsSinceEpoch
: this.dateTimeOriginal!.toUtc().toIso8601String();
json[r'dateTimeOriginal'] = this.dateTimeOriginal!.toUtc().toIso8601String();
} else {
// json[r'dateTimeOriginal'] = null;
}
@@ -249,9 +255,7 @@ class ExifResponseDto {
// json[r'model'] = null;
}
if (this.modifyDate != null) {
json[r'modifyDate'] = _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.modifyDate!.millisecondsSinceEpoch
: this.modifyDate!.toUtc().toIso8601String();
json[r'modifyDate'] = this.modifyDate!.toUtc().toIso8601String();
} else {
// json[r'modifyDate'] = null;
}
@@ -294,14 +298,10 @@ class ExifResponseDto {
return ExifResponseDto(
city: mapValueOfType<String>(json, r'city'),
country: mapValueOfType<String>(json, r'country'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', 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))$/'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
description: mapValueOfType<String>(json, r'description'),
exifImageHeight: json[r'exifImageHeight'] == null
? null
: num.parse('${json[r'exifImageHeight']}'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse('${json[r'exifImageWidth']}'),
exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
fNumber: json[r'fNumber'] == null
? null
@@ -310,9 +310,7 @@ class ExifResponseDto {
focalLength: json[r'focalLength'] == null
? null
: num.parse('${json[r'focalLength']}'),
iso: json[r'iso'] == null
? null
: num.parse('${json[r'iso']}'),
iso: mapValueOfType<int>(json, r'iso'),
latitude: json[r'latitude'] == null
? null
: num.parse('${json[r'latitude']}'),
@@ -322,12 +320,10 @@ class ExifResponseDto {
: num.parse('${json[r'longitude']}'),
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
modifyDate: mapDateTime(json, r'modifyDate', 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))$/'),
modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'),
projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: mapValueOfType<int>(json, r'rating'),
state: mapValueOfType<String>(json, r'state'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
);
@@ -21,9 +21,13 @@ class MachineLearningAvailabilityChecksDto {
/// Enabled
bool enabled;
num interval;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int interval;
num timeout;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int timeout;
@override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
@@ -59,8 +63,8 @@ class MachineLearningAvailabilityChecksDto {
return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: num.parse('${json[r'interval']}'),
timeout: num.parse('${json[r'timeout']}'),
interval: mapValueOfType<int>(json, r'interval')!,
timeout: mapValueOfType<int>(json, r'timeout')!,
);
}
return null;
@@ -20,7 +20,10 @@ class MaintenanceDetectInstallStorageFolderDto {
});
/// Number of files in the folder
num files;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int files;
StorageFolder folder;
@@ -66,7 +69,7 @@ class MaintenanceDetectInstallStorageFolderDto {
final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto(
files: num.parse('${json[r'files']}'),
files: mapValueOfType<int>(json, r'files')!,
folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!,
@@ -32,13 +32,15 @@ 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.
///
num? progress;
int? progress;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -102,7 +104,7 @@ class MaintenanceStatusResponseDto {
action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'),
progress: num.parse('${json[r'progress']}'),
progress: mapValueOfType<int>(json, r'progress'),
task: mapValueOfType<String>(json, r'task'),
);
}
+7 -8
View File
@@ -215,13 +215,14 @@ 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.
///
num? page;
int? page;
/// Filter by person IDs
List<String> personIds;
@@ -239,7 +240,7 @@ class MetadataSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? rating;
/// Number of results to return
///
@@ -251,7 +252,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.
///
num? size;
int? size;
/// Filter by state/province name
String? state;
@@ -724,15 +725,13 @@ class MetadataSearchDto {
order: AssetOrder.fromJson(json[r'order']),
originalFileName: mapValueOfType<String>(json, r'originalFileName'),
originalPath: mapValueOfType<String>(json, r'originalPath'),
page: num.parse('${json[r'page']}'),
page: mapValueOfType<int>(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: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(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 -4
View File
@@ -83,9 +83,7 @@ class PartnerResponseDto {
// json[r'inTimeline'] = null;
}
json[r'name'] = this.name;
json[r'profileChangedAt'] = _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.profileChangedAt.millisecondsSinceEpoch
: this.profileChangedAt.toUtc().toIso8601String();
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
return json;
}
@@ -104,7 +102,7 @@ class PartnerResponseDto {
id: mapValueOfType<String>(json, r'id')!,
inTimeline: mapValueOfType<bool>(json, r'inTimeline'),
name: mapValueOfType<String>(json, r'name')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', 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))$/')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
);
}
+4 -8
View File
@@ -94,9 +94,7 @@ class PersonResponseDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.birthDate != null) {
json[r'birthDate'] = _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])))$/')
? this.birthDate!.millisecondsSinceEpoch
: _dateFormatter.format(this.birthDate!.toUtc());
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
} else {
// json[r'birthDate'] = null;
}
@@ -115,9 +113,7 @@ class PersonResponseDto {
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
if (this.updatedAt != null) {
json[r'updatedAt'] = _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.updatedAt!.millisecondsSinceEpoch
: this.updatedAt!.toUtc().toIso8601String();
json[r'updatedAt'] = this.updatedAt!.toUtc().toIso8601String();
} else {
// json[r'updatedAt'] = null;
}
@@ -133,14 +129,14 @@ class PersonResponseDto {
final json = value.cast<String, dynamic>();
return PersonResponseDto(
birthDate: mapDateTime(json, r'birthDate', 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])))$/'),
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', 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))$/'),
updatedAt: mapDateTime(json, r'updatedAt', r''),
);
}
return null;
@@ -99,9 +99,7 @@ class PersonWithFacesResponseDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.birthDate != null) {
json[r'birthDate'] = _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])))$/')
? this.birthDate!.millisecondsSinceEpoch
: _dateFormatter.format(this.birthDate!.toUtc());
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
} else {
// json[r'birthDate'] = null;
}
@@ -121,9 +119,7 @@ class PersonWithFacesResponseDto {
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
if (this.updatedAt != null) {
json[r'updatedAt'] = _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.updatedAt!.millisecondsSinceEpoch
: this.updatedAt!.toUtc().toIso8601String();
json[r'updatedAt'] = this.updatedAt!.toUtc().toIso8601String();
} else {
// json[r'updatedAt'] = null;
}
@@ -139,7 +135,7 @@ class PersonWithFacesResponseDto {
final json = value.cast<String, dynamic>();
return PersonWithFacesResponseDto(
birthDate: mapDateTime(json, r'birthDate', 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])))$/'),
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
id: mapValueOfType<String>(json, r'id')!,
@@ -147,7 +143,7 @@ class PersonWithFacesResponseDto {
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', 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))$/'),
updatedAt: mapDateTime(json, r'updatedAt', r''),
);
}
return null;
+4 -6
View File
@@ -147,7 +147,7 @@ class RandomSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? 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.
///
num? size;
int? size;
/// Filter by state/province name
String? state;
@@ -549,10 +549,8 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(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)
+3 -2
View File
@@ -39,13 +39,14 @@ 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.
///
num? duration;
int? duration;
@override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
@@ -94,7 +95,7 @@ class SessionCreateDto {
return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: num.parse('${json[r'duration']}'),
duration: mapValueOfType<int>(json, r'duration'),
);
}
return null;
+7 -8
View File
@@ -154,13 +154,14 @@ 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.
///
num? page;
int? page;
/// Filter by person IDs
List<String> personIds;
@@ -187,7 +188,7 @@ class SmartSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? rating;
/// Number of results to return
///
@@ -199,7 +200,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.
///
num? size;
int? size;
/// Filter by state/province name
String? state;
@@ -583,16 +584,14 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'),
page: num.parse('${json[r'page']}'),
page: mapValueOfType<int>(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: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(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 -4
View File
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? rating;
/// Filter by state/province name
String? state;
@@ -479,9 +479,7 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: mapValueOfType<int>(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)
+3 -4
View File
@@ -57,7 +57,8 @@ class SystemConfigOAuthDto {
/// Default storage quota
///
/// Minimum value: 0
num? defaultStorageQuota;
/// Maximum value: 9007199254740991
int? defaultStorageQuota;
/// Enabled
bool enabled;
@@ -200,9 +201,7 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: json[r'defaultStorageQuota'] == null
? null
: num.parse('${json[r'defaultStorageQuota']}'),
defaultStorageQuota: mapValueOfType<int>(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
num port;
int 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: num.parse('${json[r'port']}'),
port: mapValueOfType<int>(json, r'port')!,
secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!,
);
+4 -8
View File
@@ -86,9 +86,7 @@ class TagResponseDto {
} else {
// json[r'color'] = null;
}
json[r'createdAt'] = _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.createdAt.millisecondsSinceEpoch
: this.createdAt.toUtc().toIso8601String();
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'id'] = this.id;
json[r'name'] = this.name;
if (this.parentId != null) {
@@ -96,9 +94,7 @@ class TagResponseDto {
} else {
// json[r'parentId'] = null;
}
json[r'updatedAt'] = _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.updatedAt.millisecondsSinceEpoch
: this.updatedAt.toUtc().toIso8601String();
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'value'] = this.value;
return json;
}
@@ -113,11 +109,11 @@ class TagResponseDto {
return TagResponseDto(
color: mapValueOfType<String>(json, r'color'),
createdAt: mapDateTime(json, r'createdAt', 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))$/')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
parentId: mapValueOfType<String>(json, r'parentId'),
updatedAt: mapDateTime(json, r'updatedAt', 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))$/')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
value: mapValueOfType<String>(json, r'value')!,
);
}
+2 -4
View File
@@ -153,9 +153,7 @@ class UserAdminResponseDto {
}
json[r'name'] = this.name;
json[r'oauthId'] = this.oauthId;
json[r'profileChangedAt'] = _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.profileChangedAt.millisecondsSinceEpoch
: this.profileChangedAt.toUtc().toIso8601String();
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
@@ -198,7 +196,7 @@ class UserAdminResponseDto {
license: UserLicense.fromJson(json[r'license']),
name: mapValueOfType<String>(json, r'name')!,
oauthId: mapValueOfType<String>(json, r'oauthId')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', 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))$/')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes'),
+2 -4
View File
@@ -66,9 +66,7 @@ class UserResponseDto {
json[r'email'] = this.email;
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'profileChangedAt'] = _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.profileChangedAt.millisecondsSinceEpoch
: this.profileChangedAt.toUtc().toIso8601String();
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
return json;
}
@@ -86,7 +84,7 @@ class UserResponseDto {
email: mapValueOfType<String>(json, r'email')!,
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', 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))$/')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
);
}
+5 -2
View File
@@ -26,7 +26,10 @@ class WorkflowActionResponseDto {
String id;
/// Action order
num order;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin action ID
String pluginActionId;
@@ -79,7 +82,7 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
order: mapValueOfType<int>(json, r'order')!,
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
+5 -2
View File
@@ -26,7 +26,10 @@ class WorkflowFilterResponseDto {
String id;
/// Filter order
num order;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin filter ID
String pluginFilterId;
@@ -79,7 +82,7 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
order: mapValueOfType<int>(json, r'order')!,
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
@@ -419,8 +419,8 @@ void main() {
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
return assetsByAlbum;
});
@@ -482,12 +482,18 @@ void main() {
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
test("does not request local deletions for permanent remote delete events", () async {
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 {};
});
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
+69 -77
View File
@@ -7964,8 +7964,9 @@
"description": "Page number for pagination",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"default": 1,
"type": "number"
"type": "integer"
}
},
{
@@ -7977,7 +7978,7 @@
"minimum": 1,
"maximum": 1000,
"default": 500,
"type": "number"
"type": "integer"
}
},
{
@@ -9372,7 +9373,7 @@
],
"x-immich-state": "Stable",
"schema": {
"type": "number",
"type": "integer",
"minimum": -1,
"maximum": 5,
"nullable": true
@@ -9386,7 +9387,7 @@
"schema": {
"minimum": 1,
"maximum": 1000,
"type": "number"
"type": "integer"
}
},
{
@@ -15300,9 +15301,7 @@
},
"createdAt": {
"description": "Creation date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"description": {
@@ -15311,9 +15310,7 @@
},
"endDate": {
"description": "End date (latest asset)",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"hasSharedLink": {
@@ -15330,9 +15327,7 @@
},
"lastModifiedAssetTimestamp": {
"description": "Last modified asset timestamp",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"order": {
@@ -15344,16 +15339,12 @@
},
"startDate": {
"description": "Start date (earliest asset)",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"updatedAt": {
"description": "Last update date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
}
},
@@ -15646,7 +15637,9 @@
},
"dateTimeRelative": {
"description": "Relative time offset in seconds",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"description": {
"description": "Asset description",
@@ -16628,9 +16621,7 @@
},
"createdAt": {
"description": "The UTC timestamp when the asset was originally uploaded to Immich.",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"duplicateId": {
@@ -16648,16 +16639,12 @@
},
"fileCreatedAt": {
"description": "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.",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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": "The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"hasMetadata": {
@@ -16666,9 +16653,10 @@
},
"height": {
"description": "Asset height",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"id": {
"description": "Asset ID",
@@ -16730,9 +16718,7 @@
},
"localDateTime": {
"description": "The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by \"local\" days and months.",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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": {
@@ -16805,9 +16791,7 @@
},
"updatedAt": {
"description": "The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"visibility": {
@@ -16815,9 +16799,10 @@
},
"width": {
"description": "Asset width",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
}
},
"required": [
@@ -17234,23 +17219,27 @@
"properties": {
"height": {
"description": "Height of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"width": {
"description": "Width of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"x": {
"description": "Top-Left X coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "number"
"type": "integer"
},
"y": {
"description": "Top-Left Y coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "number"
"type": "integer"
}
},
"required": [
@@ -17274,8 +17263,9 @@
},
"keepLastAmount": {
"description": "Keep last amount",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
}
},
"required": [
@@ -17308,7 +17298,9 @@
},
"filesize": {
"description": "Backup file size",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"timezone": {
"description": "Backup timezone",
@@ -17634,10 +17626,8 @@
"dateTimeOriginal": {
"default": null,
"description": "Original 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"
},
"description": {
@@ -17649,16 +17639,18 @@
"exifImageHeight": {
"default": null,
"description": "Image height in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"exifImageWidth": {
"default": null,
"description": "Image width in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"exposureTime": {
"default": null,
@@ -17689,8 +17681,10 @@
"iso": {
"default": null,
"description": "ISO sensitivity",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "number"
"type": "integer"
},
"latitude": {
"default": null,
@@ -17725,10 +17719,8 @@
"modifyDate": {
"default": null,
"description": "Modification 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"
},
"orientation": {
@@ -17746,8 +17738,10 @@
"rating": {
"default": null,
"description": "Rating",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "number"
"type": "integer"
},
"state": {
"default": null,
@@ -18174,10 +18168,14 @@
"type": "boolean"
},
"interval": {
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"timeout": {
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}
},
"required": [
@@ -18227,7 +18225,9 @@
"properties": {
"files": {
"description": "Number of files in the folder",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"folder": {
"$ref": "#/components/schemas/StorageFolder"
@@ -18270,7 +18270,9 @@
"type": "string"
},
"progress": {
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"task": {
"type": "string"
@@ -18747,8 +18749,9 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"personIds": {
"description": "Filter by person IDs",
@@ -18768,7 +18771,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -18790,7 +18793,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "number"
"type": "integer"
},
"state": {
"description": "Filter by state/province name",
@@ -19269,9 +19272,7 @@
},
"profileChangedAt": {
"description": "Profile change date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"profileImagePath": {
@@ -19626,10 +19627,8 @@
"properties": {
"birthDate": {
"description": "Person date of birth",
"example": "2024-01-01",
"format": "date",
"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])))$",
"type": "string"
},
"color": {
@@ -19680,9 +19679,7 @@
},
"updatedAt": {
"description": "Last update date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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",
"x-immich-history": [
{
@@ -19759,10 +19756,8 @@
"properties": {
"birthDate": {
"description": "Person date of birth",
"example": "2024-01-01",
"format": "date",
"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])))$",
"type": "string"
},
"color": {
@@ -19819,9 +19814,7 @@
},
"updatedAt": {
"description": "Last update date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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",
"x-immich-history": [
{
@@ -20631,7 +20624,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -20653,7 +20646,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "number"
"type": "integer"
},
"state": {
"description": "Filter by state/province name",
@@ -21471,8 +21464,9 @@
},
"duration": {
"description": "Session duration in seconds",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
}
},
"type": "object"
@@ -21986,8 +21980,9 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"personIds": {
"description": "Filter by person IDs",
@@ -22013,7 +22008,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -22035,7 +22030,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "number"
"type": "integer"
},
"state": {
"description": "Filter by state/province name",
@@ -22273,7 +22268,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -24405,9 +24400,10 @@
},
"defaultStorageQuota": {
"description": "Default storage quota",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"enabled": {
"description": "Enabled",
@@ -24582,7 +24578,7 @@
"description": "SMTP server port",
"maximum": 65535,
"minimum": 0,
"type": "number"
"type": "integer"
},
"secure": {
"description": "Whether to use secure connection (TLS/SSL)",
@@ -24848,9 +24844,7 @@
},
"createdAt": {
"description": "Creation date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"id": {
@@ -24867,9 +24861,7 @@
},
"updatedAt": {
"description": "Last update date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"value": {
@@ -25523,9 +25515,7 @@
},
"profileChangedAt": {
"description": "Profile change date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"profileImagePath": {
@@ -25807,9 +25797,7 @@
},
"profileChangedAt": {
"description": "Profile change date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"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"
},
"profileImagePath": {
@@ -26008,7 +25996,9 @@
},
"order": {
"description": "Action order",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"pluginActionId": {
"description": "Plugin action ID",
@@ -26107,7 +26097,9 @@
},
"order": {
"description": "Filter order",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"pluginFilterId": {
"description": "Plugin filter ID",
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.7.5",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
"engines": {
"pnpm": ">=10.0.0"
}
+960 -998
View File
File diff suppressed because it is too large Load Diff
+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.2.6",
"@nestjs/swagger": "^11.4.2",
"@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.62.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0",
"@opentelemetry/instrumentation-pg": "^0.66.0",
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.67.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.215.0",
+4 -4
View File
@@ -42,19 +42,19 @@ import { configureUserAgent } from 'src/utils/fetch';
const common = [...repositories, ...services, GlobalExceptionFilter];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const commonMiddleware = [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useClass: ZodValidationPipe },
...(configRepository.isDev() ? [{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor }] : []),
{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
];
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const commonImports = [
ClsModule.forRoot(cls.config),
KyselyModule.forRoot(getKyselyConfig(database.config)),
@@ -1,6 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AddUsersDto,
@@ -28,7 +27,6 @@ export class AlbumController {
@Get()
@Authenticated({ permission: Permission.AlbumRead })
@ZodSerializerDto([AlbumResponseDto])
@Endpoint({
summary: 'List all albums',
description: 'Retrieve a list of albums available to the authenticated user.',
@@ -40,7 +38,6 @@ export class AlbumController {
@Post()
@Authenticated({ permission: Permission.AlbumCreate })
@ZodSerializerDto(AlbumResponseDto)
@Endpoint({
summary: 'Create an album',
description: 'Create a new album. The album can also be created with initial users and assets.',
@@ -63,7 +60,6 @@ export class AlbumController {
@Authenticated({ permission: Permission.AlbumRead, sharedLink: true })
@Get(':id')
@ZodSerializerDto(AlbumResponseDto)
@Endpoint({
summary: 'Retrieve an album',
description: 'Retrieve information about a specific album by its ID.',
@@ -75,7 +71,6 @@ export class AlbumController {
@Patch(':id')
@Authenticated({ permission: Permission.AlbumUpdate })
@ZodSerializerDto(AlbumResponseDto)
@Endpoint({
summary: 'Update an album',
description:
@@ -157,7 +152,6 @@ export class AlbumController {
@Put(':id/users')
@Authenticated({ permission: Permission.AlbumUserCreate })
@ZodSerializerDto(AlbumResponseDto)
@Endpoint({
summary: 'Share album with users',
description: 'Share an album with multiple users. Each user can be given a specific role in the album.',
@@ -1,10 +1,7 @@
import { AssetController } from 'src/controllers/asset.controller';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetMetadataKey } from 'src/enum';
import { AssetService } from 'src/services/asset.service';
import request from 'supertest';
import { AssetFactory } from 'test/factories/asset.factory';
import { getForAsset } from 'test/mappers';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
@@ -186,10 +183,6 @@ describe(AssetController.name, () => {
});
describe('PUT /assets/:id', () => {
beforeEach(() => {
service.update.mockResolvedValue(mapAsset(getForAsset(AssetFactory.create())));
});
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/123`);
expect(ctx.authenticate).toHaveBeenCalled();
@@ -1,6 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
@@ -80,7 +79,6 @@ export class AssetController {
@Get(':id')
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
@ZodSerializerDto(AssetResponseDto)
@Endpoint({
summary: 'Retrieve an asset',
description: 'Retrieve detailed information about a specific asset.',
@@ -130,7 +128,6 @@ export class AssetController {
@Put(':id')
@Authenticated({ permission: Permission.AssetUpdate })
@ZodSerializerDto(AssetResponseDto)
@Endpoint({
summary: 'Update an asset',
description: 'Update information of a specific asset.',
@@ -1,9 +1,7 @@
import { AuthController } from 'src/controllers/auth.controller';
import { LoginResponseDto } from 'src/dtos/auth.dto';
import { mapUserAdmin } from 'src/dtos/user.dto';
import { AuthService } from 'src/services/auth.service';
import request from 'supertest';
import { UserFactory } from 'test/factories/user.factory';
import { mediumFactory } from 'test/medium.factory';
import { errorDto } from 'test/medium/responses';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
@@ -55,7 +53,6 @@ describe(AuthController.name, () => {
it('should transform email to lower case', async () => {
service.adminSignUp.mockReset();
service.adminSignUp.mockResolvedValue(mapUserAdmin(UserFactory.create()));
const { status } = await request(ctx.getHttpServer())
.post('/auth/admin-sign-up')
.send({ name: 'admin', password: 'password', email: 'aDmIn@IMMICH.cloud' });
@@ -64,7 +61,6 @@ describe(AuthController.name, () => {
});
it('should accept an email with a local domain', async () => {
service.adminSignUp.mockResolvedValue(mapUserAdmin(UserFactory.create()));
const { status } = await request(ctx.getHttpServer())
.post('/auth/admin-sign-up')
.send({ name: 'admin', password: 'password', email: 'admin@local' });
@@ -1,7 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AuthDto,
@@ -51,7 +50,6 @@ export class AuthController {
}
@Post('admin-sign-up')
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Register admin',
description: 'Create the first admin user in the system.',
@@ -76,7 +74,6 @@ export class AuthController {
@Post('change-password')
@Authenticated({ permission: Permission.AuthChangePassword })
@HttpCode(HttpStatus.OK)
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Change password',
description: 'Change the password of the current user.',
@@ -1,6 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -45,7 +44,6 @@ export class FaceController {
@Put(':id')
@Authenticated({ permission: Permission.FaceUpdate })
@ZodSerializerDto(PersonResponseDto)
@Endpoint({
summary: 'Re-assign a face to another person',
description: 'Re-assign the face provided in the body to the person identified by the id in the path parameter.',
@@ -1,7 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AuthDto,
@@ -90,7 +89,6 @@ export class OAuthController {
@Post('link')
@Authenticated()
@HttpCode(HttpStatus.OK)
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Link OAuth account',
description: 'Link an OAuth account to the authenticated user.',
@@ -107,7 +105,6 @@ export class OAuthController {
@Post('unlink')
@Authenticated()
@HttpCode(HttpStatus.OK)
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Unlink OAuth account',
description: 'Unlink the OAuth account from the authenticated user.',
@@ -14,7 +14,6 @@ import {
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -59,7 +58,6 @@ export class PersonController {
@Post()
@Authenticated({ permission: Permission.PersonCreate })
@ZodSerializerDto(PersonResponseDto)
@Endpoint({
summary: 'Create a person',
description: 'Create a new person that can have multiple faces assigned to them.',
@@ -94,7 +92,6 @@ export class PersonController {
@Get(':id')
@Authenticated({ permission: Permission.PersonRead })
@ZodSerializerDto(PersonResponseDto)
@Endpoint({
summary: 'Get a person',
description: 'Retrieve a person by id.',
@@ -106,7 +103,6 @@ export class PersonController {
@Put(':id')
@Authenticated({ permission: Permission.PersonUpdate })
@ZodSerializerDto(PersonResponseDto)
@Endpoint({
summary: 'Update person',
description: 'Update an individual person.',
@@ -162,7 +158,6 @@ export class PersonController {
@Put(':id/reassign')
@Authenticated({ permission: Permission.PersonReassign })
@ZodSerializerDto([PersonResponseDto])
@Endpoint({
summary: 'Reassign faces',
description: 'Bulk reassign a list of faces to a different person.',
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
});
it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
});
@@ -1,6 +1,5 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -55,7 +54,6 @@ export class SearchController {
@Post('random')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@ZodSerializerDto([AssetResponseDto])
@Endpoint({
summary: 'Search random assets',
description: 'Retrieve a random selection of assets based on the provided criteria.',
@@ -68,7 +66,6 @@ export class SearchController {
@Post('large-assets')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@ZodSerializerDto([AssetResponseDto])
@Endpoint({
summary: 'Search large assets',
description: 'Search for assets that are considered large based on specified criteria.',
@@ -103,7 +100,6 @@ export class SearchController {
@Get('person')
@Authenticated({ permission: Permission.PersonRead })
@ZodSerializerDto([PersonResponseDto])
@Endpoint({
summary: 'Search people',
description: 'Search for people by name.',
@@ -126,7 +122,6 @@ export class SearchController {
@Get('cities')
@Authenticated({ permission: Permission.AssetRead })
@ZodSerializerDto([AssetResponseDto])
@Endpoint({
summary: 'Retrieve assets by city',
description:
-6
View File
@@ -1,6 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -24,7 +23,6 @@ export class TagController {
@Post()
@Authenticated({ permission: Permission.TagCreate })
@ZodSerializerDto(TagResponseDto)
@Endpoint({
summary: 'Create a tag',
description: 'Create a new tag by providing a name and optional color.',
@@ -36,7 +34,6 @@ export class TagController {
@Get()
@Authenticated({ permission: Permission.TagRead })
@ZodSerializerDto([TagResponseDto])
@Endpoint({
summary: 'Retrieve tags',
description: 'Retrieve a list of all tags.',
@@ -48,7 +45,6 @@ export class TagController {
@Put()
@Authenticated({ permission: Permission.TagCreate })
@ZodSerializerDto([TagResponseDto])
@Endpoint({
summary: 'Upsert tags',
description: 'Create or update multiple tags in a single request.',
@@ -71,7 +67,6 @@ export class TagController {
@Get(':id')
@Authenticated({ permission: Permission.TagRead })
@ZodSerializerDto(TagResponseDto)
@Endpoint({
summary: 'Retrieve a tag',
description: 'Retrieve a specific tag by its ID.',
@@ -83,7 +78,6 @@ export class TagController {
@Put(':id')
@Authenticated({ permission: Permission.TagUpdate })
@ZodSerializerDto(TagResponseDto)
@Endpoint({
summary: 'Update a tag',
description: 'Update an existing tag identified by its ID.',
@@ -1,6 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -25,7 +24,6 @@ export class UserAdminController {
@Get()
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
@ZodSerializerDto([UserAdminResponseDto])
@Endpoint({
summary: 'Search users',
description: 'Search for users.',
@@ -37,7 +35,6 @@ export class UserAdminController {
@Post()
@Authenticated({ permission: Permission.AdminUserCreate, admin: true })
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Create a user',
description: 'Create a new user.',
@@ -49,7 +46,6 @@ export class UserAdminController {
@Get(':id')
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Retrieve a user',
description: 'Retrieve a specific user by their ID.',
@@ -61,7 +57,6 @@ export class UserAdminController {
@Put(':id')
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Update a user',
description: 'Update an existing user.',
@@ -77,7 +72,6 @@ export class UserAdminController {
@Delete(':id')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Delete a user',
description: 'Delete a user.',
@@ -146,7 +140,6 @@ export class UserAdminController {
@Post(':id/restore')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@HttpCode(HttpStatus.OK)
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Restore a deleted user',
description: 'Restore a previously deleted user.',
@@ -15,7 +15,6 @@ import {
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
@@ -41,7 +40,6 @@ export class UserController {
@Get()
@Authenticated({ permission: Permission.UserRead })
@ZodSerializerDto([UserResponseDto])
@Endpoint({
summary: 'Get all users',
description: 'Retrieve a list of all users on the server.',
@@ -53,7 +51,6 @@ export class UserController {
@Get('me')
@Authenticated({ permission: Permission.UserRead })
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Get current user',
description: 'Retrieve information about the user making the API request.',
@@ -65,7 +62,6 @@ export class UserController {
@Put('me')
@Authenticated({ permission: Permission.UserUpdate })
@ZodSerializerDto(UserAdminResponseDto)
@Endpoint({
summary: 'Update current user',
description: 'Update the current user making the API request.',
@@ -170,7 +166,6 @@ export class UserController {
@Get(':id')
@Authenticated({ permission: Permission.UserRead })
@ZodSerializerDto(UserResponseDto)
@Endpoint({
summary: 'Retrieve a user',
description: 'Retrieve a specific user by their ID.',
@@ -1,6 +1,5 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ZodSerializerDto } from 'nestjs-zod';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -26,7 +25,6 @@ export class ViewController {
@Get('folder')
@Authenticated({ permission: Permission.FolderRead })
@ZodSerializerDto([AssetResponseDto])
@Endpoint({
summary: 'Retrieve assets by original path',
description: 'Retrieve assets that are children of a specific folder.',
+2 -2
View File
@@ -11,8 +11,8 @@ describe('mapAlbum', () => {
.asset({ localDateTime: startDate }, (builder) => builder.exif())
.build();
const dto = mapAlbum(getForAlbum(album));
expect(dto.startDate).toEqual(startDate);
expect(dto.endDate).toEqual(endDate);
expect(dto.startDate).toEqual(startDate.toISOString());
expect(dto.endDate).toEqual(endDate.toISOString());
});
it('should not set start and end dates for empty assets', () => {
+21 -11
View File
@@ -6,7 +6,8 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { isoDatetimeToDate, stringToBool } from 'src/validation';
import { asDateString } from 'src/utils/date';
import { stringToBool } from 'src/validation';
import z from 'zod';
const AlbumUserAddSchema = z
@@ -104,8 +105,10 @@ export const AlbumResponseSchema = z
id: z.string().describe('Album ID'),
albumName: z.string().describe('Album name'),
description: z.string().describe('Album description'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
albumThumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
shared: z.boolean().describe('Is shared album'),
albumUsers: z
@@ -116,9 +119,16 @@ export const AlbumResponseSchema = z
),
hasSharedLink: z.boolean().describe('Has shared link'),
assetCount: z.int().min(0).describe('Number of assets'),
lastModifiedAssetTimestamp: isoDatetimeToDate.optional().describe('Last modified asset timestamp'),
startDate: isoDatetimeToDate.optional().describe('Start date (earliest asset)'),
endDate: isoDatetimeToDate.optional().describe('End date (latest asset)'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
.string()
.meta({ format: 'date-time' })
.optional()
.describe('Last modified asset timestamp'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
startDate: z.string().meta({ format: 'date-time' }).optional().describe('Start date (earliest asset)'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
endDate: z.string().meta({ format: 'date-time' }).optional().describe('End date (latest asset)'),
isActivityEnabled: z.boolean().describe('Activity feed enabled'),
order: AssetOrderSchema.optional(),
contributorCounts: z.array(ContributorCountResponseSchema).optional(),
@@ -134,7 +144,7 @@ export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {}
export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {}
export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {}
export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {}
export class AlbumResponseDto extends createZodDto(AlbumResponseSchema, { codec: true }) {}
export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {}
class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {}
export type MapAlbumDto = {
@@ -180,14 +190,14 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
albumName: entity.albumName,
description: entity.description,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: new Date(entity.createdAt),
updatedAt: new Date(entity.updatedAt),
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
id: entity.id,
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
startDate: asDateString(startDate),
endDate: asDateString(endDate),
assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
order: entity.order,
+42 -24
View File
@@ -25,8 +25,8 @@ import {
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { asDateString } from 'src/utils/date';
import { mimeTypes } from 'src/utils/mime-types';
import { isoDatetimeToDate } from 'src/validation';
import z from 'zod';
const SanitizedAssetResponseSchema = z
@@ -40,18 +40,22 @@ const SanitizedAssetResponseSchema = z
)
.nullable(),
originalMimeType: z.string().optional().describe('Original MIME type'),
localDateTime: isoDatetimeToDate.describe(
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
localDateTime: z
.string()
.meta({ format: 'date-time' })
.describe(
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
),
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.number().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
})
.meta({ id: 'SanitizedAssetResponseDto' });
export class SanitizedAssetResponseDto extends createZodDto(SanitizedAssetResponseSchema, { codec: true }) {}
export class SanitizedAssetResponseDto extends createZodDto(SanitizedAssetResponseSchema) {}
const AssetStackResponseSchema = z
.object({
@@ -63,7 +67,11 @@ const AssetStackResponseSchema = z
export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
z.object({
createdAt: isoDatetimeToDate.describe('The UTC timestamp when the asset was originally uploaded to Immich.'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z
.string()
.meta({ format: 'date-time' })
.describe('The UTC timestamp when the asset was originally uploaded to Immich.'),
ownerId: z.string().describe('Owner user ID'),
owner: UserResponseSchema.optional(),
libraryId: z
@@ -73,15 +81,25 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
.meta(new HistoryBuilder().added('v1').deprecated('v1').getExtensions()),
originalPath: z.string().describe('Original file path'),
originalFileName: z.string().describe('Original file name'),
fileCreatedAt: isoDatetimeToDate.describe(
'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.',
),
fileModifiedAt: isoDatetimeToDate.describe(
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
),
updatedAt: isoDatetimeToDate.describe(
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
fileCreatedAt: z
.string()
.meta({ format: 'date-time' })
.describe(
'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.',
),
fileModifiedAt: z
.string()
.meta({ format: 'date-time' })
.describe(
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
),
updatedAt: z
.string()
.meta({ format: 'date-time' })
.describe(
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
),
isFavorite: z.boolean().describe('Is favorite'),
isArchived: z.boolean().describe('Is archived'),
isTrashed: z.boolean().describe('Is trashed'),
@@ -106,7 +124,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
}).shape,
).meta({ id: 'AssetResponseDto' });
export class AssetResponseDto extends createZodDto(AssetResponseSchema, { codec: true }) {}
export class AssetResponseDto extends createZodDto(AssetResponseSchema) {}
export type MapAsset = {
createdAt: Date;
@@ -202,7 +220,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
type: entity.type,
originalMimeType: mimeTypes.lookup(entity.originalFileName),
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
localDateTime: new Date(entity.localDateTime),
localDateTime: asDateString(entity.localDateTime),
duration: entity.duration,
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
@@ -216,7 +234,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
return {
id: entity.id,
createdAt: new Date(entity.createdAt),
createdAt: asDateString(entity.createdAt),
ownerId: entity.ownerId,
owner: entity.owner ? mapUser(entity.owner) : undefined,
libraryId: entity.libraryId,
@@ -225,10 +243,10 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
originalFileName: entity.originalFileName,
originalMimeType: mimeTypes.lookup(entity.originalFileName),
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
fileCreatedAt: new Date(entity.fileCreatedAt),
fileModifiedAt: new Date(entity.fileModifiedAt),
localDateTime: new Date(entity.localDateTime),
updatedAt: new Date(entity.updatedAt),
fileCreatedAt: asDateString(entity.fileCreatedAt),
fileModifiedAt: asDateString(entity.fileModifiedAt),
localDateTime: asDateString(entity.localDateTime),
updatedAt: asDateString(entity.updatedAt),
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
isArchived: entity.visibility === AssetVisibility.Archive,
isTrashed: !!entity.deletedAt,
+1 -1
View File
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
});
+1 -4
View File
@@ -21,10 +21,7 @@ export type AuthDto = {
const LoginCredentialSchema = z
.object({
email: toEmail
.transform((val) => val.toLowerCase())
.describe('User email')
.meta({ example: 'testuser@email.com' }),
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
password: z.string().describe('User password').meta({ example: 'password' }),
})
.meta({ id: 'LoginCredentialDto' });
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z
.object({
filename: z.string().describe('Backup filename'),
filesize: z.number().describe('Backup file size'),
filesize: z.int().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'),
})
.meta({ id: 'DatabaseBackupDto' });
+4 -4
View File
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z
.object({
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'),
x: z.int().min(0).describe('Top-Left X coordinate of crop'),
y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
width: z.int().min(1).describe('Width of the crop'),
height: z.int().min(1).describe('Height of the crop'),
})
.meta({ id: 'CropParameters' });
+12 -10
View File
@@ -1,24 +1,26 @@
import { createZodDto } from 'nestjs-zod';
import { Exif } from 'src/database';
import { MaybeDehydrated } from 'src/types';
import { isoDatetimeToDate } from 'src/validation';
import { asDateString } from 'src/utils/date';
import z from 'zod';
export const ExifResponseSchema = z
.object({
make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'),
dateTimeOriginal: isoDatetimeToDate.nullish().default(null).describe('Original date/time'),
modifyDate: isoDatetimeToDate.nullish().default(null).describe('Modification date/time'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
dateTimeOriginal: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Original date/time'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
modifyDate: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Modification date/time'),
timeZone: z.string().nullish().default(null).describe('Time zone'),
lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.number().nullish().default(null).describe('ISO sensitivity'),
iso: z.int().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'),
@@ -27,12 +29,12 @@ export const ExifResponseSchema = z
country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.number().nullish().default(null).describe('Rating'),
rating: z.int().nullish().default(null).describe('Rating'),
})
.describe('EXIF response')
.meta({ id: 'ExifResponseDto' });
class ExifResponseDto extends createZodDto(ExifResponseSchema, { codec: true }) {}
class ExifResponseDto extends createZodDto(ExifResponseSchema) {}
export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
return {
@@ -42,8 +44,8 @@ export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
exifImageHeight: entity.exifImageHeight,
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal ? new Date(entity.dateTimeOriginal) : null,
modifyDate: entity.modifyDate ? new Date(entity.modifyDate) : null,
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
modifyDate: asDateString(entity.modifyDate),
timeZone: entity.timeZone,
lensModel: entity.lensModel,
fNumber: entity.fNumber,
+2 -2
View File
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({
active: z.boolean(),
action: MaintenanceActionSchema,
progress: z.number().optional(),
progress: z.int().optional(),
task: z.string().optional(),
error: z.string().optional(),
})
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'),
files: z.number().describe('Number of files in the folder'),
files: z.int().describe('Number of files in the folder'),
})
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
+14 -9
View File
@@ -7,8 +7,9 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SourceTypeSchema } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { asBirthDateString, asDateString } from 'src/utils/date';
import { transformFaceBoundingBox } from 'src/utils/transform';
import { emptyStringToNull, hexColor, isoDateToDate, isoDatetimeToDate, stringToBool } from 'src/validation';
import { emptyStringToNull, hexColor, stringToBool } from 'src/validation';
import z from 'zod';
const PersonCreateSchema = z
@@ -50,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
})
.meta({ id: 'PersonSearchDto' });
@@ -59,10 +60,14 @@ const PersonResponseSchema = z
.object({
id: z.string().describe('Person ID'),
name: z.string().describe('Person name'),
birthDate: isoDateToDate.nullable().describe('Person date of birth'),
// TODO: use `isoDateToDate` when using `ZodSerializerDto` on the controllers.
birthDate: z.string().meta({ format: 'date' }).describe('Person date of birth').nullable(),
thumbnailPath: z.string().describe('Thumbnail path'),
isHidden: z.boolean().describe('Is hidden'),
updatedAt: isoDatetimeToDate
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
updatedAt: z
.string()
.meta({ format: 'date-time' })
.optional()
.describe('Last update date')
.meta(new HistoryBuilder().added('v1.107.0').stable('v2').getExtensions()),
@@ -84,7 +89,7 @@ export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {}
export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {}
export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema, { codec: true }) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
export const AssetFaceWithoutPersonResponseSchema = z
.object({
@@ -106,7 +111,7 @@ export const PersonWithFacesResponseSchema = PersonResponseSchema.extend({
faces: z.array(AssetFaceWithoutPersonResponseSchema),
}).meta({ id: 'PersonWithFacesResponseDto' });
export class PersonWithFacesResponseDto extends createZodDto(PersonWithFacesResponseSchema, { codec: true }) {}
export class PersonWithFacesResponseDto extends createZodDto(PersonWithFacesResponseSchema) {}
const AssetFaceResponseSchema = AssetFaceWithoutPersonResponseSchema.extend({
person: PersonResponseSchema.nullable(),
@@ -179,12 +184,12 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
return {
id: person.id,
name: person.name,
birthDate: person.birthDate ? new Date(person.birthDate) : null,
birthDate: asBirthDateString(person.birthDate),
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: person.updatedAt ? new Date(person.updatedAt) : undefined,
updatedAt: asDateString(person.updatedAt),
};
}
+5 -5
View File
@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z
.number()
.int()
.min(-1)
.max(5)
.nullish()
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
});
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z
.object({
duration: z.number().min(1).optional().describe('Session duration in seconds'),
duration: z.int().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'),
})
+5 -5
View File
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({
enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema,
keepLastAmount: z.number().min(1).describe('Keep last amount'),
keepLastAmount: z.int().min(1).describe('Keep last amount'),
})
.meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z
.object({
enabled: configBool.describe('Enabled'),
timeout: z.number(),
interval: z.number(),
timeout: z.int(),
interval: z.int(),
})
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'),
defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'),
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled'),
issuerUrl: z
.string()
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'),
port: z.number().min(0).max(65_535).describe('SMTP server port'),
port: z.int().min(0).max(65_535).describe('SMTP server port'),
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'),
+9 -6
View File
@@ -1,7 +1,8 @@
import { createZodDto } from 'nestjs-zod';
import { Tag } from 'src/database';
import { MaybeDehydrated } from 'src/types';
import { emptyStringToNull, hexColor, isoDatetimeToDate } from 'src/validation';
import { asDateString } from 'src/utils/date';
import { emptyStringToNull, hexColor } from 'src/validation';
import z from 'zod';
const TagCreateSchema = z
@@ -43,8 +44,10 @@ export const TagResponseSchema = z
parentId: z.string().optional().describe('Parent tag ID'),
name: z.string().describe('Tag name'),
value: z.string().describe('Tag value (full path)'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
color: z.string().optional().describe('Tag color (hex)'),
})
.meta({ id: 'TagResponseDto' });
@@ -54,7 +57,7 @@ export class TagUpdateDto extends createZodDto(TagUpdateSchema) {}
export class TagUpsertDto extends createZodDto(TagUpsertSchema) {}
export class TagBulkAssetsDto extends createZodDto(TagBulkAssetsSchema) {}
export class TagBulkAssetsResponseDto extends createZodDto(TagBulkAssetsResponseSchema) {}
export class TagResponseDto extends createZodDto(TagResponseSchema, { codec: true }) {}
export class TagResponseDto extends createZodDto(TagResponseSchema) {}
export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
return {
@@ -62,8 +65,8 @@ export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
parentId: entity.parentId ?? undefined,
name: entity.value.split('/').at(-1) as string,
value: entity.value,
createdAt: new Date(entity.createdAt),
updatedAt: new Date(entity.updatedAt),
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
color: entity.color ?? undefined,
};
}
+9 -13
View File
@@ -3,15 +3,13 @@ import { User, UserAdmin } from 'src/database';
import { pinCodeRegex } from 'src/dtos/auth.dto';
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
import { asDateString } from 'src/utils/date';
import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
import z from 'zod';
export const UserUpdateMeSchema = z
.object({
email: toEmail
.transform((val) => val.toLowerCase())
.optional()
.describe('User email'),
email: toEmail.optional().describe('User email'),
password: z
.string()
.optional()
@@ -31,11 +29,12 @@ export const UserResponseSchema = z
email: toEmail.describe('User email'),
profileImagePath: z.string().describe('Profile image path'),
avatarColor: UserAvatarColorSchema,
profileChangedAt: isoDatetimeToDate.describe('Profile change date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
profileChangedAt: z.string().meta({ format: 'date-time' }).describe('Profile change date'),
})
.meta({ id: 'UserResponseDto' });
export class UserResponseDto extends createZodDto(UserResponseSchema, { codec: true }) {}
export class UserResponseDto extends createZodDto(UserResponseSchema) {}
const licenseKeyRegex = /^IM(SV|CL)(-[\dA-Za-z]{4}){8}$/;
@@ -62,7 +61,7 @@ export const mapUser = (entity: MaybeDehydrated<User | UserAdmin>): UserResponse
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
profileChangedAt: new Date(entity.profileChangedAt),
profileChangedAt: asDateString(entity.profileChangedAt),
};
};
@@ -77,7 +76,7 @@ export class UserAdminSearchDto extends createZodDto(UserAdminSearchSchema) {}
export const UserAdminCreateSchema = z
.object({
email: toEmail.transform((val) => val.toLowerCase()).describe('User email'),
email: toEmail.describe('User email'),
password: z.string().describe('User password'),
name: z.string().describe('User name'),
avatarColor: UserAvatarColorSchema.nullish(),
@@ -97,10 +96,7 @@ export class UserAdminCreateDto extends createZodDto(UserAdminCreateSchema) {}
const UserAdminUpdateSchema = z
.object({
email: toEmail
.transform((val) => val.toLowerCase())
.optional()
.describe('User email'),
email: toEmail.optional().describe('User email'),
password: z.string().optional().describe('User password'),
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
.optional()
@@ -139,7 +135,7 @@ const UserAdminResponseSchema = UserResponseSchema.extend({
license: UserLicenseSchema.nullable(),
}).meta({ id: 'UserAdminResponseDto' });
export class UserAdminResponseDto extends createZodDto(UserAdminResponseSchema, { codec: true }) {}
export class UserAdminResponseDto extends createZodDto(UserAdminResponseSchema) {}
export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
const metadata = entity.metadata || [];
+2 -2
View File
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(),
order: z.number().describe('Filter order'),
order: z.int().describe('Filter order'),
})
.meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(),
order: z.number().describe('Action order'),
order: z.int().describe('Action order'),
})
.meta({ id: 'WorkflowActionResponseDto' });
+7 -1
View File
@@ -22,7 +22,7 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
CorrelationId = 'X-Correlation-ID',
}
export enum ImmichQuery {
@@ -445,6 +445,12 @@ export enum VideoCodec {
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
export enum VideoSegmentCodec {
Av1 = 'av1',
Hevc = 'hevc',
H264 = 'h264',
}
export enum AudioCodec {
Mp3 = 'mp3',
Aac = 'aac',
@@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod';
@@ -16,18 +17,13 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
}
catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
this.handleError(host.switchToHttp().getResponse<Response>(), error);
}
handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error);
if (!res.headersSent) {
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
res.header(ImmichHeader.CorrelationId, this.cls.getId()).status(status).json(body);
}
}
@@ -36,26 +32,24 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof HttpException) {
const status = error.getStatus();
let body = error.getResponse();
// unclear what circumstances would return a string
if (typeof body === 'string') {
body = { message: body };
}
const response = error.getResponse();
const body: Record<string, unknown> =
typeof response === 'string' ? { message: response } : { ...(response as object) };
// handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) {
body = {
message: zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
),
error: 'Bad Request',
};
body['message'] = zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
);
}
}
// remove fields that duplicate the HTTP response line or will be reformatted in a later step
delete body['error'];
delete body['statusCode'];
delete body['errors'];
return { status, body };
}
@@ -0,0 +1,46 @@
-- NOTE: This file is auto generated by ./sql-generator
-- VideoStreamRepository.getSession
select
*
from
"video_stream_session"
where
"id" = $1
-- VideoStreamRepository.getVariant
select
*
from
"video_stream_variant"
where
"id" = $1
-- VideoStreamRepository.getSegment
select
*
from
"video_stream_segment"
where
"variantId" = $1
and "index" = $2
-- VideoStreamRepository.getExpiredSessions
select
"id"
from
"video_stream_session"
where
"expiresAt" <= $1
-- VideoStreamRepository.extendSession
update "video_stream_session"
set
"expiresAt" = $1
where
"id" = $2
-- VideoStreamRepository.deleteSession
delete from "video_stream_session"
where
"id" = $1
+2 -4
View File
@@ -301,11 +301,9 @@ const getEnv = (): EnvData => {
mount: true,
generateId: true,
setup: (cls, req: Request, res: Response) => {
const headerValues = req.headers[ImmichHeader.Cid];
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
const cid = req.header(ImmichHeader.CorrelationId) || cls.get(CLS_ID);
cls.set(CLS_ID, cid);
res.header(ImmichHeader.Cid, cid);
res.header(ImmichHeader.CorrelationId, cid);
},
},
},
+2
View File
@@ -46,6 +46,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -100,6 +101,7 @@ export const repositories = [
UserRepository,
ViewRepository,
VersionHistoryRepository,
VideoStreamRepository,
WebsocketRepository,
WorkflowRepository,
];
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
@Injectable()
export class VideoStreamRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
createSession(session: Insertable<VideoStreamSessionTable>) {
return this.db.insertInto('video_stream_session').values(session).returning(['id']).executeTakeFirstOrThrow();
}
createVariant(variant: Insertable<VideoStreamVariantTable>) {
return this.db.insertInto('video_stream_variant').values(variant).returning(['id']).executeTakeFirstOrThrow();
}
async createSegment(segment: Insertable<VideoStreamSegmentTable>) {
await this.db.insertInto('video_stream_segment').values(segment).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getSession(id: string) {
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getVariant(id: string) {
return this.db.selectFrom('video_stream_variant').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
getSegment(variantId: string, index: number) {
return this.db
.selectFrom('video_stream_segment')
.selectAll()
.where('variantId', '=', variantId)
.where('index', '=', index)
.executeTakeFirst();
}
@GenerateSql()
getExpiredSessions() {
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
async extendSession(id: string, expiresAt: Date) {
await this.db.updateTable('video_stream_session').set({ expiresAt }).where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteSession(id: string) {
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
}
}
+13 -1
View File
@@ -1,5 +1,12 @@
import { registerEnum } from '@immich/sql-tools';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
import {
AlbumUserRole,
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
@@ -25,3 +32,8 @@ export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm),
});
export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: Object.values(VideoSegmentCodec),
});
+12
View File
@@ -76,6 +76,11 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@@ -133,6 +138,9 @@ export class ImmichDatabase {
UserMetadataAuditTable,
UserTable,
VersionHistoryTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
VideoStreamSegmentTable,
PluginTable,
PluginFilterTable,
PluginActionTable,
@@ -247,6 +255,10 @@ export interface DB {
version_history: VersionHistoryTable;
video_stream_session: VideoStreamSessionTable;
video_stream_variant: VideoStreamVariantTable;
video_stream_segment: VideoStreamSegmentTable;
plugin: PluginTable;
plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable;
@@ -0,0 +1,40 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "video_stream_variant_codec_enum" AS ENUM ('av1','hevc','h264');`.execute(db);
await sql`CREATE TABLE "video_stream_session" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"assetId" uuid NOT NULL,
"expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "video_stream_session_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_session_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "video_stream_session_assetId_idx" ON "video_stream_session" ("assetId");`.execute(db);
await sql`CREATE INDEX "video_stream_session_expiresAt_idx" ON "video_stream_session" ("expiresAt");`.execute(db);
await sql`CREATE TABLE "video_stream_variant" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"sessionId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"bitrate" integer NOT NULL,
"codec" video_stream_variant_codec_enum NOT NULL,
"resolution" smallint NOT NULL,
CONSTRAINT "video_stream_variant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "video_stream_session" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_variant_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "video_stream_variant_sessionId_bitrate_resolution_codec_idx" ON "video_stream_variant" ("sessionId", "bitrate", "resolution", "codec");`.execute(db);
await sql`CREATE TABLE "video_stream_segment" (
"variantId" uuid NOT NULL,
"index" integer NOT NULL,
"durationUs" integer NOT NULL,
CONSTRAINT "video_stream_segment_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "video_stream_variant" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_segment_pkey" PRIMARY KEY ("variantId", "index")
);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "video_stream_segment";`.execute(db);
await sql`DROP TABLE "video_stream_variant";`.execute(db);
await sql`DROP TABLE "video_stream_session";`.execute(db);
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
}
@@ -0,0 +1,63 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryColumn,
PrimaryGeneratedColumn,
Table,
Timestamp,
} from '@immich/sql-tools';
import { VideoSegmentCodec } from 'src/enum';
import { video_stream_variant_codec_enum } from 'src/schema/enums';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('video_stream_session')
export class VideoStreamSessionTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE' })
assetId!: string;
@Column({ type: 'timestamp with time zone', index: true })
expiresAt!: Timestamp;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}
@Index({ columns: ['sessionId', 'bitrate', 'resolution', 'codec'], unique: true })
@Table('video_stream_variant')
export class VideoStreamVariantTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => VideoStreamSessionTable, { onDelete: 'CASCADE', index: false })
sessionId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'integer' })
bitrate!: number;
@Column({ enum: video_stream_variant_codec_enum })
codec!: VideoSegmentCodec;
@Column({ type: 'smallint' })
resolution!: number;
}
@Table('video_stream_segment')
export class VideoStreamSegmentTable {
@ForeignKeyColumn(() => VideoStreamVariantTable, { onDelete: 'CASCADE', primary: true, index: false })
variantId!: string;
@PrimaryColumn({ type: 'integer' })
index!: number;
@Column({ type: 'integer' })
durationUs!: number;
}
@@ -196,6 +196,7 @@ describe(AlbumService.name, () => {
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: albumUser.userId,
+8 -8
View File
@@ -19,6 +19,7 @@ import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { asDateString } from 'src/utils/date';
import { getPreferences } from 'src/utils/preferences';
@Injectable()
@@ -62,11 +63,11 @@ export class AlbumService extends BaseService {
return albums.map((album) => ({
...mapAlbum(album),
sharedLinks: undefined,
startDate: albumMetadata[album.id]?.startDate ?? undefined,
endDate: albumMetadata[album.id]?.endDate ?? undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined),
}));
}
@@ -82,10 +83,10 @@ export class AlbumService extends BaseService {
return {
...mapAlbum(album),
startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds?.endDate ?? undefined,
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined,
lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined,
};
}
@@ -113,7 +114,6 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Cannot share album with owner');
}
}
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
const allowedAssetIdsSet = await this.checkAccess({
auth,
@@ -132,7 +132,7 @@ export class AlbumService extends BaseService {
order: getPreferences(userMetadata).albums.defaultAssetOrder,
},
assetIds,
albumUsers,
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
auth.user.id,
);
+3
View File
@@ -53,6 +53,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -109,6 +110,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
TrashRepository,
UserRepository,
VersionHistoryRepository,
VideoStreamRepository,
ViewRepository,
WebsocketRepository,
WorkflowRepository,
@@ -167,6 +169,7 @@ export class BaseService {
protected trashRepository: TrashRepository,
protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository,
protected videoStreamRepository: VideoStreamRepository,
protected viewRepository: ViewRepository,
protected websocketRepository: WebsocketRepository,
protected workflowRepository: WorkflowRepository,
+4 -6
View File
@@ -211,12 +211,11 @@ describe(PersonService.name, () => {
await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({
id: person.id,
name: person.name,
birthDate: new Date('1976-06-30'),
birthDate: '1976-06-30',
thumbnailPath: person.thumbnailPath,
isHidden: false,
isFavorite: false,
color: undefined,
updatedAt: expect.any(Date),
updatedAt: expect.any(String),
});
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: '1976-06-30' });
expect(mocks.job.queue).not.toHaveBeenCalled();
@@ -486,11 +485,10 @@ describe(PersonService.name, () => {
birthDate: person.birthDate,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
color: undefined,
id: person.id,
name: person.name,
thumbnailPath: person.thumbnailPath,
updatedAt: expect.any(Date),
updatedAt: expect.any(String),
});
expect(mocks.job.queue).not.toHaveBeenCalledWith();
@@ -850,7 +848,7 @@ describe(PersonService.name, () => {
facesRecognizedAt: expect.any(Date),
});
const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date;
expect(facesRecognizedAt.getTime()).toBeGreaterThanOrEqual(start);
expect(facesRecognizedAt.getTime()).toBeGreaterThan(start);
});
it('should create a face with no person and queue recognition job', async () => {

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