chore: merge main into feat/hero_view_transitions

Change-Id: I6e6316f66343b8f3ea9fe33ed3f8f3e56a6a6964
This commit is contained in:
midzelis
2026-05-14 02:30:18 +00:00
379 changed files with 25782 additions and 3260 deletions
+11 -12
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202604141125@sha256:9338c216fb0fef4172cf53cd8e4ff607c6635d576dcc1366151f13d69bbb45ef AS builder
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -29,7 +29,7 @@ ENV IMMICH_BUILD=${BUILD_ID}
WORKDIR /usr/src/app
COPY ./web ./web/
COPY ./i18n ./i18n/
COPY ./open-api ./open-api/
COPY ./packages ./packages/
RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
@@ -40,8 +40,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
FROM builder AS cli
COPY ./cli ./cli/
COPY ./open-api ./open-api/
COPY ./packages ./packages/
RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
@@ -58,13 +57,13 @@ ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
COPY ./packages/plugins/mise.toml ./packages/plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/packages/plugins/mise.toml
ENV MISE_DATA_DIR=/buildcache/mise
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install --cd plugins
mise install --cd packages/plugins
COPY ./plugins ./plugins/
COPY ./packages/plugins ./packages/plugins/
# Build plugins
RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
@@ -72,9 +71,9 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
cd packages/plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202604141125@sha256:3b05219afcda09cebfb8513743fc92cec1a3ae262249bfe0de6f90da21326991
FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd
WORKDIR /usr/src/app
ENV NODE_ENV=production \
@@ -84,8 +83,8 @@ ENV NODE_ENV=production \
COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist
COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json
COPY --from=plugins /usr/src/app/packages/plugins/dist /build/corePlugin/dist
COPY --from=plugins /usr/src/app/packages/plugins/manifest.json /build/corePlugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202604141125@sha256:9338c216fb0fef4172cf53cd8e4ff607c6635d576dcc1366151f13d69bbb45ef AS dev
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
+2 -2
View File
@@ -33,9 +33,9 @@ env._.path = "./node_modules/.bin"
run = "tsc --noEmit"
[tasks.sql]
run = "node ./dist/bin/sync-open-api.js"
run = "node ./dist/bin/sync-sql.js"
[tasks."open-api"]
[tasks."sync-open-api"]
run = "node ./dist/bin/sync-open-api.js"
[tasks.migrations]
+3 -5
View File
@@ -33,8 +33,6 @@
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
"schema:reset": "pnpm run schema:drop && pnpm run migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
"email:dev": "email dev -p 3050 --dir src/emails"
},
"dependencies": {
@@ -50,14 +48,14 @@
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/exporter-prometheus": "^0.217.0",
"@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.67.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.215.0",
"@opentelemetry/sdk-node": "^0.217.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^1.0.0",
"@react-email/render": "^2.0.0",
@@ -84,7 +82,7 @@
"jose": "^6.0.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.16",
"kysely": "0.28.17",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
+1 -1
View File
@@ -223,7 +223,7 @@ export const defaults = Object.freeze<SystemConfig>({
transcode: TranscodePolicy.Required,
tonemap: ToneMapping.Hable,
accel: TranscodeHardwareAcceleration.Disabled,
accelDecode: false,
accelDecode: true,
},
job: {
[QueueName.BackgroundTask]: { concurrency: 5 },
-1
View File
@@ -199,7 +199,6 @@ export const endpointTags: Record<ApiTag, string> = {
export const AUDIO_ENCODER: Record<AudioCodec, string> = {
[AudioCodec.Aac]: 'aac',
[AudioCodec.Mp3]: 'mp3',
[AudioCodec.Libopus]: 'libopus',
[AudioCodec.Opus]: 'libopus',
[AudioCodec.PcmS16le]: 'pcm_s16le',
};
+2
View File
@@ -382,6 +382,7 @@ export const columns = {
'asset.checksum',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.createdAt',
'asset.localDateTime',
'asset.type',
'asset.deletedAt',
@@ -404,6 +405,7 @@ export const columns = {
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.localDateTime',
'asset.createdAt',
'asset.type',
'asset.deletedAt',
'asset.visibility',
+1 -1
View File
@@ -38,7 +38,7 @@ export enum UploadFieldName {
const AssetMediaBaseSchema = z.object({
fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
duration: z.int32().min(0).optional().describe('Duration in milliseconds (for videos)'),
duration: z.coerce.number().int().min(0).optional().describe('Duration in milliseconds (for videos)'),
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. */
[UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }),
-192
View File
@@ -1,192 +0,0 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { getForAsset } from 'test/mappers';
describe('mapAsset', () => {
describe('peopleWithFaces', () => {
it('should transform all faces when a person has multiple faces in the same image', () => {
const person = PersonFactory.create();
const face1 = {
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const face2 = {
boundingBoxX1: 300,
boundingBoxY1: 400,
boundingBoxX2: 400,
boundingBoxY2: 500,
imageWidth: 1000,
imageHeight: 800,
};
const asset = AssetFactory.from()
.face(face1, (builder) => builder.person(person))
.face(face2, (builder) => builder.person(person))
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.edit({
action: AssetEditAction.Crop,
parameters: {
width: 1512,
height: 1152,
x: 216,
y: 1512,
},
})
.build();
const result = mapAsset(getForAsset(asset));
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(1);
expect(result.people![0].faces).toHaveLength(2);
// Verify that both faces have been transformed (bounding boxes adjusted for crop)
const firstFace = result.people![0].faces[0];
const secondFace = result.people![0].faces[1];
// After crop (x: 216, y: 1512), the coordinates should be adjusted
// Faces outside the crop area will be clamped
expect(firstFace.boundingBoxX1).toBe(-116); // 100 - 216 = -116
expect(firstFace.boundingBoxY1).toBe(-1412); // 100 - 1512 = -1412
expect(firstFace.boundingBoxX2).toBe(-16); // 200 - 216 = -16
expect(firstFace.boundingBoxY2).toBe(-1312); // 200 - 1512 = -1312
expect(secondFace.boundingBoxX1).toBe(84); // 300 - 216
expect(secondFace.boundingBoxY1).toBe(-1112); // 400 - 1512 = -1112
expect(secondFace.boundingBoxX2).toBe(184); // 400 - 216
expect(secondFace.boundingBoxY2).toBe(-1012); // 500 - 1512 = -1012
});
it('should transform unassigned faces with edits and dimensions', () => {
const unassignedFace = AssetFaceFactory.create({
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
});
const asset = AssetFactory.from()
.face(unassignedFace)
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
.build();
const result = mapAsset(getForAsset(asset));
expect(result.unassignedFaces).toBeDefined();
expect(result.unassignedFaces).toHaveLength(1);
// Verify that unassigned face has been transformed
const face = result.unassignedFaces![0];
expect(face.boundingBoxX1).toBe(50); // 100 - 50
expect(face.boundingBoxY1).toBe(50); // 100 - 50
expect(face.boundingBoxX2).toBe(150); // 200 - 50
expect(face.boundingBoxY2).toBe(150); // 200 - 50
});
it('should handle multiple people each with multiple faces', () => {
const person1Face1 = {
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const person1Face2 = {
boundingBoxX1: 300,
boundingBoxY1: 300,
boundingBoxX2: 400,
boundingBoxY2: 400,
imageWidth: 1000,
imageHeight: 800,
};
const person2Face1 = {
boundingBoxX1: 500,
boundingBoxY1: 100,
boundingBoxX2: 600,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const person = PersonFactory.create({ id: 'person-1' });
const asset = AssetFactory.from()
.face(person1Face1, (builder) => builder.person(person))
.face(person1Face2, (builder) => builder.person(person))
.face(person2Face1, (builder) => builder.person({ id: 'person-2' }))
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.build();
const result = mapAsset(getForAsset(asset));
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(2);
const person1 = result.people!.find((p) => p.id === 'person-1');
const person2 = result.people!.find((p) => p.id === 'person-2');
expect(person1).toBeDefined();
expect(person1!.faces).toHaveLength(2);
// No edits, so coordinates should be unchanged
expect(person1!.faces[0].boundingBoxX1).toBe(100);
expect(person1!.faces[0].boundingBoxY1).toBe(100);
expect(person1!.faces[1].boundingBoxX1).toBe(300);
expect(person1!.faces[1].boundingBoxY1).toBe(300);
expect(person2).toBeDefined();
expect(person2!.faces).toHaveLength(1);
expect(person2!.faces[0].boundingBoxX1).toBe(500);
expect(person2!.faces[0].boundingBoxY1).toBe(100);
});
it('should combine faces of the same person into a single entry', () => {
const face1 = {
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const face2 = {
boundingBoxX1: 300,
boundingBoxY1: 300,
boundingBoxX2: 400,
boundingBoxY2: 400,
imageWidth: 1000,
imageHeight: 800,
};
const person = PersonFactory.create();
const asset = AssetFactory.from()
.face(face1, (builder) => builder.person(person))
.face(face2, (builder) => builder.person(person))
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.build();
const result = mapAsset(getForAsset(asset));
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(1);
expect(result.people![0].id).toBe(person.id);
expect(result.people![0].faces).toHaveLength(2);
});
});
});
+9 -35
View File
@@ -5,13 +5,7 @@ import { HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { ExifResponseSchema, mapExif } from 'src/dtos/exif.dto';
import {
AssetFaceWithoutPersonResponseSchema,
PersonWithFacesResponseDto,
PersonWithFacesResponseSchema,
mapFacesWithoutPerson,
mapPerson,
} from 'src/dtos/person.dto';
import { PersonResponseDto, PersonResponseSchema, mapPerson } from 'src/dtos/person.dto';
import { TagResponseSchema, mapTag } from 'src/dtos/tag.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import {
@@ -22,8 +16,7 @@ import {
AssetVisibilitySchema,
ChecksumAlgorithm,
} from 'src/enum';
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { MaybeDehydrated } from 'src/types';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { asDateString } from 'src/utils/date';
import { mimeTypes } from 'src/utils/mime-types';
@@ -107,8 +100,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
visibility: AssetVisibilitySchema,
exifInfo: ExifResponseSchema.optional(),
tags: z.array(TagResponseSchema).optional(),
people: z.array(PersonWithFacesResponseSchema).optional(),
unassignedFaces: z.array(AssetFaceWithoutPersonResponseSchema).optional(),
people: z.array(PersonResponseSchema).optional(),
checksum: z.string().describe('Base64 encoded SHA1 hash'),
stack: AssetStackResponseSchema.nullish(),
duplicateId: z.string().nullish().describe('Duplicate group ID'),
@@ -170,33 +162,20 @@ export type AssetMapOptions = {
auth?: AuthDto;
};
const peopleWithFaces = (
faces?: MaybeDehydrated<AssetFace>[],
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): PersonWithFacesResponseDto[] => {
const peopleFromFaces = (faces?: MaybeDehydrated<AssetFace>[]): PersonResponseDto[] => {
if (!faces) {
return [];
}
const peopleFaces: Map<string, PersonWithFacesResponseDto> = new Map();
const peopleMap: Map<string, PersonResponseDto> = new Map();
for (const face of faces) {
if (!face.person) {
continue;
if (face.person && !peopleMap.has(face.person.id)) {
peopleMap.set(face.person.id, mapPerson(face.person));
}
if (!peopleFaces.has(face.person.id)) {
peopleFaces.set(face.person.id, {
...mapPerson(face.person),
faces: [],
});
}
const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions);
peopleFaces.get(face.person.id)!.faces.push(mappedFace);
}
return [...peopleFaces.values()];
return [...peopleMap.values()];
};
const mapStack = (entity: { stack?: Stack | null }) => {
@@ -230,8 +209,6 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
return sanitizedAssetResponse as AssetResponseDto;
}
const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined;
return {
id: entity.id,
createdAt: asDateString(entity.createdAt),
@@ -255,10 +232,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
unassignedFaces: entity.faces
?.filter((face) => !face.person)
.map((face) => mapFacesWithoutPerson(face, entity.edits, assetDimensions)),
people: peopleFromFaces(entity.faces),
checksum: hexOrBufferToBase64(entity.checksum)!,
stack: withStack ? mapStack(entity) : undefined,
isOffline: entity.isOffline,
+7 -18
View File
@@ -56,7 +56,7 @@ const PersonSearchSchema = z
})
.meta({ id: 'PersonSearchDto' });
const PersonResponseSchema = z
export const PersonResponseSchema = z
.object({
id: z.string().describe('Person ID'),
name: z.string().describe('Person name'),
@@ -91,7 +91,7 @@ export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
export const AssetFaceWithoutPersonResponseSchema = z
export const AssetFaceResponseSchema = z
.object({
id: z.uuidv4().describe('Face ID'),
imageHeight: z.int().min(0).describe('Image height in pixels'),
@@ -101,21 +101,10 @@ export const AssetFaceWithoutPersonResponseSchema = z
boundingBoxY1: z.int().describe('Bounding box Y1 coordinate'),
boundingBoxY2: z.int().describe('Bounding box Y2 coordinate'),
sourceType: SourceTypeSchema.optional(),
person: PersonResponseSchema.nullable(),
})
.describe('Asset face without person')
.meta({ id: 'AssetFaceWithoutPersonResponseDto' });
class AssetFaceWithoutPersonResponseDto extends createZodDto(AssetFaceWithoutPersonResponseSchema) {}
export const PersonWithFacesResponseSchema = PersonResponseSchema.extend({
faces: z.array(AssetFaceWithoutPersonResponseSchema),
}).meta({ id: 'PersonWithFacesResponseDto' });
export class PersonWithFacesResponseDto extends createZodDto(PersonWithFacesResponseSchema) {}
const AssetFaceResponseSchema = AssetFaceWithoutPersonResponseSchema.extend({
person: PersonResponseSchema.nullable(),
}).meta({ id: 'AssetFaceResponseDto' });
.describe('Asset face with person')
.meta({ id: 'AssetFaceResponseDto' });
export class AssetFaceResponseDto extends createZodDto(AssetFaceResponseSchema) {}
@@ -193,11 +182,11 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
};
}
export function mapFacesWithoutPerson(
function mapFacesWithoutPerson(
face: MaybeDehydrated<Selectable<AssetFaceTable>>,
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceWithoutPersonResponseDto {
) {
return {
id: face.id,
...transformFaceBoundingBox(
+2
View File
@@ -75,6 +75,7 @@ const SyncAssetV1Schema = z
checksum: z.string().describe('Checksum'),
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
createdAt: isoDatetimeToDate.nullable().describe('Uploaded to Immich at'),
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
duration: z.string().nullable().describe('Duration'),
type: AssetTypeSchema,
@@ -99,6 +100,7 @@ const SyncAssetV2Schema = z
checksum: z.string().describe('Checksum'),
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
createdAt: isoDatetimeToDate.nullable().describe('Uploaded to Immich at'),
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
duration: z.int32().min(0).nullable().describe('Duration'),
type: AssetTypeSchema,
+1 -5
View File
@@ -7,7 +7,6 @@ import {
OcrConfigSchema,
} from 'src/dtos/model-config.dto';
import {
AudioCodec,
AudioCodecSchema,
ColorspaceSchema,
CQModeSchema,
@@ -65,10 +64,7 @@ const SystemConfigFFmpegSchema = z
targetVideoCodec: VideoCodecSchema,
acceptedVideoCodecs: z.array(VideoCodecSchema).describe('Accepted video codecs'),
targetAudioCodec: AudioCodecSchema,
acceptedAudioCodecs: z
.array(AudioCodecSchema)
.transform((value): AudioCodec[] => value.map((v) => (v === AudioCodec.Libopus ? AudioCodec.Opus : v)))
.describe('Accepted audio codecs'),
acceptedAudioCodecs: z.array(AudioCodecSchema).describe('Accepted audio codecs'),
acceptedContainers: z.array(VideoContainerSchema).describe('Accepted containers'),
targetResolution: z.string().describe('Target resolution'),
maxBitrate: z.string().describe('Max bitrate'),
+7 -1
View File
@@ -1,6 +1,6 @@
import { createZodDto } from 'nestjs-zod';
import { BBoxSchema } from 'src/dtos/bbox.dto';
import { AssetOrderSchema, AssetVisibilitySchema } from 'src/enum';
import { AssetOrderBySchema, AssetOrderSchema, AssetVisibilitySchema } from 'src/enum';
import { stringToBool } from 'src/validation';
import z from 'zod';
@@ -23,6 +23,9 @@ const TimeBucketQueryBaseSchema = z
order: AssetOrderSchema.optional().describe(
'Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)',
),
orderBy: AssetOrderBySchema.optional().describe(
'Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)',
),
visibility: AssetVisibilitySchema.optional().describe(
'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
),
@@ -82,6 +85,9 @@ const TimeBucketAssetResponseSchema = z
thumbhash: z
.array(z.string().nullable())
.describe('Array of BlurHash strings for generating asset previews (base64 encoded)'),
createdAt: z
.array(z.string())
.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())
+7 -2
View File
@@ -74,6 +74,13 @@ export enum AssetOrder {
export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' });
export enum AssetOrderBy {
TakenAt = 'takenAt',
CreatedAt = 'createdAt',
}
export const AssetOrderBySchema = z.enum(AssetOrderBy).describe('Asset sorting property').meta({ id: 'AssetOrderBy' });
export enum MemoryType {
/** pictures taken on this day X years ago */
OnThisDay = 'on_this_day',
@@ -454,8 +461,6 @@ export enum VideoSegmentCodec {
export enum AudioCodec {
Mp3 = 'mp3',
Aac = 'aac',
/** @deprecated Use `Opus` instead */
Libopus = 'libopus',
Opus = 'opus',
PcmS16le = 'pcm_s16le',
}
+18
View File
@@ -382,6 +382,7 @@ with
"asset"."ownerId",
"asset"."status",
asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
asset."createdAt" at time zone 'utc' as "createdAt",
encode("asset"."thumbhash", 'base64') as "thumbhash",
"asset_exif"."city",
"asset_exif"."country",
@@ -442,6 +443,7 @@ with
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
coalesce(array_agg("fileCreatedAt"), '{}') as "fileCreatedAt",
coalesce(array_agg("localOffsetHours"), '{}') as "localOffsetHours",
coalesce(array_agg("createdAt"), '{}') as "createdAt",
coalesce(array_agg("ownerId"), '{}') as "ownerId",
coalesce(array_agg("projectionType"), '{}') as "projectionType",
coalesce(array_agg("ratio"), '{}') as "ratio",
@@ -485,6 +487,22 @@ where
limit
$5
-- AssetRepository.getRecentlyCreatedAssetIds
select
"id" as "data",
"createdAt" as "value"
from
"asset"
where
"ownerId" = $1::uuid
and "asset"."visibility" = $2
and "type" = $3
and "deletedAt" is null
order by
"value" desc
limit
$4
-- AssetRepository.detectOfflineExternalAssets
update "asset"
set
+6
View File
@@ -65,6 +65,7 @@ select
"asset"."checksum",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
@@ -98,6 +99,7 @@ select
"asset"."checksum",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
@@ -133,6 +135,7 @@ select
"asset"."checksum",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
@@ -407,6 +410,7 @@ select
"asset"."checksum",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
@@ -737,6 +741,7 @@ select
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."localDateTime",
"asset"."createdAt",
"asset"."type",
"asset"."deletedAt",
"asset"."visibility",
@@ -789,6 +794,7 @@ select
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."localDateTime",
"asset"."createdAt",
"asset"."type",
"asset"."deletedAt",
"asset"."visibility",
+28 -4
View File
@@ -17,7 +17,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -89,6 +89,7 @@ interface AssetBuilderOptions {
export interface TimeBucketOptions extends AssetBuilderOptions {
order?: AssetOrder;
orderBy?: AssetOrderBy;
}
export interface TimeBucketItem {
@@ -711,7 +712,7 @@ export class AssetRepository {
.with('asset', (qb) =>
qb
.selectFrom('asset')
.select(truncatedDate<Date>().as('timeBucket'))
.select(truncatedDate<Date>(options.orderBy).as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(!!options.bbox, (qb) => {
@@ -783,6 +784,7 @@ export class AssetRepository {
'asset.ownerId',
'asset.status',
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
sql`asset."createdAt" at time zone 'utc'`.as('createdAt'),
eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
'asset_exif.city',
'asset_exif.country',
@@ -815,7 +817,7 @@ export class AssetRepository {
return withBoundingBox(withBoundingCircle, bbox);
})
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, ''))
.where(truncatedDate(options.orderBy), '=', timeBucket.replace(/^[+-]/, ''))
.$if(!!options.albumId, (qb) =>
qb.where((eb) =>
eb.exists(
@@ -861,7 +863,12 @@ export class AssetRepository {
)
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order)
.orderBy(
options.orderBy == AssetOrderBy.CreatedAt
? sql`"createdAt"`
: sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`,
order,
)
.orderBy('asset.fileCreatedAt', order),
)
.with('agg', (qb) =>
@@ -880,6 +887,7 @@ export class AssetRepository {
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
eb.fn.coalesce(eb.fn('array_agg', ['fileCreatedAt']), sql.lit('{}')).as('fileCreatedAt'),
eb.fn.coalesce(eb.fn('array_agg', ['localOffsetHours']), sql.lit('{}')).as('localOffsetHours'),
eb.fn.coalesce(eb.fn('array_agg', ['createdAt']), sql.lit('{}')).as('createdAt'),
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
@@ -929,6 +937,22 @@ export class AssetRepository {
return { fieldName: 'exifInfo.city', items };
}
@GenerateSql({ params: [DummyValue.UUID, 12] })
async getRecentlyCreatedAssetIds(ownerId: string, maxAssets: number) {
const items = await this.db
.selectFrom('asset')
.select(['id as data', 'createdAt as value'])
.where('ownerId', '=', asUuid(ownerId))
.where('asset.visibility', '=', AssetVisibility.Timeline)
.where('type', '=', AssetType.Image)
.where('deletedAt', 'is', null)
.orderBy('value', 'desc')
.limit(maxAssets)
.execute();
return { fieldName: 'createdAt', items };
}
async upsertFile(
file: Pick<
Insertable<AssetFileTable>,
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import { BinaryField, DefaultReadTaskOptions, ExifTool, ReadTaskOptions, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { mimeTypes } from 'src/utils/mime-types';
@@ -89,7 +89,7 @@ export class MetadataRepository {
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
geolocation: true,
// Enable exiftool LFS to parse metadata for files larger than 2GB.
readArgs: ['-api', 'largefilesupport=1'],
readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'],
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
taskTimeoutMillis: 2 * 60 * 1000,
});
@@ -107,8 +107,8 @@ export class MetadataRepository {
}
readTags(path: string): Promise<ImmichTags> {
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
return this.exiftool.read(path, { readArgs: args }).catch((error) => {
const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined;
return this.exiftool.read(path, options).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {};
}) as Promise<ImmichTags>;
+2
View File
@@ -110,6 +110,7 @@ export class JobService extends BaseService {
checksum: hexOrBufferToBase64(asset.checksum),
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
localDateTime: asset.localDateTime,
duration: asset.duration,
type: asset.type,
@@ -166,6 +167,7 @@ export class JobService extends BaseService {
checksum: hexOrBufferToBase64(asset.checksum),
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
localDateTime: asset.localDateTime,
duration: asset.duration,
type: asset.type,
+28 -61
View File
@@ -2698,9 +2698,11 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
it('should set options for nvenc', async () => {
it('should set options for nvenc sw decode', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc } });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: false },
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2758,7 +2760,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device', 'cuda=cuda:0', '-filter_hw_device', 'cuda']),
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]),
twoPass: false,
}),
@@ -2775,7 +2777,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device', 'cuda=cuda:0', '-filter_hw_device', 'cuda']),
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-cq:v', '23', '-maxrate', '10000k', '-bufsize', '6897k']),
twoPass: false,
}),
@@ -2792,7 +2794,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device', 'cuda=cuda:0', '-filter_hw_device', 'cuda']),
inputOptions: expect.any(Array),
outputOptions: expect.not.stringContaining('-maxrate'),
twoPass: false,
}),
@@ -2809,7 +2811,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device', 'cuda=cuda:0', '-filter_hw_device', 'cuda']),
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]),
twoPass: false,
}),
@@ -2824,7 +2826,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device', 'cuda=cuda:0', '-filter_hw_device', 'cuda']),
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]),
twoPass: false,
}),
@@ -2894,10 +2896,10 @@ describe(MediaService.name, () => {
);
});
it('should set options for qsv', async () => {
it('should set options for qsv with sw decode', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, maxBitrate: '10000k' },
ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, maxBitrate: '10000k', accelDecode: false },
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
@@ -2947,13 +2949,14 @@ describe(MediaService.name, () => {
);
});
it('should set options for qsv with custom dri node', async () => {
it('should set options for qsv with custom dri node with sw decode', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: {
accel: TranscodeHardwareAcceleration.Qsv,
maxBitrate: '10000k',
preferredHwDevice: '/dev/dri/renderD128',
accelDecode: false,
},
});
await sut.handleVideoConversion({ id: 'video-id' });
@@ -2983,12 +2986,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'qsv=hw,child_device=/dev/dri/renderD128',
'-filter_hw_device',
'hw',
]),
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]),
twoPass: false,
}),
@@ -3005,12 +3003,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'qsv=hw,child_device=/dev/dri/renderD128',
'-filter_hw_device',
'hw',
]),
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-low_power', '1']),
twoPass: false,
}),
@@ -3036,12 +3029,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'qsv=hw,child_device=/dev/dri/renderD129',
'-filter_hw_device',
'hw',
]),
inputOptions: expect.arrayContaining(['-qsv_device', '/dev/dri/renderD129']),
outputOptions: expect.arrayContaining(['-c:v', 'h264_qsv']),
twoPass: false,
}),
@@ -3158,9 +3146,11 @@ describe(MediaService.name, () => {
);
});
it('should set options for vaapi', async () => {
it('should set options for sw decode vaapi', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: false },
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -3211,12 +3201,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device',
'accel',
]),
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
'-c:v',
'h264_vaapi',
@@ -3242,12 +3227,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device',
'accel',
]),
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
'-c:v',
'h264_vaapi',
@@ -3275,12 +3255,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device',
'accel',
]),
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]),
twoPass: false,
}),
@@ -3296,12 +3271,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'vaapi=accel:/dev/dri/renderD129',
'-filter_hw_device',
'accel',
]),
inputOptions: expect.arrayContaining(['-hwaccel_device', '/dev/dri/renderD129']),
outputOptions: expect.arrayContaining(['-c:v', 'h264_vaapi']),
twoPass: false,
}),
@@ -3319,12 +3289,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device',
'vaapi=accel:/dev/dri/renderD128',
'-filter_hw_device',
'accel',
]),
inputOptions: expect.arrayContaining(['-hwaccel_device', '/dev/dri/renderD128']),
outputOptions: expect.arrayContaining(['-c:v', 'h264_vaapi']),
twoPass: false,
}),
@@ -3481,7 +3446,9 @@ describe(MediaService.name, () => {
it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: false },
});
mocks.media.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
+9 -1
View File
@@ -65,7 +65,7 @@ describe(SearchService.name, () => {
});
describe('getExploreData', () => {
it('should get assets by city and tag', async () => {
it('should get recent assets and assets by city and tag', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
@@ -74,9 +74,17 @@ describe(SearchService.name, () => {
fieldName: 'exifInfo.city',
items: [{ value: 'city', data: asset.id }],
});
mocks.asset.getRecentlyCreatedAssetIds.mockResolvedValue({
fieldName: 'createdAt',
items: [{ value: asset.createdAt, data: asset.id }],
});
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] },
{
fieldName: 'createdAt',
items: [{ value: asset.createdAt.toISOString(), data: mapAsset(getForAsset(asset)) }],
},
];
const result = await sut.getExploreData(auth);
+19 -3
View File
@@ -40,10 +40,26 @@ export class SearchService extends BaseService {
async getExploreData(auth: AuthDto) {
const options = { maxFields: 12, minAssetsPerField: 5 };
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));
const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
return [{ fieldName: cities.fieldName, items }];
const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
cities.items.map(({ data }) => data),
);
const cityItems = cityAssets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
const recents = await this.assetRepository.getRecentlyCreatedAssetIds(auth.user.id, options.maxFields);
const recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
recents.items.map((item) => item.data),
);
const recentItems = recentAssets.map((asset) => ({
value: asset.createdAt.toISOString(),
data: mapAsset(asset, { auth }),
}));
return [
{ fieldName: cities.fieldName, items: cityItems },
{ fieldName: recents.fieldName, items: recentItems },
];
}
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
@@ -70,7 +70,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
preferredHwDevice: 'auto',
transcode: TranscodePolicy.Required,
accel: TranscodeHardwareAcceleration.Disabled,
accelDecode: false,
accelDecode: true,
tonemap: ToneMapping.Hable,
},
logging: {
+3 -3
View File
@@ -17,7 +17,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -298,8 +298,8 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
).as('tags');
}
export function truncatedDate<O>() {
return sql<O>`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
}
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
@@ -118,6 +118,7 @@ describe(TimelineService.name, () => {
expect(response).toEqual({
city: [],
country: [],
createdAt: [],
duration: [],
id: [],
visibility: [],
@@ -47,6 +47,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
createdAt: date,
deletedAt: null,
duration: 600_000,
livePhotoVideoId: null,
@@ -73,6 +74,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
@@ -34,6 +34,7 @@ describe(SyncEntityType.AssetV2, () => {
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
createdAt: date,
deletedAt: null,
duration: 600_000,
libraryId: null,
@@ -54,6 +55,7 @@ describe(SyncEntityType.AssetV2, () => {
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
@@ -38,6 +38,7 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
createdAt: date,
deletedAt: null,
duration: 600_000,
libraryId: null,
@@ -58,6 +59,7 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
deletedAt: null,
fileCreatedAt: date,
fileModifiedAt: date,
createdAt: date,
isFavorite: false,
localDateTime: date,
type: asset.type,
@@ -31,6 +31,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getTimeBucket: vitest.fn(),
getTimeBuckets: vitest.fn(),
getAssetIdByCity: vitest.fn(),
getRecentlyCreatedAssetIds: vitest.fn(),
upsertFile: vitest.fn(),
upsertFiles: vitest.fn(),
deleteFile: vitest.fn(),