Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-04-30 20:24:18 +02:00
143 changed files with 3088 additions and 2141 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tools] [tools]
terragrunt = "1.0.1" terragrunt = "1.0.2"
opentofu = "1.11.6" opentofu = "1.11.6"
[tasks."tg:fmt"] [tasks."tg:fmt"]
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78 image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
@@ -81,7 +81,7 @@ VectorChord is the successor extension to pgvecto.rs, allowing for higher perfor
### Migrating from pgvecto.rs ### Migrating from pgvecto.rs
Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so. Support for pgvecto.rs has been dropped as of 3.0, hence all users currently using pgvecto.rs should migrate to VectorChord. There are two primary approaches to do so.
The easiest option is to have both extensions installed during the migration: The easiest option is to have both extensions installed during the migration:
+1 -1
View File
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
### Cons ### 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. - 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. - Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy ## Option 3: Reverse Proxy
+1 -1
View File
@@ -81,7 +81,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> | | `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> | | `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
| `DB_SSL_MODE` | Database SSL mode | | server | | `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server | | `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | | `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database | | `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
-4
View File
@@ -130,7 +130,3 @@ These storage mediums have different performance characteristics. As a result, t
#### Can I use the new database image as a general PostgreSQL image outside of Immich? #### Can I use the new database image as a general PostgreSQL image outside of Immich?
Its a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image. Its a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
-39
View File
@@ -2,82 +2,43 @@ import { expect } from 'vitest';
export const errorDto = { export const errorDto = {
unauthorized: { unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required', message: 'Authentication required',
correlationId: expect.any(String),
}, },
unauthorizedWithMessage: (message: string) => ({ unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message, message,
correlationId: expect.any(String),
}), }),
forbidden: { forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String), message: expect.any(String),
correlationId: expect.any(String),
}, },
missingPermission: (permission: string) => ({ missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`, message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}), }),
wrongPassword: { wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password', message: 'Wrong password',
correlationId: expect.any(String),
}, },
invalidToken: { invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token', message: 'Invalid user token',
correlationId: expect.any(String),
}, },
invalidShareKey: { invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key', message: 'Invalid share key',
correlationId: expect.any(String),
}, },
passwordRequired: { passwordRequired: {
error: 'Unauthorized',
statusCode: 401,
message: 'Password required', message: 'Password required',
correlationId: expect.any(String),
}, },
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(), message: message ?? expect.anything(),
correlationId: expect.any(String),
}), }),
noPermission: { noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'), message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
}, },
incorrectLogin: { incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password', message: 'Incorrect email or password',
correlationId: expect.any(String),
}, },
alreadyHasAdmin: { alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin', message: 'The server already has an admin',
correlationId: expect.any(String),
}, },
invalidEmail: { invalidEmail: {
error: 'Bad Request',
statusCode: 400,
message: ['email must be an email'], 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); const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(500); expect(status).toBe(500);
expect(body).toMatchObject({ expect(body).toMatchObject({
error: 'Internal Server Error',
message: 'Failed to finish oauth', message: 'Failed to finish oauth',
statusCode: 500,
}); });
}); });
@@ -495,11 +493,10 @@ describe(`/oauth`, () => {
}); });
it('should reject OAuth discovery over HTTP', async () => { it('should reject OAuth discovery over HTTP', async () => {
const { status, body } = await request(app) const { status } = await request(app)
.post('/oauth/authorize') .post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' }); .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(500); expect(status).toBe(500);
expect(body).toMatchObject({ statusCode: 500 });
}); });
}); });
}); });
@@ -32,8 +32,12 @@ export function generateThumbhash(rng: SeededRandom): string {
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join(''); return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
} }
export function generateDuration(rng: SeededRandom): string { export function generateDuration(rng: SeededRandom): number {
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`; return (
rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) *
1000 +
rng.nextInt(0, 1000)
);
} }
export function generateUUID(): string { export function generateUUID(): string {
@@ -43,7 +43,7 @@ export type MockTimelineAsset = {
isTrashed: boolean; isTrashed: boolean;
isVideo: boolean; isVideo: boolean;
isImage: boolean; isImage: boolean;
duration: string | null; duration: number | null;
projectionType: string | null; projectionType: string | null;
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
city: string | null; city: string | null;
+4 -4
View File
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
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.32.7/intel-igc-core-2_2.32.7+21184_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/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.05.37020.3/intel-opencl-icd_26.05.37020.3-0_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-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/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 && \ 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 # 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 && \ dpkg -i *.deb && \
rm *.deb && \ rm *.deb && \
apt-get remove wget -yqq && \ apt-get remove wget -yqq && \
+4 -1
View File
@@ -183,7 +183,10 @@ async def predict(
text: str | None = Form(default=None), text: str | None = Form(default=None),
) -> Any: ) -> Any:
if image is not None: 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: elif text is not None:
inputs = text inputs = text
else: else:
+2 -2
View File
@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0", "aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0", "fastapi>=0.95.2,<1.0",
"gunicorn>=21.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", "insightface>=0.7.3,<1.0",
"numpy<2.4.0", "numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0", "opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5", "orjson>=3.9.5",
"pillow>=12.2,<12.3", "pillow>=12.2,<13",
"pydantic>=2.0.0,<3", "pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3", "pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0", "python-multipart>=0.0.6,<1.0",
+13
View File
@@ -1198,6 +1198,19 @@ class TestLoad:
mock_model.model_format = ModelFormat.ONNX 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: def test_root_endpoint(deployed_app: TestClient) -> None:
response = deployed_app.get("http://localhost:3003") response = deployed_app.get("http://localhost:3003")
+3 -3
View File
@@ -15,9 +15,9 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
flutter = "3.41.6" flutter = "3.41.7"
pnpm = "10.33.0" pnpm = "10.33.1"
terragrunt = "1.0.1" terragrunt = "1.0.2"
opentofu = "1.11.6" opentofu = "1.11.6"
java = "21.0.2" java = "21.0.2"
+15 -15
View File
@@ -751,7 +751,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -760,7 +760,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.121.0; MARKETING_VERSION = 1.121.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile; PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile;
PRODUCT_NAME = "Immich-Profile"; PRODUCT_NAME = "Immich-Profile";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -895,7 +895,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -904,7 +904,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.121.0; MARKETING_VERSION = 1.121.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug; PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug;
PRODUCT_NAME = "Immich-Debug"; PRODUCT_NAME = "Immich-Debug";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -925,7 +925,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -958,7 +958,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -975,7 +975,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget; PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.Widget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1001,7 +1001,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1041,7 +1041,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1057,7 +1057,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget; PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.Widget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1081,7 +1081,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1098,7 +1098,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -1125,7 +1125,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1166,7 +1166,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240; CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1182,7 +1182,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
+26 -26
View File
@@ -1,35 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionAttributes</key> <key>NSExtensionAttributes</key>
<dict> <dict>
<key>IntentsSupported</key> <key>IntentsSupported</key>
<array> <array>
<string>INSendMessageIntent</string> <string>INSendMessageIntent</string>
</array> </array>
<key>NSExtensionActivationRule</key> <key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, <string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count &gt; 0 $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count &gt; 0
).@count &gt; 0 </string> ).@count &gt; 0 </string>
<key>PHSupportedMediaTypes</key> <key>PHSupportedMediaTypes</key>
<array> <array>
<string>Video</string> <string>Video</string>
<string>Image</string> <string>Image</string>
</array> </array>
</dict> </dict>
<key>NSExtensionMainStoryboard</key> <key>NSExtensionMainStoryboard</key>
<string>MainInterface</string> <string>MainInterface</string>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string> <string>com.apple.share-services</string>
</dict> </dict>
</dict> </dict>
</plist> </plist>
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.app.immich.share</string> <string>group.app.immich.share</string>
</array> </array>
</dict> </dict>
</plist> </plist>
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.app.immich.share</string> <string>group.app.immich.share</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -1,5 +1,5 @@
app_identifier "app.alextran.immich" # The bundle identifier of your app 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: # For more information about the Appfile, see:
+41 -41
View File
@@ -17,10 +17,11 @@ default_platform(:ios)
platform :ios do platform :ios do
# Constants # Constants
TEAM_ID = "2F67MQ8R79" TEAM_ID = "2W7AC6T8T5"
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})" CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich" BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
# Helper method to get App Store Connect API key # Helper method to get App Store Connect API key
def get_api_key def get_api_key
app_store_connect_api_key( app_store_connect_api_key(
@@ -44,47 +45,45 @@ def get_version_from_pubspec
end end
# Helper method to configure code signing for all targets # 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:) def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app) # Runner (main app)
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}", bundle_identifier: base_bundle_id,
profile_name: profile_name_main, profile_name: profile_name_main,
targets: ["Runner"] targets: ["Runner"]
) )
# ShareExtension # ShareExtension
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, 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, profile_name: profile_name_share,
targets: ["ShareExtension"] targets: ["ShareExtension"]
) )
# WidgetExtension # WidgetExtension
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, 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, profile_name: profile_name_widget,
targets: ["WidgetExtension"] targets: ["WidgetExtension"]
) )
end end
# Helper method to build and upload to TestFlight # Helper method to build and upload to TestFlight
def build_and_upload( def build_and_upload(
api_key:, api_key:,
bundle_id_suffix: "", base_bundle_id:,
configuration: "Release", configuration: "Release",
distribute_external: true, distribute_external: true,
version_number: nil, version_number: nil,
@@ -92,9 +91,8 @@ end
profile_name_share:, profile_name_share:,
profile_name_widget: profile_name_widget:
) )
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" app_identifier = base_bundle_id
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
# Set version number if provided # Set version number if provided
if version_number if version_number
increment_version_number(version_number: version_number) increment_version_number(version_number: version_number)
@@ -138,31 +136,31 @@ end
desc "iOS Development Build to TestFlight (requires separate bundle ID)" desc "iOS Development Build to TestFlight (requires separate bundle ID)"
lane :gha_testflight_dev do lane :gha_testflight_dev do
api_key = get_api_key api_key = get_api_key
# Download and install provisioning profiles from App Store Connect # Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain # Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call # 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] 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] 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] widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names # Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing( configure_code_signing(
bundle_id_suffix: "development", base_bundle_id: DEV_BUNDLE_ID,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
) )
# Build and upload # Build and upload
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
bundle_id_suffix: "development", base_bundle_id: DEV_BUNDLE_ID,
configuration: "Profile", configuration: "Profile",
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
@@ -189,6 +187,7 @@ end
# Configure code signing for production bundle IDs # Configure code signing for production bundle IDs
configure_code_signing( configure_code_signing(
base_bundle_id: BASE_BUNDLE_ID,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
@@ -197,6 +196,7 @@ end
# Build and upload with version number # Build and upload with version number
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
base_bundle_id: BASE_BUNDLE_ID,
version_number: get_version_from_pubspec, version_number: get_version_from_pubspec,
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
@@ -243,30 +243,30 @@ end
desc "iOS Build Only (no TestFlight upload)" desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do lane :gha_build_only do
# Use the same build process as production, just skip the upload # Use the same build process as the dev TestFlight lane, just skip the upload
# This ensures PR builds validate the same way as production builds # This ensures PR builds validate the same way as dev TestFlight builds
api_key = get_api_key api_key = get_api_key
# Download and install provisioning profiles from App Store Connect # Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain # 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] 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] 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] widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs # Configure code signing for dev bundle IDs
configure_code_signing( configure_code_signing(
bundle_id_suffix: "development", base_bundle_id: DEV_BUNDLE_ID,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
) )
# Build the app (same as gha_testflight_dev but without upload) # Build the app (same as gha_testflight_dev but without upload)
build_app( build_app(
scheme: "Runner", scheme: "Runner",
@@ -277,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: { export_options: {
provisioningProfiles: { provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => main_profile_name, DEV_BUNDLE_ID => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name, "#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name "#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
}, },
signingStyle: "manual", signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY signingCertificate: CODE_SIGN_IDENTITY
@@ -3,6 +3,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; 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/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@@ -192,17 +193,22 @@ class SyncStreamService {
final remoteSyncAssets = data.cast<SyncAssetV1>(); final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets); await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission(); await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
if (hasPermission) { }
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum)); return;
await _applyRemoteRestoreToLocal(); case SyncEntityType.assetV2:
} else { final remoteSyncAssets = data.cast<SyncAssetV2>();
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing"); await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
} if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
} }
return; return;
case SyncEntityType.assetDeleteV1: case SyncEntityType.assetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast()); final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
case SyncEntityType.assetExifV1: case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast()); return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetEditV1: case SyncEntityType.assetEditV1:
@@ -215,8 +221,12 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast()); return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
case SyncEntityType.partnerAssetV1: case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetBackfillV1: case SyncEntityType.partnerAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.partnerAssetBackfillV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.partnerAssetDeleteV1: case SyncEntityType.partnerAssetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner"); return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner");
case SyncEntityType.partnerAssetExifV1: case SyncEntityType.partnerAssetExifV1:
@@ -237,10 +247,16 @@ class SyncStreamService {
return _syncStreamRepository.deleteAlbumUsersV1(data.cast()); return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
case SyncEntityType.albumAssetCreateV1: case SyncEntityType.albumAssetCreateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create');
case SyncEntityType.albumAssetCreateV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset create');
case SyncEntityType.albumAssetUpdateV1: case SyncEntityType.albumAssetUpdateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update');
case SyncEntityType.albumAssetUpdateV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset update');
case SyncEntityType.albumAssetBackfillV1: case SyncEntityType.albumAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill');
case SyncEntityType.albumAssetBackfillV2:
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset backfill');
case SyncEntityType.albumAssetExifCreateV1: case SyncEntityType.albumAssetExifCreateV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create'); return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create');
case SyncEntityType.albumAssetExifUpdateV1: case SyncEntityType.albumAssetExifUpdateV1:
@@ -346,6 +362,47 @@ class SyncStreamService {
} }
} }
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetUploadReadyV2 events');
final List<SyncAssetV2> assets = [];
final List<SyncAssetExifV1> exifs = [];
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
final exifData = payload['exif'];
if (assetData == null || exifData == null) {
continue;
}
final asset = SyncAssetV2.fromJson(assetData);
final exif = SyncAssetExifV1.fromJson(exifData);
if (asset != null && exif != null) {
assets.add(asset);
exifs.add(exif);
}
}
if (assets.isNotEmpty && exifs.isNotEmpty) {
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
_logger.info('Successfully processed ${assets.length} assets in batch');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetUploadReadyV2 websocket batch events", error, stackTrace);
}
}
Future<void> handleWsAssetEditReadyV1(dynamic data) async { Future<void> handleWsAssetEditReadyV1(dynamic data) async {
_logger.info('Processing AssetEditReadyV1 event'); _logger.info('Processing AssetEditReadyV1 event');
@@ -386,28 +443,67 @@ class SyncStreamService {
} }
} }
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async { Future<void> handleWsAssetEditReadyV2(dynamic data) async {
if (checksums.isEmpty) { _logger.info('Processing AssetEditReadyV2 event');
try {
if (data is! Map<String, dynamic>) {
throw ArgumentError("Invalid data format for AssetEditReadyV2 event");
}
final payload = data;
if (payload['asset'] == null) {
throw ArgumentError("Missing 'asset' field in AssetEditReadyV2 event data");
}
final asset = SyncAssetV2.fromJson(payload['asset']);
if (asset == null) {
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV2 event data");
}
final assetEdits = (payload['edit'] as List<dynamic>)
.map((e) => SyncAssetEditV1.fromJson(e))
.whereType<SyncAssetEditV1>()
.toList();
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
_logger.info(
'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits',
);
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
}
}
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value(); return Future.value();
} else { } else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums); final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
if (localAssetsToTrash.isNotEmpty) { if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait( await _trashLocalAssets(localAssetsToTrash);
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else { } 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 { Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) { if (assetsToRestore.isNotEmpty) {
@@ -417,4 +513,23 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration"); _logger.info("No remote assets found for restoration");
} }
} }
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
await _applyRemoteRestoreToLocal();
}
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
}
} }
+32 -2
View File
@@ -186,7 +186,7 @@ class BackgroundSyncManager {
}); });
} }
Future<void> syncWebsocketBatch(List<dynamic> batchData) { Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
if (_syncWebsocketTask != null) { if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future; return _syncWebsocketTask!.future;
} }
@@ -196,7 +196,17 @@ class BackgroundSyncManager {
}); });
} }
Future<void> syncWebsocketEdit(dynamic data) { Future<void> syncWebsocketBatchV2(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetUploadReadyV2Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncWebsocketEditV1(dynamic data) {
if (_syncWebsocketTask != null) { if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future; return _syncWebsocketTask!.future;
} }
@@ -206,6 +216,16 @@ class BackgroundSyncManager {
}); });
} }
Future<void> syncWebsocketEditV2(dynamic data) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV2(data);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() { Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) { if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future; return _linkedAlbumSyncTask!.future;
@@ -242,7 +262,17 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
debugLabel: 'websocket-batch', debugLabel: 'websocket-batch',
); );
Cancelable<void> _handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV2Batch(batchData),
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle( Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data), computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
debugLabel: 'websocket-edit', debugLabel: 'websocket-edit',
); );
Cancelable<void> _handleWsAssetEditReadyV2(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV2(data),
debugLabel: 'websocket-edit',
);
+2 -3
View File
@@ -1,6 +1,5 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:openapi/api.dart' as api; import 'package:openapi/api.dart' as api;
@@ -14,7 +13,7 @@ extension DTOToAsset on api.AssetResponseDto {
updatedAt: updatedAt, updatedAt: updatedAt,
ownerId: ownerId, ownerId: ownerId,
visibility: visibility.toAssetVisibility(), visibility: visibility.toAssetVisibility(),
durationMs: duration?.toDuration()?.inMilliseconds ?? 0, durationMs: duration,
height: height?.toInt(), height: height?.toInt(),
width: width?.toInt(), width: width?.toInt(),
isFavorite: isFavorite, isFavorite: isFavorite,
@@ -36,7 +35,7 @@ extension DTOToAsset on api.AssetResponseDto {
updatedAt: updatedAt, updatedAt: updatedAt,
ownerId: ownerId, ownerId: ownerId,
visibility: visibility.toAssetVisibility(), visibility: visibility.toAssetVisibility(),
durationMs: duration?.toDuration()?.inMilliseconds ?? 0, durationMs: duration,
height: height?.toInt(), height: height?.toInt(),
width: width?.toInt(), width: width?.toInt(),
isFavorite: isFavorite, isFavorite: isFavorite,
@@ -109,31 +109,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get(); return query.map((localAlbum) => localAlbum.toDto()).get();
} }
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async { Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (checksums.isEmpty) { if (remoteIds.isEmpty) {
return {}; return {};
} }
final result = <String, List<LocalAsset>>{}; final result = <String, List<LocalAsset>>{};
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) { for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
final rows = final rows =
await (_db.select(_db.localAlbumAssetEntity).join([ 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.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where( ])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.localAssetEntity.checksum.isIn(slice), _db.remoteAssetEntity.id.isIn(slice),
)) ))
.get(); .get();
for (final row in rows) { for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final assetData = row.readTable(_db.localAssetEntity); final asset = row.readTable(_db.localAssetEntity).toDto();
final asset = assetData.toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset); (result[albumId] ??= <LocalAsset>[]).add(asset);
} }
} }
return result; return result;
} }
@@ -46,19 +46,25 @@ class SyncApiRepository {
types: [ types: [
SyncRequestType.authUsersV1, SyncRequestType.authUsersV1,
SyncRequestType.usersV1, SyncRequestType.usersV1,
SyncRequestType.assetsV1, serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.assetsV2
: SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1, SyncRequestType.assetExifsV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1, if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1, SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1, SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1, serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.partnerAssetsV2
: SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1, SyncRequestType.partnerAssetExifsV1,
if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0)) if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
SyncRequestType.albumsV1 SyncRequestType.albumsV1
else else
SyncRequestType.albumsV2, SyncRequestType.albumsV2,
SyncRequestType.albumUsersV1, SyncRequestType.albumUsersV1,
SyncRequestType.albumAssetsV1, serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.albumAssetsV2
: SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1, SyncRequestType.albumAssetExifsV1,
SyncRequestType.albumToAssetsV1, SyncRequestType.albumToAssetsV1,
SyncRequestType.memoriesV1, SyncRequestType.memoriesV1,
@@ -67,8 +73,9 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1, SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1, SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1, SyncRequestType.peopleV1,
if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1, serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2, ? SyncRequestType.assetFacesV2
: SyncRequestType.assetFacesV1,
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1, if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
], ],
reset: shouldReset, reset: shouldReset,
@@ -154,6 +161,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.partnerV1: SyncPartnerV1.fromJson, SyncEntityType.partnerV1: SyncPartnerV1.fromJson,
SyncEntityType.partnerDeleteV1: SyncPartnerDeleteV1.fromJson, SyncEntityType.partnerDeleteV1: SyncPartnerDeleteV1.fromJson,
SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetV2: SyncAssetV2.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson, SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
@@ -161,7 +169,9 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetV2: SyncAssetV2.fromJson,
SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetBackfillV2: SyncAssetV2.fromJson,
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson, SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson,
@@ -172,8 +182,11 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson, SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson,
SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson, SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson,
SyncEntityType.albumAssetCreateV1: SyncAssetV1.fromJson, SyncEntityType.albumAssetCreateV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetCreateV2: SyncAssetV2.fromJson,
SyncEntityType.albumAssetUpdateV1: SyncAssetV1.fromJson, SyncEntityType.albumAssetUpdateV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetUpdateV2: SyncAssetV2.fromJson,
SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetBackfillV2: SyncAssetV2.fromJson,
SyncEntityType.albumAssetExifCreateV1: SyncAssetExifV1.fromJson, SyncEntityType.albumAssetExifCreateV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumAssetExifUpdateV1: SyncAssetExifV1.fromJson, SyncEntityType.albumAssetExifUpdateV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson, SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson,
@@ -222,6 +222,44 @@ class SyncStreamRepository extends DriftDatabaseRepository {
} }
} }
Future<void> updateAssetsV2(Iterable<SyncAssetV2> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final asset in data) {
final companion = RemoteAssetEntityCompanion(
name: Value(asset.originalFileName),
type: Value(asset.type.toAssetType()),
createdAt: Value.absentIfNull(asset.fileCreatedAt),
updatedAt: Value.absentIfNull(asset.fileModifiedAt),
durationMs: Value(asset.duration),
checksum: Value(asset.checksum),
isFavorite: Value(asset.isFavorite),
ownerId: Value(asset.ownerId),
localDateTime: Value(asset.localDateTime),
thumbHash: Value(asset.thumbhash),
deletedAt: Value(asset.deletedAt),
visibility: Value(asset.visibility.toAssetVisibility()),
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
isEdited: Value(asset.isEdited),
);
batch.insert(
_db.remoteAssetEntity,
companion.copyWith(id: Value(asset.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetsV2 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> updateAssetsExifV1(Iterable<SyncAssetExifV1> data, {String debugLabel = 'user'}) async { Future<void> updateAssetsExifV1(Iterable<SyncAssetExifV1> data, {String debugLabel = 'user'}) async {
try { try {
await _db.batch((batch) { await _db.batch((batch) {
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
@@ -365,7 +366,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
} }
BaseAsset displayAsset = asset; 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) { if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex); displayAsset = stackChildren.elementAt(stackIndex);
} }
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class AssetStackRow extends ConsumerWidget { class AssetStackRow extends ConsumerWidget {
final List<RemoteAsset> stack; final List<RemoteAsset> stack;
@@ -15,6 +17,11 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink(); 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)); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/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/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.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/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget { class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key}); final Key? sheetKey;
const MapBottomSheet({super.key, this.sheetKey});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BaseBottomSheet( return BaseBottomSheet(
key: sheetKey,
initialChildSize: 0.25, initialChildSize: 0.25,
maxChildSize: 0.75, maxChildSize: 0.75,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
@@ -49,7 +53,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService; 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.showStorageIndicator = false,
this.lockSelection = false, this.lockSelection = false,
this.heroOffset, this.heroOffset,
this.showStackIndicator = false,
super.key, super.key,
}); });
@@ -30,6 +31,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
final bool showStorageIndicator; final bool showStorageIndicator;
final bool lockSelection; final bool lockSelection;
final int? heroOffset; final int? heroOffset;
final bool showStackIndicator;
@override @override
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState(); ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
@@ -139,7 +141,14 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
duration: Durations.short4, duration: Durations.short4,
child: Align( child: Align(
alignment: Alignment.topRight, 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) if (storageIndicator && asset != null)
@@ -286,8 +295,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null; final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null; final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -295,11 +304,6 @@ class _AssetTypeIcons extends StatelessWidget {
children: [ children: [
if (asset.isVideo) if (asset.isVideo)
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)), 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) if (isLivePhoto)
const Padding( const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0), 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 { class _UploadProgressOverlay extends StatelessWidget {
final double progress; 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/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/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/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.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart'; import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
@@ -53,6 +54,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex(); final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2)); final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25); final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
final GlobalKey _bottomSheetKey = GlobalKey();
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
@override @override
@@ -184,7 +186,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack( return Stack(
children: [ children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady), _Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset), _DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset), _DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
], ],
); );
@@ -224,8 +226,9 @@ class _Map extends StatelessWidget {
class _DynamicBottomSheet extends StatefulWidget { class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset; final ValueNotifier<double> bottomSheetOffset;
final GlobalKey sheetKey;
const _DynamicBottomSheet({required this.bottomSheetOffset}); const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
@override @override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState(); State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
@@ -236,10 +239,13 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>( return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) { onNotification: (notification) {
widget.bottomSheetOffset.value = notification.extent; final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
return true; 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 lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
@@ -253,6 +254,7 @@ class _AssetTileWidget extends ConsumerWidget {
asset, asset,
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
showStackIndicator: showStackIndicator,
heroOffset: heroOffset, heroOffset: heroOffset,
), ),
), ),
@@ -469,6 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(timelineStateProvider.notifier).setScrolling(true); ref.read(timelineStateProvider.notifier).setScrolling(true);
}, },
child: Stack( child: Stack(
clipBehavior: Clip.none,
children: [ children: [
timeline, timeline,
if (isBottomWidgetVisible) if (isBottomWidgetVisible)
+40 -8
View File
@@ -94,8 +94,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state = const WebsocketState(isConnected: false, socket: null); state = const WebsocketState(isConnected: false, socket: null);
}); });
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady); socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1);
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates); socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) { } catch (e) {
@@ -163,16 +165,25 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_ref.read(serverInfoProvider.notifier).handleReleaseInfo(serverVersion, releaseVersion); _ref.read(serverInfoProvider.notifier).handleReleaseInfo(serverVersion, releaseVersion);
} }
void _handleSyncAssetUploadReady(dynamic data) { void _handleSyncAssetUploadReadyV1(dynamic data) {
_batchedAssetUploadReady.add(data); _batchedAssetUploadReady.add(data);
_batchDebouncer.run(_processBatchedAssetUploadReady); _batchDebouncer.run(_processBatchedAssetUploadReadyV1);
} }
void _handleSyncAssetEditReady(dynamic data) { void _handleSyncAssetUploadReadyV2(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data)); _batchedAssetUploadReady.add(data);
_batchDebouncer.run(_processBatchedAssetUploadReadyV2);
} }
void _processBatchedAssetUploadReady() { void _handleSyncAssetEditReadyV1(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
}
void _handleSyncAssetEditReadyV2(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
}
void _processBatchedAssetUploadReadyV1() {
if (_batchedAssetUploadReady.isEmpty) { if (_batchedAssetUploadReady.isEmpty) {
return; return;
} }
@@ -180,7 +191,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false); final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
try { try {
unawaited( unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) { _ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
if (isSyncAlbumEnabled) { if (isSyncAlbumEnabled) {
_ref.read(backgroundSyncProvider).syncLinkedAlbum(); _ref.read(backgroundSyncProvider).syncLinkedAlbum();
} }
@@ -192,6 +203,27 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchedAssetUploadReady.clear(); _batchedAssetUploadReady.clear();
} }
void _processBatchedAssetUploadReadyV2() {
if (_batchedAssetUploadReady.isEmpty) {
return;
}
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
try {
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
if (isSyncAlbumEnabled) {
_ref.read(backgroundSyncProvider).syncLinkedAlbum();
}
}),
);
} catch (error) {
_log.severe("Error processing batched AssetUploadReadyV2 events: $error");
}
_batchedAssetUploadReady.clear();
}
} }
final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) { final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
@@ -148,6 +148,7 @@ enum ActionButtonType {
context.selectedCount == 1, context.selectedCount == 1,
ActionButtonType.unstack => ActionButtonType.unstack =>
context.isOwner && // context.isOwner && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isInLockedView && // !context.isInLockedView && //
context.isStacked, context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
+6 -6
View File
@@ -1234,8 +1234,8 @@ class AssetsApi {
/// * [String] xImmichChecksum: /// * [String] xImmichChecksum:
/// sha1 checksum that can be used for duplicate detection before the file is uploaded /// sha1 checksum that can be used for duplicate detection before the file is uploaded
/// ///
/// * [String] duration: /// * [int] duration:
/// Duration (for videos) /// Duration in milliseconds (for videos)
/// ///
/// * [String] filename: /// * [String] filename:
/// Filename /// Filename
@@ -1253,7 +1253,7 @@ class AssetsApi {
/// Sidecar file data /// Sidecar file data
/// ///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/assets'; final apiPath = r'/assets';
@@ -1358,8 +1358,8 @@ class AssetsApi {
/// * [String] xImmichChecksum: /// * [String] xImmichChecksum:
/// sha1 checksum that can be used for duplicate detection before the file is uploaded /// sha1 checksum that can be used for duplicate detection before the file is uploaded
/// ///
/// * [String] duration: /// * [int] duration:
/// Duration (for videos) /// Duration in milliseconds (for videos)
/// ///
/// * [String] filename: /// * [String] filename:
/// Filename /// Filename
@@ -1377,7 +1377,7 @@ class AssetsApi {
/// Sidecar file data /// Sidecar file data
/// ///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, ); final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+6 -6
View File
@@ -183,15 +183,15 @@ class PeopleApi {
/// * [String] closestPersonId: /// * [String] closestPersonId:
/// Closest person ID for similarity search /// Closest person ID for similarity search
/// ///
/// * [num] page: /// * [int] page:
/// Page number for pagination /// Page number for pagination
/// ///
/// * [num] size: /// * [int] size:
/// Number of items per page /// Number of items per page
/// ///
/// * [bool] withHidden: /// * [bool] withHidden:
/// Include hidden people /// 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 // ignore: prefer_const_declarations
final apiPath = r'/people'; final apiPath = r'/people';
@@ -244,15 +244,15 @@ class PeopleApi {
/// * [String] closestPersonId: /// * [String] closestPersonId:
/// Closest person ID for similarity search /// Closest person ID for similarity search
/// ///
/// * [num] page: /// * [int] page:
/// Page number for pagination /// Page number for pagination
/// ///
/// * [num] size: /// * [int] size:
/// Number of items per page /// Number of items per page
/// ///
/// * [bool] withHidden: /// * [bool] withHidden:
/// Include hidden people /// 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, ); final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+6 -6
View File
@@ -404,10 +404,10 @@ class SearchApi {
/// * [List<String>] personIds: /// * [List<String>] personIds:
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [num] rating: /// * [int] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating [1-5], or null for unrated
/// ///
/// * [num] size: /// * [int] size:
/// Number of results to return /// Number of results to return
/// ///
/// * [String] state: /// * [String] state:
@@ -443,7 +443,7 @@ class SearchApi {
/// ///
/// * [bool] withExif: /// * [bool] withExif:
/// Include EXIF data in response /// 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 // ignore: prefer_const_declarations
final apiPath = r'/search/large-assets'; final apiPath = r'/search/large-assets';
@@ -619,10 +619,10 @@ class SearchApi {
/// * [List<String>] personIds: /// * [List<String>] personIds:
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [num] rating: /// * [int] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating [1-5], or null for unrated
/// ///
/// * [num] size: /// * [int] size:
/// Number of results to return /// Number of results to return
/// ///
/// * [String] state: /// * [String] state:
@@ -658,7 +658,7 @@ class SearchApi {
/// ///
/// * [bool] withExif: /// * [bool] withExif:
/// Include EXIF data in response /// 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, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+5 -2
View File
@@ -37,12 +37,15 @@ class AssetBulkUpdateDto {
/// Relative time offset in seconds /// Relative time offset in seconds
/// ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? dateTimeRelative; int? dateTimeRelative;
/// Asset description /// Asset description
/// ///
@@ -213,7 +216,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto( return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'), dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'), dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'), duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable ids: json[r'ids'] is Iterable
@@ -24,22 +24,26 @@ class AssetEditActionItemDtoParameters {
/// Height of the crop /// Height of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num height; /// Maximum value: 9007199254740991
int height;
/// Width of the crop /// Width of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num width; /// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop /// Top-Left X coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num x; /// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop /// Top-Left Y coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num y; /// Maximum value: 9007199254740991
int y;
/// Rotation angle in degrees /// Rotation angle in degrees
num angle; num angle;
@@ -88,10 +92,10 @@ class AssetEditActionItemDtoParameters {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters( return AssetEditActionItemDtoParameters(
height: num.parse('${json[r'height']}'), height: mapValueOfType<int>(json, r'height')!,
width: num.parse('${json[r'width']}'), width: mapValueOfType<int>(json, r'width')!,
x: num.parse('${json[r'x']}'), x: mapValueOfType<int>(json, r'x')!,
y: num.parse('${json[r'y']}'), y: mapValueOfType<int>(json, r'y')!,
angle: num.parse('${json[r'angle']}'), angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!, axis: MirrorAxis.fromJson(json[r'axis'])!,
); );
+12 -11
View File
@@ -57,8 +57,11 @@ class AssetResponseDto {
/// Duplicate group ID /// Duplicate group ID
String? duplicateId; String? duplicateId;
/// Video/gif duration in hh:mm:ss.SSS format (null for static images) /// Video/gif duration in milliseconds (null for static images)
String? duration; ///
/// Minimum value: 0
/// Maximum value: 2147483647
int? duration;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
@@ -80,7 +83,8 @@ class AssetResponseDto {
/// Asset height /// Asset height
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? height; /// Maximum value: 9007199254740991
int? height;
/// Asset ID /// Asset ID
String id; String id;
@@ -165,7 +169,8 @@ class AssetResponseDto {
/// Asset width /// Asset width
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? width; /// Maximum value: 9007199254740991
int? width;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -341,14 +346,12 @@ class AssetResponseDto {
checksum: mapValueOfType<String>(json, r'checksum')!, checksum: mapValueOfType<String>(json, r'checksum')!,
createdAt: mapDateTime(json, r'createdAt', r'')!, createdAt: mapDateTime(json, r'createdAt', r'')!,
duplicateId: mapValueOfType<String>(json, r'duplicateId'), duplicateId: mapValueOfType<String>(json, r'duplicateId'),
duration: mapValueOfType<String>(json, r'duration'), duration: mapValueOfType<int>(json, r'duration'),
exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!, hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null height: mapValueOfType<int>(json, r'height'),
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!, isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!, isEdited: mapValueOfType<bool>(json, r'isEdited')!,
@@ -372,9 +375,7 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!, visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null width: mapValueOfType<int>(json, r'width'),
? null
: num.parse('${json[r'width']}'),
); );
} }
return null; return null;
+12 -8
View File
@@ -22,22 +22,26 @@ class CropParameters {
/// Height of the crop /// Height of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num height; /// Maximum value: 9007199254740991
int height;
/// Width of the crop /// Width of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num width; /// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop /// Top-Left X coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num x; /// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop /// Top-Left Y coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num y; /// Maximum value: 9007199254740991
int y;
@override @override
bool operator ==(Object other) => identical(this, other) || other is CropParameters && bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
@@ -75,10 +79,10 @@ class CropParameters {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return CropParameters( return CropParameters(
height: num.parse('${json[r'height']}'), height: mapValueOfType<int>(json, r'height')!,
width: num.parse('${json[r'width']}'), width: mapValueOfType<int>(json, r'width')!,
x: num.parse('${json[r'x']}'), x: mapValueOfType<int>(json, r'x')!,
y: num.parse('${json[r'y']}'), y: mapValueOfType<int>(json, r'y')!,
); );
} }
return null; return null;
+3 -2
View File
@@ -27,7 +27,8 @@ class DatabaseBackupConfig {
/// Keep last amount /// Keep last amount
/// ///
/// Minimum value: 1 /// Minimum value: 1
num keepLastAmount; /// Maximum value: 9007199254740991
int keepLastAmount;
@override @override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig && bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
@@ -64,7 +65,7 @@ class DatabaseBackupConfig {
return DatabaseBackupConfig( return DatabaseBackupConfig(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!, cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
keepLastAmount: num.parse('${json[r'keepLastAmount']}'), keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
); );
} }
return null; return null;
+5 -2
View File
@@ -22,7 +22,10 @@ class DatabaseBackupDto {
String filename; String filename;
/// Backup file size /// Backup file size
num filesize; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int filesize;
/// Backup timezone /// Backup timezone
String timezone; String timezone;
@@ -61,7 +64,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto( return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!, filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'), filesize: mapValueOfType<int>(json, r'filesize')!,
timezone: mapValueOfType<String>(json, r'timezone')!, timezone: mapValueOfType<String>(json, r'timezone')!,
); );
} }
+16 -16
View File
@@ -52,12 +52,14 @@ class ExifResponseDto {
/// Image height in pixels /// Image height in pixels
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? exifImageHeight; /// Maximum value: 9007199254740991
int? exifImageHeight;
/// Image width in pixels /// Image width in pixels
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? exifImageWidth; /// Maximum value: 9007199254740991
int? exifImageWidth;
/// Exposure time /// Exposure time
String? exposureTime; String? exposureTime;
@@ -75,7 +77,10 @@ class ExifResponseDto {
num? focalLength; num? focalLength;
/// ISO sensitivity /// ISO sensitivity
num? iso; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? iso;
/// GPS latitude /// GPS latitude
num? latitude; num? latitude;
@@ -102,7 +107,10 @@ class ExifResponseDto {
String? projectionType; String? projectionType;
/// Rating /// Rating
num? rating; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? rating;
/// State/province name /// State/province name
String? state; String? state;
@@ -292,12 +300,8 @@ class ExifResponseDto {
country: mapValueOfType<String>(json, r'country'), country: mapValueOfType<String>(json, r'country'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
exifImageHeight: json[r'exifImageHeight'] == null exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
? null exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
: num.parse('${json[r'exifImageHeight']}'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse('${json[r'exifImageWidth']}'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'), exposureTime: mapValueOfType<String>(json, r'exposureTime'),
fNumber: json[r'fNumber'] == null fNumber: json[r'fNumber'] == null
? null ? null
@@ -306,9 +310,7 @@ class ExifResponseDto {
focalLength: json[r'focalLength'] == null focalLength: json[r'focalLength'] == null
? null ? null
: num.parse('${json[r'focalLength']}'), : num.parse('${json[r'focalLength']}'),
iso: json[r'iso'] == null iso: mapValueOfType<int>(json, r'iso'),
? null
: num.parse('${json[r'iso']}'),
latitude: json[r'latitude'] == null latitude: json[r'latitude'] == null
? null ? null
: num.parse('${json[r'latitude']}'), : num.parse('${json[r'latitude']}'),
@@ -321,9 +323,7 @@ class ExifResponseDto {
modifyDate: mapDateTime(json, r'modifyDate', r''), modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'), orientation: mapValueOfType<String>(json, r'orientation'),
projectionType: mapValueOfType<String>(json, r'projectionType'), projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
timeZone: mapValueOfType<String>(json, r'timeZone'), timeZone: mapValueOfType<String>(json, r'timeZone'),
); );
@@ -21,9 +21,13 @@ class MachineLearningAvailabilityChecksDto {
/// Enabled /// Enabled
bool enabled; bool enabled;
num interval; /// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int interval;
num timeout; /// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int timeout;
@override @override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto && bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
@@ -59,8 +63,8 @@ class MachineLearningAvailabilityChecksDto {
return MachineLearningAvailabilityChecksDto( return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: num.parse('${json[r'interval']}'), interval: mapValueOfType<int>(json, r'interval')!,
timeout: num.parse('${json[r'timeout']}'), timeout: mapValueOfType<int>(json, r'timeout')!,
); );
} }
return null; return null;
@@ -20,7 +20,10 @@ class MaintenanceDetectInstallStorageFolderDto {
}); });
/// Number of files in the folder /// Number of files in the folder
num files; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int files;
StorageFolder folder; StorageFolder folder;
@@ -66,7 +69,7 @@ class MaintenanceDetectInstallStorageFolderDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto( return MaintenanceDetectInstallStorageFolderDto(
files: num.parse('${json[r'files']}'), files: mapValueOfType<int>(json, r'files')!,
folder: StorageFolder.fromJson(json[r'folder'])!, folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!, readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!, writable: mapValueOfType<bool>(json, r'writable')!,
@@ -32,13 +32,15 @@ class MaintenanceStatusResponseDto {
/// ///
String? error; String? error;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// 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 /// 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'])!, action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!, active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'), error: mapValueOfType<String>(json, r'error'),
progress: num.parse('${json[r'progress']}'), progress: mapValueOfType<int>(json, r'progress'),
task: mapValueOfType<String>(json, r'task'), task: mapValueOfType<String>(json, r'task'),
); );
} }
+7 -8
View File
@@ -215,13 +215,14 @@ class MetadataSearchDto {
/// Page number /// Page number
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? page; int? page;
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
@@ -239,7 +240,7 @@ class MetadataSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -251,7 +252,7 @@ class MetadataSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? size; int? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -724,15 +725,13 @@ class MetadataSearchDto {
order: AssetOrder.fromJson(json[r'order']), order: AssetOrder.fromJson(json[r'order']),
originalFileName: mapValueOfType<String>(json, r'originalFileName'), originalFileName: mapValueOfType<String>(json, r'originalFileName'),
originalPath: mapValueOfType<String>(json, r'originalPath'), originalPath: mapValueOfType<String>(json, r'originalPath'),
page: num.parse('${json[r'page']}'), page: mapValueOfType<int>(json, r'page'),
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
previewPath: mapValueOfType<String>(json, r'previewPath'), previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null size: mapValueOfType<int>(json, r'size'),
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+4 -6
View File
@@ -147,7 +147,7 @@ class RandomSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -159,7 +159,7 @@ class RandomSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? size; int? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -549,10 +549,8 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null size: mapValueOfType<int>(json, r'size'),
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+3 -2
View File
@@ -39,13 +39,14 @@ class SessionCreateDto {
/// Session duration in seconds /// Session duration in seconds
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? duration; int? duration;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto && bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
@@ -94,7 +95,7 @@ class SessionCreateDto {
return SessionCreateDto( return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'), deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'), deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: num.parse('${json[r'duration']}'), duration: mapValueOfType<int>(json, r'duration'),
); );
} }
return null; return null;
+7 -8
View File
@@ -154,13 +154,14 @@ class SmartSearchDto {
/// Page number /// Page number
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? page; int? page;
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
@@ -187,7 +188,7 @@ class SmartSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -199,7 +200,7 @@ class SmartSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? size; int? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -583,16 +584,14 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'), make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'), model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'), ocr: mapValueOfType<String>(json, r'ocr'),
page: num.parse('${json[r'page']}'), page: mapValueOfType<int>(json, r'page'),
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
query: mapValueOfType<String>(json, r'query'), query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'), queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null size: mapValueOfType<int>(json, r'size'),
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+2 -4
View File
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -479,9 +479,7 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+321
View File
@@ -0,0 +1,321 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetV2 {
/// Returns a new [SyncAssetV2] instance.
SyncAssetV2({
required this.checksum,
required this.deletedAt,
required this.duration,
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.height,
required this.id,
required this.isEdited,
required this.isFavorite,
required this.libraryId,
required this.livePhotoVideoId,
required this.localDateTime,
required this.originalFileName,
required this.ownerId,
required this.stackId,
required this.thumbhash,
required this.type,
required this.visibility,
required this.width,
});
/// Checksum
String checksum;
/// Deleted at
DateTime? deletedAt;
/// Duration
///
/// Minimum value: 0
/// Maximum value: 2147483647
int? duration;
/// File created at
DateTime? fileCreatedAt;
/// File modified at
DateTime? fileModifiedAt;
/// Asset height
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? height;
/// Asset ID
String id;
/// Is edited
bool isEdited;
/// Is favorite
bool isFavorite;
/// Library ID
String? libraryId;
/// Live photo video ID
String? livePhotoVideoId;
/// Local date time
DateTime? localDateTime;
/// Original file name
String originalFileName;
/// Owner ID
String ownerId;
/// Stack ID
String? stackId;
/// Thumbhash
String? thumbhash;
AssetTypeEnum type;
AssetVisibility visibility;
/// Asset width
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV2 &&
other.checksum == checksum &&
other.deletedAt == deletedAt &&
other.duration == duration &&
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.height == height &&
other.id == id &&
other.isEdited == isEdited &&
other.isFavorite == isFavorite &&
other.libraryId == libraryId &&
other.livePhotoVideoId == livePhotoVideoId &&
other.localDateTime == localDateTime &&
other.originalFileName == originalFileName &&
other.ownerId == ownerId &&
other.stackId == stackId &&
other.thumbhash == thumbhash &&
other.type == type &&
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checksum.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isEdited.hashCode) +
(isFavorite.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(localDateTime == null ? 0 : localDateTime!.hashCode) +
(originalFileName.hashCode) +
(ownerId.hashCode) +
(stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'SyncAssetV2[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checksum'] = this.checksum;
if (this.deletedAt != null) {
json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.deletedAt!.millisecondsSinceEpoch
: this.deletedAt!.toUtc().toIso8601String();
} else {
// json[r'deletedAt'] = null;
}
if (this.duration != null) {
json[r'duration'] = this.duration;
} else {
// json[r'duration'] = null;
}
if (this.fileCreatedAt != null) {
json[r'fileCreatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.fileCreatedAt!.millisecondsSinceEpoch
: this.fileCreatedAt!.toUtc().toIso8601String();
} else {
// json[r'fileCreatedAt'] = null;
}
if (this.fileModifiedAt != null) {
json[r'fileModifiedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.fileModifiedAt!.millisecondsSinceEpoch
: this.fileModifiedAt!.toUtc().toIso8601String();
} else {
// json[r'fileModifiedAt'] = null;
}
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isEdited'] = this.isEdited;
json[r'isFavorite'] = this.isFavorite;
if (this.libraryId != null) {
json[r'libraryId'] = this.libraryId;
} else {
// json[r'libraryId'] = null;
}
if (this.livePhotoVideoId != null) {
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
} else {
// json[r'livePhotoVideoId'] = null;
}
if (this.localDateTime != null) {
json[r'localDateTime'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.localDateTime!.millisecondsSinceEpoch
: this.localDateTime!.toUtc().toIso8601String();
} else {
// json[r'localDateTime'] = null;
}
json[r'originalFileName'] = this.originalFileName;
json[r'ownerId'] = this.ownerId;
if (this.stackId != null) {
json[r'stackId'] = this.stackId;
} else {
// json[r'stackId'] = null;
}
if (this.thumbhash != null) {
json[r'thumbhash'] = this.thumbhash;
} else {
// json[r'thumbhash'] = null;
}
json[r'type'] = this.type;
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
/// Returns a new [SyncAssetV2] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetV2? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetV2");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetV2(
checksum: mapValueOfType<String>(json, r'checksum')!,
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
duration: mapValueOfType<int>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
libraryId: mapValueOfType<String>(json, r'libraryId'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
localDateTime: mapDateTime(json, r'localDateTime', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
stackId: mapValueOfType<String>(json, r'stackId'),
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
);
}
return null;
}
static List<SyncAssetV2> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetV2>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetV2.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetV2> mapFromJson(dynamic json) {
final map = <String, SyncAssetV2>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetV2.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetV2-objects as value to a dart map
static Map<String, List<SyncAssetV2>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetV2>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetV2.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checksum',
'deletedAt',
'duration',
'fileCreatedAt',
'fileModifiedAt',
'height',
'id',
'isEdited',
'isFavorite',
'libraryId',
'livePhotoVideoId',
'localDateTime',
'originalFileName',
'ownerId',
'stackId',
'thumbhash',
'type',
'visibility',
'width',
};
}
+3 -4
View File
@@ -57,7 +57,8 @@ class SystemConfigOAuthDto {
/// Default storage quota /// Default storage quota
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? defaultStorageQuota; /// Maximum value: 9007199254740991
int? defaultStorageQuota;
/// Enabled /// Enabled
bool enabled; bool enabled;
@@ -200,9 +201,7 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!, buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!, clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!, clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: json[r'defaultStorageQuota'] == null defaultStorageQuota: mapValueOfType<int>(json, r'defaultStorageQuota'),
? null
: num.parse('${json[r'defaultStorageQuota']}'),
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!, endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!, issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 65535 /// Maximum value: 65535
num port; int port;
/// Whether to use secure connection (TLS/SSL) /// Whether to use secure connection (TLS/SSL)
bool secure; bool secure;
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
host: mapValueOfType<String>(json, r'host')!, host: mapValueOfType<String>(json, r'host')!,
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!, ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!, password: mapValueOfType<String>(json, r'password')!,
port: num.parse('${json[r'port']}'), port: mapValueOfType<int>(json, r'port')!,
secure: mapValueOfType<bool>(json, r'secure')!, secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!, username: mapValueOfType<String>(json, r'username')!,
); );
@@ -39,8 +39,8 @@ class TimeBucketAssetResponseDto {
/// Array of country names extracted from EXIF GPS data /// Array of country names extracted from EXIF GPS data
List<String?> country; List<String?> country;
/// Array of video/gif durations in hh:mm:ss.SSS format (null for static images) /// Array of video/gif durations in milliseconds (null for static images)
List<String?> duration; List<int?> duration;
/// Array of file creation timestamps in UTC /// Array of file creation timestamps in UTC
List<String> fileCreatedAt; List<String> fileCreatedAt;
@@ -172,7 +172,7 @@ class TimeBucketAssetResponseDto {
? (json[r'country'] as Iterable).cast<String>().toList(growable: false) ? (json[r'country'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
duration: json[r'duration'] is Iterable duration: json[r'duration'] is Iterable
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false) ? (json[r'duration'] as Iterable).cast<int>().toList(growable: false)
: const [], : const [],
fileCreatedAt: json[r'fileCreatedAt'] is Iterable fileCreatedAt: json[r'fileCreatedAt'] is Iterable
? (json[r'fileCreatedAt'] as Iterable).cast<String>().toList(growable: false) ? (json[r'fileCreatedAt'] as Iterable).cast<String>().toList(growable: false)
+5 -2
View File
@@ -26,7 +26,10 @@ class WorkflowActionResponseDto {
String id; String id;
/// Action order /// Action order
num order; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin action ID /// Plugin action ID
String pluginActionId; String pluginActionId;
@@ -79,7 +82,7 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto( return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'), actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'), order: mapValueOfType<int>(json, r'order')!,
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!, pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!, workflowId: mapValueOfType<String>(json, r'workflowId')!,
); );
+5 -2
View File
@@ -26,7 +26,10 @@ class WorkflowFilterResponseDto {
String id; String id;
/// Filter order /// Filter order
num order; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin filter ID /// Plugin filter ID
String pluginFilterId; String pluginFilterId;
@@ -79,7 +82,7 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto( return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'), filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'), order: mapValueOfType<int>(json, r'order')!,
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!, pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!, workflowId: mapValueOfType<String>(json, r'workflowId')!,
); );
@@ -419,8 +419,8 @@ void main() {
'album-b': [mergedAsset], 'album-b': [mergedAsset],
}; };
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async { when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>; final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'})); expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
return assetsByAlbum; return assetsByAlbum;
}); });
@@ -482,12 +482,18 @@ void main() {
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())); 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]; final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events); await simulateEvents(events);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())); verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1); verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
}); });
+219 -41
View File
@@ -7964,8 +7964,9 @@
"description": "Page number for pagination", "description": "Page number for pagination",
"schema": { "schema": {
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991,
"default": 1, "default": 1,
"type": "number" "type": "integer"
} }
}, },
{ {
@@ -7977,7 +7978,7 @@
"minimum": 1, "minimum": 1,
"maximum": 1000, "maximum": 1000,
"default": 500, "default": 500,
"type": "number" "type": "integer"
} }
}, },
{ {
@@ -9372,7 +9373,7 @@
], ],
"x-immich-state": "Stable", "x-immich-state": "Stable",
"schema": { "schema": {
"type": "number", "type": "integer",
"minimum": -1, "minimum": -1,
"maximum": 5, "maximum": 5,
"nullable": true "nullable": true
@@ -9386,7 +9387,7 @@
"schema": { "schema": {
"minimum": 1, "minimum": 1,
"maximum": 1000, "maximum": 1000,
"type": "number" "type": "integer"
} }
}, },
{ {
@@ -15636,7 +15637,9 @@
}, },
"dateTimeRelative": { "dateTimeRelative": {
"description": "Relative time offset in seconds", "description": "Relative time offset in seconds",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"description": { "description": {
"description": "Asset description", "description": "Asset description",
@@ -16258,8 +16261,10 @@
"type": "string" "type": "string"
}, },
"duration": { "duration": {
"description": "Duration (for videos)", "description": "Duration in milliseconds (for videos)",
"type": "string" "maximum": 2147483647,
"minimum": 0,
"type": "integer"
}, },
"fileCreatedAt": { "fileCreatedAt": {
"description": "File creation date", "description": "File creation date",
@@ -16627,9 +16632,11 @@
"type": "string" "type": "string"
}, },
"duration": { "duration": {
"description": "Video/gif duration in hh:mm:ss.SSS format (null for static images)", "description": "Video/gif duration in milliseconds (null for static images)",
"maximum": 2147483647,
"minimum": 0,
"nullable": true, "nullable": true,
"type": "string" "type": "integer"
}, },
"exifInfo": { "exifInfo": {
"$ref": "#/components/schemas/ExifResponseDto" "$ref": "#/components/schemas/ExifResponseDto"
@@ -16650,9 +16657,10 @@
}, },
"height": { "height": {
"description": "Asset height", "description": "Asset height",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"id": { "id": {
"description": "Asset ID", "description": "Asset ID",
@@ -16795,9 +16803,10 @@
}, },
"width": { "width": {
"description": "Asset width", "description": "Asset width",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
} }
}, },
"required": [ "required": [
@@ -17214,23 +17223,27 @@
"properties": { "properties": {
"height": { "height": {
"description": "Height of the crop", "description": "Height of the crop",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"width": { "width": {
"description": "Width of the crop", "description": "Width of the crop",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"x": { "x": {
"description": "Top-Left X coordinate of crop", "description": "Top-Left X coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
}, },
"y": { "y": {
"description": "Top-Left Y coordinate of crop", "description": "Top-Left Y coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
} }
}, },
"required": [ "required": [
@@ -17254,8 +17267,9 @@
}, },
"keepLastAmount": { "keepLastAmount": {
"description": "Keep last amount", "description": "Keep last amount",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
} }
}, },
"required": [ "required": [
@@ -17288,7 +17302,9 @@
}, },
"filesize": { "filesize": {
"description": "Backup file size", "description": "Backup file size",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"timezone": { "timezone": {
"description": "Backup timezone", "description": "Backup timezone",
@@ -17627,16 +17643,18 @@
"exifImageHeight": { "exifImageHeight": {
"default": null, "default": null,
"description": "Image height in pixels", "description": "Image height in pixels",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"exifImageWidth": { "exifImageWidth": {
"default": null, "default": null,
"description": "Image width in pixels", "description": "Image width in pixels",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"exposureTime": { "exposureTime": {
"default": null, "default": null,
@@ -17667,8 +17685,10 @@
"iso": { "iso": {
"default": null, "default": null,
"description": "ISO sensitivity", "description": "ISO sensitivity",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"latitude": { "latitude": {
"default": null, "default": null,
@@ -17722,8 +17742,10 @@
"rating": { "rating": {
"default": null, "default": null,
"description": "Rating", "description": "Rating",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"default": null, "default": null,
@@ -18150,10 +18172,14 @@
"type": "boolean" "type": "boolean"
}, },
"interval": { "interval": {
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"timeout": { "timeout": {
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
} }
}, },
"required": [ "required": [
@@ -18203,7 +18229,9 @@
"properties": { "properties": {
"files": { "files": {
"description": "Number of files in the folder", "description": "Number of files in the folder",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"folder": { "folder": {
"$ref": "#/components/schemas/StorageFolder" "$ref": "#/components/schemas/StorageFolder"
@@ -18246,7 +18274,9 @@
"type": "string" "type": "string"
}, },
"progress": { "progress": {
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"task": { "task": {
"type": "string" "type": "string"
@@ -18723,8 +18753,9 @@
}, },
"page": { "page": {
"description": "Page number", "description": "Page number",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"personIds": { "personIds": {
"description": "Filter by person IDs", "description": "Filter by person IDs",
@@ -18744,7 +18775,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -18766,7 +18797,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -20597,7 +20628,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -20619,7 +20650,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -21437,8 +21468,9 @@
}, },
"duration": { "duration": {
"description": "Session duration in seconds", "description": "Session duration in seconds",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
} }
}, },
"type": "object" "type": "object"
@@ -21952,8 +21984,9 @@
}, },
"page": { "page": {
"description": "Page number", "description": "Page number",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"personIds": { "personIds": {
"description": "Filter by person IDs", "description": "Filter by person IDs",
@@ -21979,7 +22012,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -22001,7 +22034,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -22239,7 +22272,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -23258,6 +23291,135 @@
], ],
"type": "object" "type": "object"
}, },
"SyncAssetV2": {
"properties": {
"checksum": {
"description": "Checksum",
"type": "string"
},
"deletedAt": {
"description": "Deleted at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"duration": {
"description": "Duration",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"type": "integer"
},
"fileCreatedAt": {
"description": "File created at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"fileModifiedAt": {
"description": "File modified at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"height": {
"description": "Asset height",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
},
"id": {
"description": "Asset ID",
"type": "string"
},
"isEdited": {
"description": "Is edited",
"type": "boolean"
},
"isFavorite": {
"description": "Is favorite",
"type": "boolean"
},
"libraryId": {
"description": "Library ID",
"nullable": true,
"type": "string"
},
"livePhotoVideoId": {
"description": "Live photo video ID",
"nullable": true,
"type": "string"
},
"localDateTime": {
"description": "Local date time",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"originalFileName": {
"description": "Original file name",
"type": "string"
},
"ownerId": {
"description": "Owner ID",
"type": "string"
},
"stackId": {
"description": "Stack ID",
"nullable": true,
"type": "string"
},
"thumbhash": {
"description": "Thumbhash",
"nullable": true,
"type": "string"
},
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"visibility": {
"$ref": "#/components/schemas/AssetVisibility"
},
"width": {
"description": "Asset width",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
}
},
"required": [
"checksum",
"deletedAt",
"duration",
"fileCreatedAt",
"fileModifiedAt",
"height",
"id",
"isEdited",
"isFavorite",
"libraryId",
"livePhotoVideoId",
"localDateTime",
"originalFileName",
"ownerId",
"stackId",
"thumbhash",
"type",
"visibility",
"width"
],
"type": "object"
},
"SyncAuthUserV1": { "SyncAuthUserV1": {
"properties": { "properties": {
"avatarColor": { "avatarColor": {
@@ -23358,6 +23520,7 @@
"UserV1", "UserV1",
"UserDeleteV1", "UserDeleteV1",
"AssetV1", "AssetV1",
"AssetV2",
"AssetDeleteV1", "AssetDeleteV1",
"AssetExifV1", "AssetExifV1",
"AssetEditV1", "AssetEditV1",
@@ -23369,7 +23532,9 @@
"PartnerV1", "PartnerV1",
"PartnerDeleteV1", "PartnerDeleteV1",
"PartnerAssetV1", "PartnerAssetV1",
"PartnerAssetV2",
"PartnerAssetBackfillV1", "PartnerAssetBackfillV1",
"PartnerAssetBackfillV2",
"PartnerAssetDeleteV1", "PartnerAssetDeleteV1",
"PartnerAssetExifV1", "PartnerAssetExifV1",
"PartnerAssetExifBackfillV1", "PartnerAssetExifBackfillV1",
@@ -23383,8 +23548,11 @@
"AlbumUserBackfillV1", "AlbumUserBackfillV1",
"AlbumUserDeleteV1", "AlbumUserDeleteV1",
"AlbumAssetCreateV1", "AlbumAssetCreateV1",
"AlbumAssetCreateV2",
"AlbumAssetUpdateV1", "AlbumAssetUpdateV1",
"AlbumAssetUpdateV2",
"AlbumAssetBackfillV1", "AlbumAssetBackfillV1",
"AlbumAssetBackfillV2",
"AlbumAssetExifCreateV1", "AlbumAssetExifCreateV1",
"AlbumAssetExifUpdateV1", "AlbumAssetExifUpdateV1",
"AlbumAssetExifBackfillV1", "AlbumAssetExifBackfillV1",
@@ -23676,8 +23844,10 @@
"AlbumUsersV1", "AlbumUsersV1",
"AlbumToAssetsV1", "AlbumToAssetsV1",
"AlbumAssetsV1", "AlbumAssetsV1",
"AlbumAssetsV2",
"AlbumAssetExifsV1", "AlbumAssetExifsV1",
"AssetsV1", "AssetsV1",
"AssetsV2",
"AssetExifsV1", "AssetExifsV1",
"AssetEditsV1", "AssetEditsV1",
"AssetMetadataV1", "AssetMetadataV1",
@@ -23687,6 +23857,7 @@
"MemoryToAssetsV1", "MemoryToAssetsV1",
"PartnersV1", "PartnersV1",
"PartnerAssetsV1", "PartnerAssetsV1",
"PartnerAssetsV2",
"PartnerAssetExifsV1", "PartnerAssetExifsV1",
"PartnerStacksV1", "PartnerStacksV1",
"StacksV1", "StacksV1",
@@ -24486,9 +24657,10 @@
}, },
"defaultStorageQuota": { "defaultStorageQuota": {
"description": "Default storage quota", "description": "Default storage quota",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"enabled": { "enabled": {
"description": "Enabled", "description": "Enabled",
@@ -24663,7 +24835,7 @@
"description": "SMTP server port", "description": "SMTP server port",
"maximum": 65535, "maximum": 65535,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
}, },
"secure": { "secure": {
"description": "Whether to use secure connection (TLS/SSL)", "description": "Whether to use secure connection (TLS/SSL)",
@@ -25079,10 +25251,12 @@
"type": "array" "type": "array"
}, },
"duration": { "duration": {
"description": "Array of video/gif durations in hh:mm:ss.SSS format (null for static images)", "description": "Array of video/gif durations in milliseconds (null for static images)",
"items": { "items": {
"maximum": 2147483647,
"minimum": 0,
"nullable": true, "nullable": true,
"type": "string" "type": "integer"
}, },
"type": "array" "type": "array"
}, },
@@ -26081,7 +26255,9 @@
}, },
"order": { "order": {
"description": "Action order", "description": "Action order",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"pluginActionId": { "pluginActionId": {
"description": "Plugin action ID", "description": "Plugin action ID",
@@ -26180,7 +26356,9 @@
}, },
"order": { "order": {
"description": "Filter order", "description": "Filter order",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"pluginFilterId": { "pluginFilterId": {
"description": "Plugin filter ID", "description": "Plugin filter ID",
+53 -6
View File
@@ -614,8 +614,8 @@ export type AssetMetadataUpsertItemDto = {
export type AssetMediaCreateDto = { export type AssetMediaCreateDto = {
/** Asset file data */ /** Asset file data */
assetData: Blob; assetData: Blob;
/** Duration (for videos) */ /** Duration in milliseconds (for videos) */
duration?: string; duration?: number;
/** File creation date */ /** File creation date */
fileCreatedAt: string; fileCreatedAt: string;
/** File modification date */ /** File modification date */
@@ -854,8 +854,8 @@ export type AssetResponseDto = {
createdAt: string; createdAt: string;
/** Duplicate group ID */ /** Duplicate group ID */
duplicateId?: string | null; duplicateId?: string | null;
/** Video/gif duration in hh:mm:ss.SSS format (null for static images) */ /** Video/gif duration in milliseconds (null for static images) */
duration: string | null; duration: number | null;
exifInfo?: ExifResponseDto; exifInfo?: ExifResponseDto;
/** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */ /** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */
fileCreatedAt: string; fileCreatedAt: string;
@@ -2673,8 +2673,8 @@ export type TimeBucketAssetResponseDto = {
city: (string | null)[]; city: (string | null)[];
/** Array of country names extracted from EXIF GPS data */ /** Array of country names extracted from EXIF GPS data */
country: (string | null)[]; country: (string | null)[];
/** Array of video/gif durations in hh:mm:ss.SSS format (null for static images) */ /** Array of video/gif durations in milliseconds (null for static images) */
duration: (string | null)[]; duration: (number | null)[];
/** Array of file creation timestamps in UTC */ /** Array of file creation timestamps in UTC */
fileCreatedAt: string[]; fileCreatedAt: string[];
/** Array of asset IDs in the time bucket */ /** Array of asset IDs in the time bucket */
@@ -3113,6 +3113,44 @@ export type SyncAssetV1 = {
/** Asset width */ /** Asset width */
width: number | null; width: number | null;
}; };
export type SyncAssetV2 = {
/** Checksum */
checksum: string;
/** Deleted at */
deletedAt: string | null;
/** Duration */
duration: number | null;
/** File created at */
fileCreatedAt: string | null;
/** File modified at */
fileModifiedAt: string | null;
/** Asset height */
height: number | null;
/** Asset ID */
id: string;
/** Is edited */
isEdited: boolean;
/** Is favorite */
isFavorite: boolean;
/** Library ID */
libraryId: string | null;
/** Live photo video ID */
livePhotoVideoId: string | null;
/** Local date time */
localDateTime: string | null;
/** Original file name */
originalFileName: string;
/** Owner ID */
ownerId: string;
/** Stack ID */
stackId: string | null;
/** Thumbhash */
thumbhash: string | null;
"type": AssetTypeEnum;
visibility: AssetVisibility;
/** Asset width */
width: number | null;
};
export type SyncAuthUserV1 = { export type SyncAuthUserV1 = {
avatarColor?: (UserAvatarColor) | null; avatarColor?: (UserAvatarColor) | null;
/** User deleted at */ /** User deleted at */
@@ -7147,6 +7185,7 @@ export enum SyncEntityType {
UserV1 = "UserV1", UserV1 = "UserV1",
UserDeleteV1 = "UserDeleteV1", UserDeleteV1 = "UserDeleteV1",
AssetV1 = "AssetV1", AssetV1 = "AssetV1",
AssetV2 = "AssetV2",
AssetDeleteV1 = "AssetDeleteV1", AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1", AssetExifV1 = "AssetExifV1",
AssetEditV1 = "AssetEditV1", AssetEditV1 = "AssetEditV1",
@@ -7158,7 +7197,9 @@ export enum SyncEntityType {
PartnerV1 = "PartnerV1", PartnerV1 = "PartnerV1",
PartnerDeleteV1 = "PartnerDeleteV1", PartnerDeleteV1 = "PartnerDeleteV1",
PartnerAssetV1 = "PartnerAssetV1", PartnerAssetV1 = "PartnerAssetV1",
PartnerAssetV2 = "PartnerAssetV2",
PartnerAssetBackfillV1 = "PartnerAssetBackfillV1", PartnerAssetBackfillV1 = "PartnerAssetBackfillV1",
PartnerAssetBackfillV2 = "PartnerAssetBackfillV2",
PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", PartnerAssetDeleteV1 = "PartnerAssetDeleteV1",
PartnerAssetExifV1 = "PartnerAssetExifV1", PartnerAssetExifV1 = "PartnerAssetExifV1",
PartnerAssetExifBackfillV1 = "PartnerAssetExifBackfillV1", PartnerAssetExifBackfillV1 = "PartnerAssetExifBackfillV1",
@@ -7172,8 +7213,11 @@ export enum SyncEntityType {
AlbumUserBackfillV1 = "AlbumUserBackfillV1", AlbumUserBackfillV1 = "AlbumUserBackfillV1",
AlbumUserDeleteV1 = "AlbumUserDeleteV1", AlbumUserDeleteV1 = "AlbumUserDeleteV1",
AlbumAssetCreateV1 = "AlbumAssetCreateV1", AlbumAssetCreateV1 = "AlbumAssetCreateV1",
AlbumAssetCreateV2 = "AlbumAssetCreateV2",
AlbumAssetUpdateV1 = "AlbumAssetUpdateV1", AlbumAssetUpdateV1 = "AlbumAssetUpdateV1",
AlbumAssetUpdateV2 = "AlbumAssetUpdateV2",
AlbumAssetBackfillV1 = "AlbumAssetBackfillV1", AlbumAssetBackfillV1 = "AlbumAssetBackfillV1",
AlbumAssetBackfillV2 = "AlbumAssetBackfillV2",
AlbumAssetExifCreateV1 = "AlbumAssetExifCreateV1", AlbumAssetExifCreateV1 = "AlbumAssetExifCreateV1",
AlbumAssetExifUpdateV1 = "AlbumAssetExifUpdateV1", AlbumAssetExifUpdateV1 = "AlbumAssetExifUpdateV1",
AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1", AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1",
@@ -7203,8 +7247,10 @@ export enum SyncRequestType {
AlbumUsersV1 = "AlbumUsersV1", AlbumUsersV1 = "AlbumUsersV1",
AlbumToAssetsV1 = "AlbumToAssetsV1", AlbumToAssetsV1 = "AlbumToAssetsV1",
AlbumAssetsV1 = "AlbumAssetsV1", AlbumAssetsV1 = "AlbumAssetsV1",
AlbumAssetsV2 = "AlbumAssetsV2",
AlbumAssetExifsV1 = "AlbumAssetExifsV1", AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1", AssetsV1 = "AssetsV1",
AssetsV2 = "AssetsV2",
AssetExifsV1 = "AssetExifsV1", AssetExifsV1 = "AssetExifsV1",
AssetEditsV1 = "AssetEditsV1", AssetEditsV1 = "AssetEditsV1",
AssetMetadataV1 = "AssetMetadataV1", AssetMetadataV1 = "AssetMetadataV1",
@@ -7214,6 +7260,7 @@ export enum SyncRequestType {
MemoryToAssetsV1 = "MemoryToAssetsV1", MemoryToAssetsV1 = "MemoryToAssetsV1",
PartnersV1 = "PartnersV1", PartnersV1 = "PartnersV1",
PartnerAssetsV1 = "PartnerAssetsV1", PartnerAssetsV1 = "PartnerAssetsV1",
PartnerAssetsV2 = "PartnerAssetsV2",
PartnerAssetExifsV1 = "PartnerAssetExifsV1", PartnerAssetExifsV1 = "PartnerAssetExifsV1",
PartnerStacksV1 = "PartnerStacksV1", PartnerStacksV1 = "PartnerStacksV1",
StacksV1 = "StacksV1", StacksV1 = "StacksV1",
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.7.5", "version": "2.7.5",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
} }
+968 -1000
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -46,15 +46,15 @@
"@nestjs/platform-express": "^11.0.4", "@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4", "@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0", "@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "11.2.6", "@nestjs/swagger": "^11.4.2",
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.215.0", "@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/instrumentation-http": "^0.215.0", "@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.62.0", "@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0", "@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.66.0", "@opentelemetry/instrumentation-pg": "^0.67.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.215.0", "@opentelemetry/sdk-node": "^0.215.0",
@@ -114,7 +114,7 @@
"thumbhash": "^0.1.1", "thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0", "transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0", "ua-parser-js": "^2.0.0",
"uuid": "^11.1.0", "uuid": "^14.0.0",
"validator": "^13.12.0", "validator": "^13.12.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
+1 -7
View File
@@ -14,7 +14,6 @@ export const ErrorMessages = {
export const POSTGRES_VERSION_RANGE = '>=14.0.0'; export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2'; export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
export const VECTOR_VERSION_RANGE = '>=0.5 <1'; export const VECTOR_VERSION_RANGE = '>=0.5 <1';
export const JOBS_ASSET_PAGINATION_SIZE = 1000; export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@@ -24,15 +23,10 @@ export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
cube: 'cube', cube: 'cube',
earthdistance: 'earthdistance', earthdistance: 'earthdistance',
vector: 'pgvector', vector: 'pgvector',
vectors: 'pgvecto.rs',
vchord: 'VectorChord', vchord: 'VectorChord',
} as const; } as const;
export const VECTOR_EXTENSIONS = [ export const VECTOR_EXTENSIONS = [DatabaseExtension.VectorChord, DatabaseExtension.Vector] as const;
DatabaseExtension.VectorChord,
DatabaseExtension.Vectors,
DatabaseExtension.Vector,
] as const;
export const VECTOR_INDEX_TABLES = { export const VECTOR_INDEX_TABLES = {
[VectorIndex.Clip]: 'smart_search', [VectorIndex.Clip]: 'smart_search',
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
}); });
it('should reject an invalid size', async () => { 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(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1'])); expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
}); });
+1 -1
View File
@@ -38,7 +38,7 @@ export enum UploadFieldName {
const AssetMediaBaseSchema = z.object({ const AssetMediaBaseSchema = z.object({
fileCreatedAt: isoDatetimeToDate.describe('File creation date'), fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
fileModifiedAt: isoDatetimeToDate.describe('File modification date'), fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
duration: z.string().optional().describe('Duration (for videos)'), duration: z.int32().min(0).optional().describe('Duration in milliseconds (for videos)'),
filename: z.string().optional().describe('Filename'), filename: z.string().optional().describe('Filename'),
/** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */ /** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */
[UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }), [UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }),
+4 -4
View File
@@ -47,11 +47,11 @@ const SanitizedAssetResponseSchema = z
.describe( .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.', '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)'), duration: z.int32().min(0).nullable().describe('Video/gif duration in milliseconds (null for static images)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'), livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'), hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.number().min(0).nullable().describe('Asset width'), width: z.int().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'), height: z.int().min(0).nullable().describe('Asset height'),
}) })
.meta({ id: 'SanitizedAssetResponseDto' }); .meta({ id: 'SanitizedAssetResponseDto' });
@@ -136,7 +136,7 @@ export type MapAsset = {
checksum: Buffer<ArrayBufferLike>; checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm; checksumAlgorithm: ChecksumAlgorithm;
duplicateId: string | null; duplicateId: string | null;
duration: string | null; duration: number | null;
edits?: ShallowDehydrateObject<AssetEditActionItem>[]; edits?: ShallowDehydrateObject<AssetEditActionItem>[];
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null; exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
faces?: ShallowDehydrateObject<AssetFace>[]; faces?: ShallowDehydrateObject<AssetFace>[];
+1 -1
View File
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({ const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'), ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'), 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)'), timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z const DatabaseBackupSchema = z
.object({ .object({
filename: z.string().describe('Backup filename'), 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'), timezone: z.string().describe('Backup timezone'),
}) })
.meta({ id: 'DatabaseBackupDto' }); .meta({ id: 'DatabaseBackupDto' });
+4 -4
View File
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z const CropParametersSchema = z
.object({ .object({
x: z.number().min(0).describe('Top-Left X coordinate of crop'), x: z.int().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'), y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'), width: z.int().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'), height: z.int().min(1).describe('Height of the crop'),
}) })
.meta({ id: 'CropParameters' }); .meta({ id: 'CropParameters' });
+1 -1
View File
@@ -77,7 +77,7 @@ export const EnvSchema = z
DB_SSL_MODE: DatabaseSslModeSchema.optional(), DB_SSL_MODE: DatabaseSslModeSchema.optional(),
DB_URL: z.string().optional(), DB_URL: z.string().optional(),
DB_USERNAME: z.string().optional(), DB_USERNAME: z.string().optional(),
DB_VECTOR_EXTENSION: z.enum(['pgvector', 'pgvecto.rs', 'vectorchord']).optional(), DB_VECTOR_EXTENSION: z.enum(['pgvector', 'vectorchord']).optional(),
NO_COLOR: z.string().optional(), NO_COLOR: z.string().optional(),
REDIS_HOSTNAME: z.string().optional(), REDIS_HOSTNAME: z.string().optional(),
REDIS_PORT: z.coerce.number().int().optional(), REDIS_PORT: z.coerce.number().int().optional(),
+4 -4
View File
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
.object({ .object({
make: z.string().nullish().default(null).describe('Camera make'), make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'), model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'), exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height 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'), fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'), orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -20,7 +20,7 @@ export const ExifResponseSchema = z
lensModel: z.string().nullish().default(null).describe('Lens model'), lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'), fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'), 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'), exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'), latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'), longitude: z.number().nullish().default(null).describe('GPS longitude'),
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
country: z.string().nullish().default(null).describe('Country name'), country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'), description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'), 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') .describe('EXIF response')
.meta({ id: 'ExifResponseDto' }); .meta({ id: 'ExifResponseDto' });
+2 -2
View File
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({ .object({
active: z.boolean(), active: z.boolean(),
action: MaintenanceActionSchema, action: MaintenanceActionSchema,
progress: z.number().optional(), progress: z.int().optional(),
task: z.string().optional(), task: z.string().optional(),
error: z.string().optional(), error: z.string().optional(),
}) })
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema, folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'), readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'), 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' }); .meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
+2 -2
View File
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'), withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'), closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset 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'), page: z.coerce.number().int().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'), size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
}) })
.meta({ id: 'PersonSearchDto' }); .meta({ id: 'PersonSearchDto' });
+5 -5
View File
@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'), tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'), albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z rating: z
.number() .int()
.min(-1) .min(-1)
.max(5) .max(5)
.nullish() .nullish()
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({ const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'), withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'), 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({ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({ const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'), 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' }); }).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({ const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'), thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'), encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'), 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' }); }).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({ const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'), query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'), queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'), 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' }); }).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z const SearchPlacesSchema = z
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z const SessionCreateSchema = z
.object({ .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'), deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'), deviceOS: z.string().optional().describe('Device OS'),
}) })
+32 -12
View File
@@ -90,6 +90,30 @@ const SyncAssetV1Schema = z
}) })
.meta({ id: 'SyncAssetV1' }); .meta({ id: 'SyncAssetV1' });
const SyncAssetV2Schema = z
.object({
id: z.string().describe('Asset ID'),
ownerId: z.string().describe('Owner ID'),
originalFileName: z.string().describe('Original file name'),
thumbhash: z.string().nullable().describe('Thumbhash'),
checksum: z.string().describe('Checksum'),
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
duration: z.int32().min(0).nullable().describe('Duration'),
type: AssetTypeSchema,
deletedAt: isoDatetimeToDate.nullable().describe('Deleted at'),
isFavorite: z.boolean().describe('Is favorite'),
visibility: AssetVisibilitySchema,
livePhotoVideoId: z.string().nullable().describe('Live photo video ID'),
stackId: z.string().nullable().describe('Stack ID'),
libraryId: z.string().nullable().describe('Library ID'),
width: z.int().nullable().describe('Asset width'),
height: z.int().nullable().describe('Asset height'),
isEdited: z.boolean().describe('Is edited'),
})
.meta({ id: 'SyncAssetV2' });
@ExtraModel() @ExtraModel()
class SyncUserV1 extends createZodDto(SyncUserV1Schema) {} class SyncUserV1 extends createZodDto(SyncUserV1Schema) {}
@ExtraModel() @ExtraModel()
@@ -102,6 +126,8 @@ class SyncPartnerV1 extends createZodDto(SyncPartnerV1Schema) {}
class SyncPartnerDeleteV1 extends createZodDto(SyncPartnerDeleteV1Schema) {} class SyncPartnerDeleteV1 extends createZodDto(SyncPartnerDeleteV1Schema) {}
@ExtraModel() @ExtraModel()
export class SyncAssetV1 extends createZodDto(SyncAssetV1Schema) {} export class SyncAssetV1 extends createZodDto(SyncAssetV1Schema) {}
@ExtraModel()
export class SyncAssetV2 extends createZodDto(SyncAssetV2Schema) {}
const SyncAssetDeleteV1Schema = z const SyncAssetDeleteV1Schema = z
.object({ assetId: z.string().describe('Asset ID') }) .object({ assetId: z.string().describe('Asset ID') })
@@ -429,12 +455,6 @@ class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {}
class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {} class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {}
@ExtraModel() @ExtraModel()
class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {} class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {}
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
return faceV1;
}
@ExtraModel() @ExtraModel()
class SyncAssetFaceDeleteV1 extends createZodDto(SyncAssetFaceDeleteV1Schema) {} class SyncAssetFaceDeleteV1 extends createZodDto(SyncAssetFaceDeleteV1Schema) {}
@ExtraModel() @ExtraModel()
@@ -454,7 +474,7 @@ export type SyncItem = {
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
[SyncEntityType.PartnerV1]: SyncPartnerV1; [SyncEntityType.PartnerV1]: SyncPartnerV1;
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
[SyncEntityType.AssetV1]: SyncAssetV1; [SyncEntityType.AssetV2]: SyncAssetV2;
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1; [SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1; [SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
@@ -463,8 +483,8 @@ export type SyncItem = {
[SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1; [SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1;
[SyncEntityType.AssetEditV1]: SyncAssetEditV1; [SyncEntityType.AssetEditV1]: SyncAssetEditV1;
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1; [SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetV2]: SyncAssetV2;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; [SyncEntityType.PartnerAssetBackfillV2]: SyncAssetV2;
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1; [SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1;
@@ -474,9 +494,9 @@ export type SyncItem = {
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1; [SyncEntityType.AlbumAssetCreateV2]: SyncAssetV2;
[SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1; [SyncEntityType.AlbumAssetUpdateV2]: SyncAssetV2;
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1; [SyncEntityType.AlbumAssetBackfillV2]: SyncAssetV2;
[SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1;
+5 -5
View File
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({ .object({
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema, cronExpression: cronExpressionSchema,
keepLastAmount: z.number().min(1).describe('Keep last amount'), keepLastAmount: z.int().min(1).describe('Keep last amount'),
}) })
.meta({ id: 'DatabaseBackupConfig' }); .meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z const MachineLearningAvailabilityChecksSchema = z
.object({ .object({
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
timeout: z.number(), timeout: z.int(),
interval: z.number(), interval: z.int(),
}) })
.meta({ id: 'MachineLearningAvailabilityChecksDto' }); .meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema, tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'), timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'), 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'), enabled: configBool.describe('Enabled'),
issuerUrl: z issuerUrl: z
.string() .string()
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({ .object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'), ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'), 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)'), secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'), username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'), password: z.string().describe('SMTP password'),
+2 -2
View File
@@ -89,8 +89,8 @@ const TimeBucketAssetResponseSchema = z
"Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.",
), ),
duration: z duration: z
.array(z.string().nullable()) .array(z.int32().min(0).nullable())
.describe('Array of video/gif durations in hh:mm:ss.SSS format (null for static images)'), .describe('Array of video/gif durations in milliseconds (null for static images)'),
stack: z stack: z
.array(stackTupleSchema) .array(stackTupleSchema)
.optional() .optional()
+2 -2
View File
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'), workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'), pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(), filterConfig: FilterConfigSchema.nullable(),
order: z.number().describe('Filter order'), order: z.int().describe('Filter order'),
}) })
.meta({ id: 'WorkflowFilterResponseDto' }); .meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'), workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'), pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(), actionConfig: ActionConfigSchema.nullable(),
order: z.number().describe('Action order'), order: z.int().describe('Action order'),
}) })
.meta({ id: 'WorkflowActionResponseDto' }); .meta({ id: 'WorkflowActionResponseDto' });
+26 -2
View File
@@ -22,7 +22,7 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key', SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug', SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum', Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid', CorrelationId = 'X-Correlation-ID',
} }
export enum ImmichQuery { export enum ImmichQuery {
@@ -445,6 +445,12 @@ export enum VideoCodec {
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: '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 { export enum AudioCodec {
Mp3 = 'mp3', Mp3 = 'mp3',
Aac = 'aac', Aac = 'aac',
@@ -601,7 +607,6 @@ export enum DatabaseExtension {
Cube = 'cube', Cube = 'cube',
EarthDistance = 'earthdistance', EarthDistance = 'earthdistance',
Vector = 'vector', Vector = 'vector',
Vectors = 'vectors',
VectorChord = 'vchord', VectorChord = 'vchord',
} }
@@ -801,9 +806,13 @@ export enum SyncRequestType {
AlbumsV2 = 'AlbumsV2', AlbumsV2 = 'AlbumsV2',
AlbumUsersV1 = 'AlbumUsersV1', AlbumUsersV1 = 'AlbumUsersV1',
AlbumToAssetsV1 = 'AlbumToAssetsV1', AlbumToAssetsV1 = 'AlbumToAssetsV1',
/** @deprecated */
AlbumAssetsV1 = 'AlbumAssetsV1', AlbumAssetsV1 = 'AlbumAssetsV1',
AlbumAssetsV2 = 'AlbumAssetsV2',
AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
/** @deprecated */
AssetsV1 = 'AssetsV1', AssetsV1 = 'AssetsV1',
AssetsV2 = 'AssetsV2',
AssetExifsV1 = 'AssetExifsV1', AssetExifsV1 = 'AssetExifsV1',
AssetEditsV1 = 'AssetEditsV1', AssetEditsV1 = 'AssetEditsV1',
AssetMetadataV1 = 'AssetMetadataV1', AssetMetadataV1 = 'AssetMetadataV1',
@@ -812,12 +821,15 @@ export enum SyncRequestType {
MemoriesV1 = 'MemoriesV1', MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1', MemoryToAssetsV1 = 'MemoryToAssetsV1',
PartnersV1 = 'PartnersV1', PartnersV1 = 'PartnersV1',
/** @deprecated */
PartnerAssetsV1 = 'PartnerAssetsV1', PartnerAssetsV1 = 'PartnerAssetsV1',
PartnerAssetsV2 = 'PartnerAssetsV2',
PartnerAssetExifsV1 = 'PartnerAssetExifsV1', PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
PartnerStacksV1 = 'PartnerStacksV1', PartnerStacksV1 = 'PartnerStacksV1',
StacksV1 = 'StacksV1', StacksV1 = 'StacksV1',
UsersV1 = 'UsersV1', UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1', PeopleV1 = 'PeopleV1',
/** @deprecated */
AssetFacesV1 = 'AssetFacesV1', AssetFacesV1 = 'AssetFacesV1',
AssetFacesV2 = 'AssetFacesV2', AssetFacesV2 = 'AssetFacesV2',
UserMetadataV1 = 'UserMetadataV1', UserMetadataV1 = 'UserMetadataV1',
@@ -834,7 +846,9 @@ export enum SyncEntityType {
UserV1 = 'UserV1', UserV1 = 'UserV1',
UserDeleteV1 = 'UserDeleteV1', UserDeleteV1 = 'UserDeleteV1',
/** @deprecated */
AssetV1 = 'AssetV1', AssetV1 = 'AssetV1',
AssetV2 = 'AssetV2',
AssetDeleteV1 = 'AssetDeleteV1', AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1', AssetExifV1 = 'AssetExifV1',
AssetEditV1 = 'AssetEditV1', AssetEditV1 = 'AssetEditV1',
@@ -847,8 +861,12 @@ export enum SyncEntityType {
PartnerV1 = 'PartnerV1', PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1', PartnerDeleteV1 = 'PartnerDeleteV1',
/** @deprecated */
PartnerAssetV1 = 'PartnerAssetV1', PartnerAssetV1 = 'PartnerAssetV1',
PartnerAssetV2 = 'PartnerAssetV2',
/** @deprecated */
PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1', PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1',
PartnerAssetBackfillV2 = 'PartnerAssetBackfillV2',
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
PartnerAssetExifV1 = 'PartnerAssetExifV1', PartnerAssetExifV1 = 'PartnerAssetExifV1',
PartnerAssetExifBackfillV1 = 'PartnerAssetExifBackfillV1', PartnerAssetExifBackfillV1 = 'PartnerAssetExifBackfillV1',
@@ -864,9 +882,15 @@ export enum SyncEntityType {
AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
/** @deprecated */
AlbumAssetCreateV1 = 'AlbumAssetCreateV1', AlbumAssetCreateV1 = 'AlbumAssetCreateV1',
AlbumAssetCreateV2 = 'AlbumAssetCreateV2',
/** @deprecated */
AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1', AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1',
AlbumAssetUpdateV2 = 'AlbumAssetUpdateV2',
/** @deprecated */
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1', AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1',
AlbumAssetBackfillV2 = 'AlbumAssetBackfillV2',
AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1', AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1',
AlbumAssetExifUpdateV1 = 'AlbumAssetExifUpdateV1', AlbumAssetExifUpdateV1 = 'AlbumAssetExifUpdateV1',
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1', AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',
@@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
import { Response } from 'express'; import { Response } from 'express';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod'; import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger'; import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
@@ -16,18 +17,13 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
} }
catch(error: Error, host: ArgumentsHost) { catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp(); this.handleError(host.switchToHttp().getResponse<Response>(), error);
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() });
}
} }
handleError(res: Response, error: Error) { handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error); const { status, body } = this.fromError(error);
if (!res.headersSent) { 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) { if (error instanceof HttpException) {
const status = error.getStatus(); const status = error.getStatus();
let body = error.getResponse(); const response = error.getResponse();
const body: Record<string, unknown> =
// unclear what circumstances would return a string typeof response === 'string' ? { message: response } : { ...(response as object) };
if (typeof body === 'string') {
body = { message: body };
}
// handle both request and response validation errors // handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) { if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError(); const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) { if (zodError instanceof ZodError && zodError.issues.length > 0) {
body = { body['message'] = zodError.issues.map((issue) =>
message: zodError.issues.map((issue) => issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message, );
),
error: 'Bad Request',
};
} }
} }
// 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 }; 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 -8
View File
@@ -248,10 +248,6 @@ const getEnv = (): EnvData => {
vectorExtension = DatabaseExtension.Vector; vectorExtension = DatabaseExtension.Vector;
break; break;
} }
case 'pgvecto.rs': {
vectorExtension = DatabaseExtension.Vectors;
break;
}
case 'vectorchord': { case 'vectorchord': {
vectorExtension = DatabaseExtension.VectorChord; vectorExtension = DatabaseExtension.VectorChord;
break; break;
@@ -301,11 +297,9 @@ const getEnv = (): EnvData => {
mount: true, mount: true,
generateId: true, generateId: true,
setup: (cls, req: Request, res: Response) => { setup: (cls, req: Request, res: Response) => {
const headerValues = req.headers[ImmichHeader.Cid]; const cid = req.header(ImmichHeader.CorrelationId) || cls.get(CLS_ID);
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
cls.set(CLS_ID, cid); cls.set(CLS_ID, cid);
res.header(ImmichHeader.Cid, cid); res.header(ImmichHeader.CorrelationId, cid);
}, },
}, },
}, },
+9 -40
View File
@@ -1,7 +1,7 @@
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools'; import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import AsyncLock from 'async-lock'; import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely'; import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises'; import { readdir } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -14,7 +14,6 @@ import {
VECTOR_VERSION_RANGE, VECTOR_VERSION_RANGE,
VECTORCHORD_LIST_SLACK_FACTOR, VECTORCHORD_LIST_SLACK_FACTOR,
VECTORCHORD_VERSION_RANGE, VECTORCHORD_VERSION_RANGE,
VECTORS_VERSION_RANGE,
} from 'src/constants'; } from 'src/constants';
import { GenerateSql } from 'src/decorators'; import { GenerateSql } from 'src/decorators';
import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
@@ -23,7 +22,7 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { immich_uuid_v7 } from 'src/schema/functions'; import { immich_uuid_v7 } from 'src/schema/functions';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { ExtensionVersion, VectorExtension } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database'; import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
@@ -73,7 +72,7 @@ export class DatabaseRepository {
return getVectorExtension(this.db); return getVectorExtension(this.db);
} }
@GenerateSql({ params: [[DatabaseExtension.Vectors]] }) @GenerateSql({ params: [[DatabaseExtension.Vector]] })
async getExtensionVersions(extensions: readonly DatabaseExtension[]): Promise<ExtensionVersion[]> { async getExtensionVersions(extensions: readonly DatabaseExtension[]): Promise<ExtensionVersion[]> {
const { rows } = await sql<ExtensionVersion>` const { rows } = await sql<ExtensionVersion>`
SELECT name, default_version as "availableVersion", installed_version as "installedVersion" SELECT name, default_version as "availableVersion", installed_version as "installedVersion"
@@ -88,9 +87,6 @@ export class DatabaseRepository {
case DatabaseExtension.VectorChord: { case DatabaseExtension.VectorChord: {
return VECTORCHORD_VERSION_RANGE; return VECTORCHORD_VERSION_RANGE;
} }
case DatabaseExtension.Vectors: {
return VECTORS_VERSION_RANGE;
}
case DatabaseExtension.Vector: { case DatabaseExtension.Vector: {
return VECTOR_VERSION_RANGE; return VECTOR_VERSION_RANGE;
} }
@@ -125,7 +121,7 @@ export class DatabaseRepository {
await sql`DROP EXTENSION IF EXISTS ${sql.raw(extension)}`.execute(this.db); await sql`DROP EXTENSION IF EXISTS ${sql.raw(extension)}`.execute(this.db);
} }
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<VectorUpdateResult> { async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<void> {
const [{ availableVersion, installedVersion }] = await this.getExtensionVersions([extension]); const [{ availableVersion, installedVersion }] = await this.getExtensionVersions([extension]);
if (!installedVersion) { if (!installedVersion) {
throw new Error(`${EXTENSION_NAMES[extension]} extension is not installed`); throw new Error(`${EXTENSION_NAMES[extension]} extension is not installed`);
@@ -136,10 +132,8 @@ export class DatabaseRepository {
} }
targetVersion ??= availableVersion; targetVersion ??= availableVersion;
let restartRequired = false; if (!semver.diff(installedVersion, targetVersion)) {
const diff = semver.diff(installedVersion, targetVersion); return;
if (!diff) {
return { restartRequired: false };
} }
await Promise.all([ await Promise.all([
@@ -147,22 +141,8 @@ export class DatabaseRepository {
this.db.schema.dropIndex(VectorIndex.Face).ifExists().execute(), this.db.schema.dropIndex(VectorIndex.Face).ifExists().execute(),
]); ]);
await this.db.transaction().execute(async (tx) => { await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(this.db);
await this.setSearchPath(tx); await Promise.all([this.reindexVectors(VectorIndex.Clip), this.reindexVectors(VectorIndex.Face)]);
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx);
if (extension === DatabaseExtension.Vectors && (diff === 'major' || diff === 'minor')) {
await sql`SELECT pgvectors_upgrade()`.execute(tx);
restartRequired = true;
}
});
if (!restartRequired) {
await Promise.all([this.reindexVectors(VectorIndex.Clip), this.reindexVectors(VectorIndex.Face)]);
}
return { restartRequired };
} }
async prewarm(index: VectorIndex): Promise<void> { async prewarm(index: VectorIndex): Promise<void> {
@@ -198,12 +178,6 @@ export class DatabaseRepository {
} }
break; break;
} }
case DatabaseExtension.Vectors: {
if (!row.indexdef.toLowerCase().includes('using vectors')) {
promises.push(this.reindexVectors(indexName));
}
break;
}
case DatabaseExtension.VectorChord: { case DatabaseExtension.VectorChord: {
const matches = row.indexdef.match(/(?<=lists = \[)\d+/g); const matches = row.indexdef.match(/(?<=lists = \[)\d+/g);
const lists = matches && matches.length > 0 ? Number(matches[0]) : 1; const lists = matches && matches.length > 0 ? Number(matches[0]) : 1;
@@ -260,11 +234,10 @@ export class DatabaseRepository {
await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx); await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx);
} }
await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx); await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
const schema = vectorExtension === DatabaseExtension.Vectors ? 'vectors.' : '';
await sql` await sql`
ALTER TABLE ${sql.raw(table)} ALTER TABLE ${sql.raw(table)}
ALTER COLUMN embedding ALTER COLUMN embedding
SET DATA TYPE ${sql.raw(schema)}vector(${sql.raw(String(dimSize))})`.execute(tx); SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute(tx);
await sql.raw(vectorIndexQuery({ vectorExtension, table, indexName, lists })).execute(tx); await sql.raw(vectorIndexQuery({ vectorExtension, table, indexName, lists })).execute(tx);
}); });
try { try {
@@ -275,10 +248,6 @@ export class DatabaseRepository {
this.logger.log(`Reindexed ${indexName}`); this.logger.log(`Reindexed ${indexName}`);
} }
private async setSearchPath(tx: Transaction<DB>): Promise<void> {
await sql`SET search_path TO "$user", public, vectors`.execute(tx);
}
private async getDatabaseName(): Promise<string> { private async getDatabaseName(): Promise<string> {
const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(this.db); const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(this.db);
return rows[0].db; return rows[0].db;
+2
View File
@@ -46,6 +46,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.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 { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -100,6 +101,7 @@ export const repositories = [
UserRepository, UserRepository,
ViewRepository, ViewRepository,
VersionHistoryRepository, VersionHistoryRepository,
VideoStreamRepository,
WebsocketRepository, WebsocketRepository,
WorkflowRepository, 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();
}
}
@@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto'; import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
@@ -35,9 +35,9 @@ export interface ClientEventMap {
on_notification: [NotificationDto]; on_notification: [NotificationDto];
on_session_delete: [string]; on_session_delete: [string];
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AssetUploadReadyV2: [{ asset: SyncAssetV2; exif: SyncAssetExifV1 }];
AppRestartV1: [AppRestartEvent]; AppRestartV1: [AppRestartEvent];
AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }]; AssetEditReadyV2: [{ asset: SyncAssetV2; edit: SyncAssetEditV1[] }];
} }
export type AuthFn = (client: Socket) => Promise<AuthDto>; export type AuthFn = (client: Socket) => Promise<AuthDto>;
+13 -1
View File
@@ -1,5 +1,12 @@
import { registerEnum } from '@immich/sql-tools'; 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({ export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum', name: 'album_user_role_enum',
@@ -25,3 +32,8 @@ export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum', name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm), 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
@@ -78,6 +78,11 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.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'; import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@@ -136,6 +141,9 @@ export class ImmichDatabase {
UserMetadataAuditTable, UserMetadataAuditTable,
UserTable, UserTable,
VersionHistoryTable, VersionHistoryTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
VideoStreamSegmentTable,
PluginTable, PluginTable,
PluginFilterTable, PluginFilterTable,
PluginActionTable, PluginActionTable,
@@ -252,6 +260,10 @@ export interface DB {
version_history: VersionHistoryTable; version_history: VersionHistoryTable;
video_stream_session: VideoStreamSessionTable;
video_stream_variant: VideoStreamVariantTable;
video_stream_segment: VideoStreamSegmentTable;
plugin: PluginTable; plugin: PluginTable;
plugin_filter: PluginFilterTable; plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable; plugin_action: PluginActionTable;
@@ -1,6 +1,5 @@
import { Kysely, sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { ErrorMessages } from 'src/constants'; import { ErrorMessages } from 'src/constants';
import { DatabaseExtension } from 'src/enum';
import { getVectorExtension } from 'src/repositories/database.repository'; import { getVectorExtension } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { vectorIndexQuery } from 'src/utils/database'; import { vectorIndexQuery } from 'src/utils/database';
@@ -107,9 +106,6 @@ export async function up(db: Kysely<any>): Promise<void> {
RETURN NULL; RETURN NULL;
END; END;
$$;`.execute(db); $$;`.execute(db);
if (vectorExtension === DatabaseExtension.Vectors) {
await sql`SET search_path TO "$user", public, vectors`.execute(db);
}
await sql`CREATE TYPE "assets_status_enum" AS ENUM ('active','trashed','deleted');`.execute(db); await sql`CREATE TYPE "assets_status_enum" AS ENUM ('active','trashed','deleted');`.execute(db);
await sql`CREATE TYPE "sourcetype" AS ENUM ('machine-learning','exif','manual');`.execute(db); await sql`CREATE TYPE "sourcetype" AS ENUM ('machine-learning','exif','manual');`.execute(db);
await sql`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password" character varying NOT NULL DEFAULT '', "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "profileImagePath" character varying NOT NULL DEFAULT '', "isAdmin" boolean NOT NULL DEFAULT false, "shouldChangePassword" boolean NOT NULL DEFAULT true, "deletedAt" timestamp with time zone, "oauthId" character varying NOT NULL DEFAULT '', "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "storageLabel" character varying, "name" character varying NOT NULL DEFAULT '', "quotaSizeInBytes" bigint, "quotaUsageInBytes" bigint NOT NULL DEFAULT 0, "status" character varying NOT NULL DEFAULT 'active', "profileChangedAt" timestamp with time zone NOT NULL DEFAULT now(), "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( await sql`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password" character varying NOT NULL DEFAULT '', "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "profileImagePath" character varying NOT NULL DEFAULT '', "isAdmin" boolean NOT NULL DEFAULT false, "shouldChangePassword" boolean NOT NULL DEFAULT true, "deletedAt" timestamp with time zone, "oauthId" character varying NOT NULL DEFAULT '', "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "storageLabel" character varying, "name" character varying NOT NULL DEFAULT '', "quotaSizeInBytes" bigint, "quotaUsageInBytes" bigint NOT NULL DEFAULT 0, "status" character varying NOT NULL DEFAULT 'active', "profileChangedAt" timestamp with time zone NOT NULL DEFAULT now(), "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(
@@ -0,0 +1,31 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
ALTER TABLE asset
ALTER COLUMN duration TYPE integer
USING (
CASE
WHEN duration ~ '^\\d{2}:\\d{2}:\\d{2}\\.\\d{3}$'
THEN substr(duration, 1, 2)::int * 3600000
+ substr(duration, 4, 2)::int * 60000
+ substr(duration, 7, 2)::int * 1000
+ substr(duration, 10, 3)::int
END
);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`
ALTER TABLE asset
ALTER COLUMN duration TYPE varchar
USING (
CASE
WHEN duration IS NULL THEN NULL
ELSE lpad((duration / 3600000)::text, 2, '0')
|| ':' || lpad(((duration / 60000) % 60)::text, 2, '0')
|| ':' || lpad(((duration / 1000) % 60)::text, 2, '0')
|| '.' || lpad((duration % 1000)::text, 3, '0')
END
);`.execute(db);
}
@@ -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);
}
+2 -2
View File
@@ -83,8 +83,8 @@ export class AssetTable {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>; isFavorite!: Generated<boolean>;
@Column({ type: 'character varying', nullable: true }) @Column({ type: 'integer', nullable: true })
duration!: string | null; duration!: number | null;
@Column({ type: 'bytea', index: true }) @Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum checksum!: Buffer; // sha1 checksum
@@ -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.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id, id: album.id,
userId: albumUser.userId, userId: albumUser.userId,
+1 -2
View File
@@ -114,7 +114,6 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Cannot share album with owner'); throw new BadRequestException('Cannot share album with owner');
} }
} }
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
const allowedAssetIdsSet = await this.checkAccess({ const allowedAssetIdsSet = await this.checkAccess({
auth, auth,
@@ -133,7 +132,7 @@ export class AlbumService extends BaseService {
order: getPreferences(userMetadata).albums.defaultAssetOrder, order: getPreferences(userMetadata).albums.defaultAssetOrder,
}, },
assetIds, assetIds,
albumUsers, [{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
auth.user.id, auth.user.id,
); );

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