diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index ec23e1f6dd27d0..b9577bbb2ee146 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -153,7 +153,7 @@ jobs:
machine-learning:
name: Build and Push ML
needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true || startsWith(github.base_ref, 'release/') }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
strategy:
fail-fast: false
matrix:
@@ -184,7 +184,7 @@ jobs:
server:
name: Build and Push Server
needs: pre-job
- if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true || startsWith(github.base_ref, 'release/') }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@4d420cf8e5ea499f99b2c2849ff9a219d5f7b178 # multi-runner-build-workflow-v4.0.0
permissions:
contents: read
diff --git a/docs/docs/partials/_mobile-app-download.md b/docs/docs/partials/_mobile-app-download.md
index 08540837a1518e..83f37c537e0ebe 100644
--- a/docs/docs/partials/_mobile-app-download.md
+++ b/docs/docs/partials/_mobile-app-download.md
@@ -4,4 +4,4 @@ The mobile app can be downloaded from the following places:
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [GitHub Releases (APK)](https://github.com/immich-app/immich/releases)
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
-- [F-Droid](https://app.futo.org/fdroid/repo/)
+- [FUTO F-Droid](https://get.immich.app/fdroid)
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 743abd442cfd6c..2da7aaf931a8f6 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -19889,12 +19889,14 @@
},
"latitude": {
"description": "Latitude coordinate",
+ "format": "double",
"maximum": 90,
"minimum": -90,
"type": "number"
},
"longitude": {
"description": "Longitude coordinate",
+ "format": "double",
"maximum": 180,
"minimum": -180,
"type": "number"
@@ -22131,6 +22133,7 @@
"fNumber": {
"default": null,
"description": "F-number (aperture)",
+ "format": "double",
"nullable": true,
"type": "number"
},
@@ -22145,6 +22148,7 @@
"focalLength": {
"default": null,
"description": "Focal length in mm",
+ "format": "double",
"nullable": true,
"type": "number"
},
@@ -22159,6 +22163,7 @@
"latitude": {
"default": null,
"description": "GPS latitude",
+ "format": "double",
"nullable": true,
"type": "number"
},
@@ -22171,6 +22176,7 @@
"longitude": {
"default": null,
"description": "GPS longitude",
+ "format": "double",
"nullable": true,
"type": "number"
},
@@ -24287,32 +24293,40 @@
"NumberFilter": {
"properties": {
"eq": {
+ "format": "double",
"type": "number"
},
"gt": {
+ "format": "double",
"type": "number"
},
"gte": {
+ "format": "double",
"type": "number"
},
"in": {
"items": {
+ "format": "double",
"type": "number"
},
"minItems": 1,
"type": "array"
},
"lt": {
+ "format": "double",
"type": "number"
},
"lte": {
+ "format": "double",
"type": "number"
},
"ne": {
+ "format": "double",
"type": "number"
},
"notIn": {
"items": {
+ "format": "double",
"type": "number"
},
"minItems": 1,
@@ -24324,34 +24338,42 @@
"NumberFilterNullable": {
"properties": {
"eq": {
+ "format": "double",
"nullable": true,
"type": "number"
},
"gt": {
+ "format": "double",
"type": "number"
},
"gte": {
+ "format": "double",
"type": "number"
},
"in": {
"items": {
+ "format": "double",
"type": "number"
},
"minItems": 1,
"type": "array"
},
"lt": {
+ "format": "double",
"type": "number"
},
"lte": {
+ "format": "double",
"type": "number"
},
"ne": {
+ "format": "double",
"nullable": true,
"type": "number"
},
"notIn": {
"items": {
+ "format": "double",
"type": "number"
},
"minItems": 1,
@@ -25096,10 +25118,12 @@
},
"latitude": {
"description": "Latitude coordinate",
+ "format": "double",
"type": "number"
},
"longitude": {
"description": "Longitude coordinate",
+ "format": "double",
"type": "number"
},
"name": {
@@ -26489,7 +26513,9 @@
"properties": {
"angle": {
"description": "Rotation angle in degrees",
- "type": "number"
+ "maximum": 270,
+ "minimum": 0,
+ "type": "integer"
}
},
"required": [
@@ -31554,6 +31580,7 @@
"latitude": {
"description": "Array of latitude coordinates extracted from EXIF GPS data",
"items": {
+ "format": "double",
"nullable": true,
"type": "number"
},
@@ -31570,6 +31597,7 @@
"localOffsetHours": {
"description": "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.",
"items": {
+ "format": "double",
"type": "number"
},
"type": "array"
@@ -31577,6 +31605,7 @@
"longitude": {
"description": "Array of longitude coordinates extracted from EXIF GPS data",
"items": {
+ "format": "double",
"nullable": true,
"type": "number"
},
@@ -31600,6 +31629,7 @@
"ratio": {
"description": "Array of aspect ratios (width/height) for each asset",
"items": {
+ "format": "double",
"type": "number"
},
"type": "array"
@@ -31783,6 +31813,7 @@
},
"latitude": {
"description": "Latitude coordinate",
+ "format": "double",
"maximum": 90,
"minimum": -90,
"type": "number"
@@ -31796,6 +31827,7 @@
},
"longitude": {
"description": "Longitude coordinate",
+ "format": "double",
"maximum": 180,
"minimum": -180,
"type": "number"
diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts
index e862a117eefc5c..f60db435e1ca2a 100644
--- a/server/src/dtos/editing.dto.ts
+++ b/server/src/dtos/editing.dto.ts
@@ -31,7 +31,9 @@ const CropParametersSchema = z
const RotateParametersSchema = z
.object({
angle: z
- .number()
+ .int()
+ .min(0)
+ .max(270)
.refine((v) => [0, 90, 180, 270].includes(v), {
error: 'Angle must be one of the following values: 0, 90, 180, 270',
})
diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts
index b918c4abd1829c..99bdaaf1007719 100644
--- a/server/src/dtos/exif.dto.ts
+++ b/server/src/dtos/exif.dto.ts
@@ -18,12 +18,12 @@ export const ExifResponseSchema = z
modifyDate: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Modification date/time'),
timeZone: z.string().nullish().default(null).describe('Time zone'),
lensModel: z.string().nullish().default(null).describe('Lens model'),
- fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
- focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
+ fNumber: z.number().meta({ format: 'double' }).nullish().default(null).describe('F-number (aperture)'),
+ focalLength: z.number().meta({ format: 'double' }).nullish().default(null).describe('Focal length in mm'),
iso: z.int().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
- latitude: z.number().nullish().default(null).describe('GPS latitude'),
- longitude: z.number().nullish().default(null).describe('GPS longitude'),
+ latitude: z.number().meta({ format: 'double' }).nullish().default(null).describe('GPS latitude'),
+ longitude: z.number().meta({ format: 'double' }).nullish().default(null).describe('GPS longitude'),
city: z.string().nullish().default(null).describe('City name'),
state: z.string().nullish().default(null).describe('State/province name'),
country: z.string().nullish().default(null).describe('Country name'),
diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts
index 1f65c3e434e8f2..686ce38a288051 100644
--- a/server/src/dtos/search.dto.ts
+++ b/server/src/dtos/search.dto.ts
@@ -94,8 +94,8 @@ const SearchPeopleSchema = z
const PlacesResponseSchema = z
.object({
name: z.string().describe('Place name'),
- latitude: z.number().describe('Latitude coordinate'),
- longitude: z.number().describe('Longitude coordinate'),
+ latitude: z.number().meta({ format: 'double' }).describe('Latitude coordinate'),
+ longitude: z.number().meta({ format: 'double' }).describe('Longitude coordinate'),
admin1name: z.string().optional().describe('Administrative level 1 name (state/province)'),
admin2name: z.string().optional().describe('Administrative level 2 name (county/district)'),
})
@@ -174,23 +174,23 @@ const StringPatternFilterSchema = nonEmptyPartial({
}).meta({ id: 'StringPatternFilter' });
const numberRangeShape = {
- lt: z.number(),
- lte: z.number(),
- gt: z.number(),
- gte: z.number(),
- in: z.array(z.number()).min(1),
- notIn: z.array(z.number()).min(1),
+ lt: z.number().meta({ format: 'double' }),
+ lte: z.number().meta({ format: 'double' }),
+ gt: z.number().meta({ format: 'double' }),
+ gte: z.number().meta({ format: 'double' }),
+ in: z.array(z.number().meta({ format: 'double' })).min(1),
+ notIn: z.array(z.number().meta({ format: 'double' })).min(1),
};
const NumberFilterSchema = nonEmptyPartial({
- eq: z.number(),
- ne: z.number(),
+ eq: z.number().meta({ format: 'double' }),
+ ne: z.number().meta({ format: 'double' }),
...numberRangeShape,
}).meta({ id: 'NumberFilter' });
const NumberFilterNullableSchema = nonEmptyPartial({
- eq: z.number().nullable(),
- ne: z.number().nullable(),
+ eq: z.number().meta({ format: 'double' }).nullable(),
+ ne: z.number().meta({ format: 'double' }).nullable(),
...numberRangeShape,
}).meta({ id: 'NumberFilterNullable' });
diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts
index a2ee06c87898da..c85c7e83a1cf38 100644
--- a/server/src/dtos/time-bucket.dto.ts
+++ b/server/src/dtos/time-bucket.dto.ts
@@ -75,7 +75,9 @@ const TimeBucketAssetResponseSchema = z
.object({
id: z.array(z.string()).describe('Array of asset IDs in the time bucket'),
ownerId: z.array(z.string()).describe('Array of owner IDs for each asset'),
- ratio: z.array(z.number()).describe('Array of aspect ratios (width/height) for each asset'),
+ ratio: z
+ .array(z.number().meta({ format: 'double' }))
+ .describe('Array of aspect ratios (width/height) for each asset'),
isFavorite: z.array(z.boolean()).describe('Array indicating whether each asset is favorited'),
visibility: z
.array(AssetVisibilitySchema)
@@ -90,7 +92,7 @@ const TimeBucketAssetResponseSchema = z
.describe('Array of UTC timestamps when each asset was originally uploaded to Immich'),
fileCreatedAt: z.array(z.string()).describe('Array of file creation timestamps in UTC'),
localOffsetHours: z
- .array(z.number())
+ .array(z.number().meta({ format: 'double' }))
.describe(
"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.",
),
@@ -110,11 +112,11 @@ const TimeBucketAssetResponseSchema = z
city: z.array(z.string().nullable()).optional().describe('Array of city names extracted from EXIF GPS data'),
country: z.array(z.string().nullable()).optional().describe('Array of country names extracted from EXIF GPS data'),
latitude: z
- .array(z.number().nullable())
+ .array(z.number().meta({ format: 'double' }).nullable())
.optional()
.describe('Array of latitude coordinates extracted from EXIF GPS data'),
longitude: z
- .array(z.number().nullable())
+ .array(z.number().meta({ format: 'double' }).nullable())
.optional()
.describe('Array of longitude coordinates extracted from EXIF GPS data'),
})
diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts
index 0c2b18b469d03b..77c06c24407f3b 100644
--- a/server/src/utils/misc.ts
+++ b/server/src/utils/misc.ts
@@ -225,6 +225,8 @@ const patchOpenAPI = (document: OpenAPIObject) => {
document.components.schemas = sortKeys(schemas);
+ const errors: string[] = [];
+
for (const [schemaName, schema] of Object.entries(schemas)) {
if (!schema.properties) {
continue;
@@ -232,16 +234,35 @@ const patchOpenAPI = (document: OpenAPIObject) => {
schema.properties = sortKeys(schema.properties);
- for (const [key, value] of Object.entries(schema.properties)) {
- if (typeof value === 'string') {
+ for (const [key, initialValue] of Object.entries(schema.properties)) {
+ if (typeof initialValue === 'string' || !isSchema(initialValue)) {
continue;
}
- if (isSchema(value) && value.type === 'number' && value.format === 'float') {
- throw new Error(`Invalid number format: ${schemaName}.${key}=float (use double instead). `);
+ // check array types
+ let value: SchemaObject | ReferenceObject = initialValue;
+ if (value.type === 'array' && value.items) {
+ value = value.items;
+ }
+
+ if (isSchema(value) && value.type === 'number') {
+ if (value.format === 'float') {
+ errors.push(`Invalid number format: ${schemaName}.${key}=float (use double instead). `);
+ }
+
+ // verify it was meant to be a number (and not an integer)
+ if (!value.format) {
+ errors.push(
+ `${schemaName}.${key} is a number (not an integer) and requires a format (e.g .meta({ format: 'double' })). `,
+ );
+ }
}
}
schema.required?.sort();
+
+ if (errors.length > 0) {
+ throw new Error(`Schema validation failed:\n ${errors.join('\n ')}`);
+ }
}
}
diff --git a/server/src/validation.ts b/server/src/validation.ts
index 467a87b676f654..f9f4288ee45083 100644
--- a/server/src/validation.ts
+++ b/server/src/validation.ts
@@ -185,7 +185,7 @@ export const isoDateToDate = z
* // Pipe (query): coerce string to number then validate range
* z.coerce.number().pipe(latitudeSchema).describe('Latitude (-90 to 90)')
*/
-export const latitudeSchema = z.number().min(-90).max(90);
+export const latitudeSchema = z.number().meta({ format: 'double' }).min(-90).max(90);
/**
* Longitude in range [-180, 180]. Reuse for body or query params.
@@ -198,7 +198,7 @@ export const latitudeSchema = z.number().min(-90).max(90);
* // Pipe (query): coerce string to number then validate range
* z.coerce.number().pipe(longitudeSchema).describe('Longitude (-180 to 180)')
*/
-export const longitudeSchema = z.number().min(-180).max(180);
+export const longitudeSchema = z.number().meta({ format: 'double' }).min(-180).max(180);
/**
* Parse string to boolean
diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte
index 01a998bf5cc933..fb629470f9ea44 100644
--- a/web/src/lib/modals/AppDownloadModal.svelte
+++ b/web/src/lib/modals/AppDownloadModal.svelte
@@ -1,14 +1,19 @@
+
+
@@ -16,9 +21,5 @@
-
-
-
-