mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
chore: merge main into feat/hero_view_transitions
Change-Id: I6e6316f66343b8f3ea9fe33ed3f8f3e56a6a6964
This commit is contained in:
+11
-12
@@ -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,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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user