refactor: remove album entity, update types (#17450)

This commit is contained in:
Daniel Dietzler 2025-04-18 23:10:34 +02:00 committed by GitHub
parent 854ea13d6a
commit 52ae06c119
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 473 additions and 396 deletions

View File

@ -1,13 +1,14 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { AssetJobStatus as DatabaseAssetJobStatus, Exif as DatabaseExif } from 'src/db'; import { Albums, AssetJobStatus as DatabaseAssetJobStatus, Exif as DatabaseExif } from 'src/db';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { import {
AlbumUserRole, AlbumUserRole,
AssetFileType, AssetFileType,
AssetStatus,
AssetType, AssetType,
MemoryType, MemoryType,
Permission, Permission,
SharedLinkType,
SourceType, SourceType,
UserStatus, UserStatus,
} from 'src/enum'; } from 'src/enum';
@ -44,7 +45,7 @@ export type Library = {
exclusionPatterns: string[]; exclusionPatterns: string[];
deletedAt: Date | null; deletedAt: Date | null;
refreshedAt: Date | null; refreshedAt: Date | null;
assets?: Asset[]; assets?: MapAsset[];
}; };
export type AuthApiKey = { export type AuthApiKey = {
@ -96,7 +97,26 @@ export type Memory = {
data: OnThisDayData; data: OnThisDayData;
ownerId: string; ownerId: string;
isSaved: boolean; isSaved: boolean;
assets: Asset[]; assets: MapAsset[];
};
export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
isExternal: boolean;
isVisible: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date;
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
type: AssetType;
}; };
export type User = { export type User = {
@ -128,39 +148,6 @@ export type StorageAsset = {
encodedVideoPath: string | null; encodedVideoPath: string | null;
}; };
export type Asset = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
id: string;
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
encodedVideoPath: string | null;
fileCreatedAt: Date | null;
fileModifiedAt: Date | null;
isArchived: boolean;
isExternal: boolean;
isFavorite: boolean;
isOffline: boolean;
isVisible: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date | null;
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
stack?: Stack | null;
stackId: string | null;
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
};
export type SidecarWriteAsset = { export type SidecarWriteAsset = {
id: string; id: string;
sidecarPath: string | null; sidecarPath: string | null;
@ -173,7 +160,7 @@ export type Stack = {
primaryAssetId: string; primaryAssetId: string;
owner?: User; owner?: User;
ownerId: string; ownerId: string;
assets: AssetEntity[]; assets: MapAsset[];
assetCount?: number; assetCount?: number;
}; };
@ -187,6 +174,28 @@ export type AuthSharedLink = {
password: string | null; password: string | null;
}; };
export type SharedLink = {
id: string;
album?: Album | null;
albumId: string | null;
allowDownload: boolean;
allowUpload: boolean;
assets: MapAsset[];
createdAt: Date;
description: string | null;
expiresAt: Date | null;
key: Buffer;
password: string | null;
showExif: boolean;
type: SharedLinkType;
userId: string;
};
export type Album = Selectable<Albums> & {
owner: User;
assets: MapAsset[];
};
export type AuthSession = { export type AuthSession = {
id: string; id: string;
}; };

6
server/src/db.d.ts vendored
View File

@ -143,8 +143,8 @@ export interface Assets {
duplicateId: string | null; duplicateId: string | null;
duration: string | null; duration: string | null;
encodedVideoPath: Generated<string | null>; encodedVideoPath: Generated<string | null>;
fileCreatedAt: Timestamp | null; fileCreatedAt: Timestamp;
fileModifiedAt: Timestamp | null; fileModifiedAt: Timestamp;
id: Generated<string>; id: Generated<string>;
isArchived: Generated<boolean>; isArchived: Generated<boolean>;
isExternal: Generated<boolean>; isExternal: Generated<boolean>;
@ -153,7 +153,7 @@ export interface Assets {
isVisible: Generated<boolean>; isVisible: Generated<boolean>;
libraryId: string | null; libraryId: string | null;
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
localDateTime: Timestamp | null; localDateTime: Timestamp;
originalFileName: string; originalFileName: string;
originalPath: string; originalPath: string;
ownerId: string; ownerId: string;

View File

@ -2,10 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsEnum, IsString, ValidateNested } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsEnum, IsString, ValidateNested } from 'class-validator';
import _ from 'lodash'; import _ from 'lodash';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { AlbumUserRole, AssetOrder } from 'src/enum'; import { AlbumUserRole, AssetOrder } from 'src/enum';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
@ -142,7 +142,23 @@ export class AlbumResponseDto {
order?: AssetOrder; order?: AssetOrder;
} }
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { export type MapAlbumDto = {
albumUsers?: AlbumUser[];
assets?: MapAsset[];
sharedLinks?: AuthSharedLink[];
albumName: string;
description: string;
albumThumbnailAssetId: string | null;
createdAt: Date;
updatedAt: Date;
id: string;
ownerId: string;
owner: User;
isActivityEnabled: boolean;
order: AssetOrder;
};
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = [];
if (entity.albumUsers) { if (entity.albumUsers) {
@ -159,7 +175,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
const assets = entity.assets || []; const assets = entity.assets || [];
const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
const hasSharedUser = albumUsers.length > 0; const hasSharedUser = albumUsers.length > 0;
let startDate = assets.at(0)?.localDateTime; let startDate = assets.at(0)?.localDateTime;
@ -190,5 +206,5 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
}; };
}; };
export const mapAlbumWithAssets = (entity: AlbumEntity) => mapAlbum(entity, true); export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true);
export const mapAlbumWithoutAssets = (entity: AlbumEntity) => mapAlbum(entity, false); export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false);

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { AssetFace } from 'src/database'; import { Selectable } from 'kysely';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
@ -11,8 +12,7 @@ import {
} from 'src/dtos/person.dto'; } from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus, AssetType } from 'src/enum';
import { AssetType } from 'src/enum';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
export class SanitizedAssetResponseDto { export class SanitizedAssetResponseDto {
@ -56,6 +56,44 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
resized?: boolean; resized?: boolean;
} }
export type MapAsset = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
id: string;
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
encodedVideoPath: string | null;
exifInfo?: Selectable<Exif> | null;
faces?: AssetFace[];
fileCreatedAt: Date;
fileModifiedAt: Date;
files?: AssetFile[];
isArchived: boolean;
isExternal: boolean;
isFavorite: boolean;
isOffline: boolean;
isVisible: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date;
originalFileName: string;
originalPath: string;
owner?: User | null;
ownerId: string;
sidecarPath: string | null;
stack?: Stack | null;
stackId: string | null;
tags?: Tag[];
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
};
export class AssetStackResponseDto { export class AssetStackResponseDto {
id!: string; id!: string;
@ -72,7 +110,7 @@ export type AssetMapOptions = {
}; };
// TODO: this is inefficient // TODO: this is inefficient
const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => { const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = []; const result: PersonWithFacesResponseDto[] = [];
if (faces) { if (faces) {
for (const face of faces) { for (const face of faces) {
@ -90,7 +128,7 @@ const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => {
return result; return result;
}; };
const mapStack = (entity: AssetEntity) => { const mapStack = (entity: { stack?: Stack | null }) => {
if (!entity.stack) { if (!entity.stack) {
return null; return null;
} }
@ -111,7 +149,7 @@ export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
return encoded.toString('base64'); return encoded.toString('base64');
}; };
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options; const { stripMetadata = false, withStack = false } = options;
if (stripMetadata) { if (stripMetadata) {

View File

@ -4,7 +4,6 @@ import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-valid
import { Memory } from 'src/database'; import { Memory } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryType } from 'src/enum'; import { MemoryType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
@ -103,6 +102,6 @@ export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
type: entity.type as MemoryType, type: entity.type as MemoryType,
data: entity.data as unknown as MemoryData, data: entity.data as unknown as MemoryData,
isSaved: entity.isSaved, isSaved: entity.isSaved,
assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(asset as AssetEntity, { auth })), assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(asset, { auth })),
}; };
}; };

View File

@ -1,9 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator'; import { IsEnum, IsString } from 'class-validator';
import _ from 'lodash'; import _ from 'lodash';
import { SharedLink } from 'src/database';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
@ -102,7 +102,7 @@ export class SharedLinkResponseDto {
showMetadata!: boolean; showMetadata!: boolean;
} }
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
return { return {
@ -122,7 +122,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
}; };
} }
export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
@ -137,7 +137,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
type: sharedLink.type, type: sharedLink.type,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[], assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,

View File

@ -1,23 +0,0 @@
import { AlbumUser, User } from 'src/database';
import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { AssetOrder } from 'src/enum';
export class AlbumEntity {
id!: string;
owner!: User;
ownerId!: string;
albumName!: string;
description!: string;
createdAt!: Date;
updatedAt!: Date;
updateId?: string;
deletedAt!: Date | null;
albumThumbnailAsset!: AssetEntity | null;
albumThumbnailAssetId!: string | null;
albumUsers!: AlbumUser[];
assets!: AssetEntity[];
sharedLinks!: SharedLinkEntity[];
isActivityEnabled!: boolean;
order!: AssetOrder;
}

View File

@ -1,12 +1,12 @@
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { AssetFace, AssetFile, AssetJobStatus, columns, Exif, Stack, Tag, User } from 'src/database'; import { AssetFace, AssetFile, AssetJobStatus, columns, Exif, Person, Stack, Tag, User } from 'src/database';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { anyUuid, asUuid } from 'src/utils/database'; import { anyUuid, asUuid, toJson } from 'src/utils/database';
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
@ -37,13 +37,12 @@ export class AssetEntity {
checksum!: Buffer; // sha1 checksum checksum!: Buffer; // sha1 checksum
duration!: string | null; duration!: string | null;
isVisible!: boolean; isVisible!: boolean;
livePhotoVideo!: AssetEntity | null; livePhotoVideo!: MapAsset | null;
livePhotoVideoId!: string | null; livePhotoVideoId!: string | null;
originalFileName!: string; originalFileName!: string;
sidecarPath!: string | null; sidecarPath!: string | null;
exifInfo?: Exif; exifInfo?: Exif;
tags?: Tag[]; tags?: Tag[];
sharedLinks!: SharedLinkEntity[];
faces!: AssetFace[]; faces!: AssetFace[];
stackId?: string | null; stackId?: string | null;
stack?: Stack | null; stack?: Stack | null;
@ -51,6 +50,7 @@ export class AssetEntity {
duplicateId!: string | null; duplicateId!: string | null;
} }
// TODO come up with a better query that only selects the fields we need
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb return qb
.leftJoin('exif', 'assets.id', 'exif.assetId') .leftJoin('exif', 'assets.id', 'exif.assetId')
@ -66,7 +66,7 @@ export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb return qb
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId') .leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
.select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch')); .select((eb) => toJson(eb, 'smart_search').as('smartSearch'));
} }
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) { export function withFaces(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
@ -99,7 +99,7 @@ export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDele
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.selectAll('asset_faces') .selectAll('asset_faces')
.select((eb) => eb.table('person').as('person')) .select((eb) => eb.table('person').$castTo<Person>().as('person'))
.whereRef('asset_faces.assetId', '=', 'assets.id') .whereRef('asset_faces.assetId', '=', 'assets.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)), .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
).as('faces'); ).as('faces');
@ -136,13 +136,15 @@ export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: stri
} }
export function withOwner(eb: ExpressionBuilder<DB, 'assets'>) { export function withOwner(eb: ExpressionBuilder<DB, 'assets'>) {
return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'assets.ownerId')).as(
'owner',
);
} }
export function withLibrary(eb: ExpressionBuilder<DB, 'assets'>) { export function withLibrary(eb: ExpressionBuilder<DB, 'assets'>) {
return jsonObjectFrom(eb.selectFrom('libraries').selectAll().whereRef('libraries.id', '=', 'assets.libraryId')).as( return jsonObjectFrom(
'library', eb.selectFrom('libraries').selectAll('libraries').whereRef('libraries.id', '=', 'assets.libraryId'),
); ).as('library');
} }
export function withTags(eb: ExpressionBuilder<DB, 'assets'>) { export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {

View File

@ -1,20 +0,0 @@
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkType } from 'src/enum';
export class SharedLinkEntity {
id!: string;
description!: string | null;
password!: string | null;
userId!: string;
key!: Buffer; // use to access the inidividual asset
type!: SharedLinkType;
createdAt!: Date;
expiresAt!: Date | null;
allowUpload!: boolean;
allowDownload!: boolean;
showExif!: boolean;
assets!: AssetEntity[];
album?: AlbumEntity;
albumId!: string | null;
}

View File

@ -82,7 +82,7 @@ from
where where
"assets"."id" = any ($1::uuid[]) "assets"."id" = any ($1::uuid[])
-- AssetRepository.getByIdsWithAllRelations -- AssetRepository.getByIdsWithAllRelationsButStacks
select select
"assets".*, "assets".*,
( (
@ -127,28 +127,13 @@ select
"assets"."id" = "tag_asset"."assetsId" "assets"."id" = "tag_asset"."assetsId"
) as agg ) as agg
) as "tags", ) as "tags",
to_json("exif") as "exifInfo", to_json("exif") as "exifInfo"
to_json("stacked_assets") as "stack"
from from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
left join lateral (
select
"asset_stack".*,
array_agg("stacked") as "assets"
from
"assets" as "stacked"
where
"stacked"."stackId" = "asset_stack"."id"
and "stacked"."id" != "asset_stack"."primaryAssetId"
and "stacked"."deletedAt" is null
and "stacked"."isArchived" = $1
group by
"asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
where where
"assets"."id" = any ($2::uuid[]) "assets"."id" = any ($1::uuid[])
-- AssetRepository.deleteAll -- AssetRepository.deleteAll
delete from "assets" delete from "assets"

View File

@ -1,12 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns, Exif } from 'src/database';
import { Albums, DB } from 'src/db'; import { Albums, DB } from 'src/db';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumEntity } from 'src/entities/album.entity';
export interface AlbumAssetCount { export interface AlbumAssetCount {
albumId: string; albumId: string;
@ -21,9 +20,9 @@ export interface AlbumInfoOptions {
} }
const withOwner = (eb: ExpressionBuilder<DB, 'albums'>) => { const withOwner = (eb: ExpressionBuilder<DB, 'albums'>) => {
return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'albums.ownerId')).as( return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'albums.ownerId'))
'owner', .$notNull()
); .as('owner');
}; };
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'albums'>) => { const withAlbumUsers = (eb: ExpressionBuilder<DB, 'albums'>) => {
@ -32,12 +31,14 @@ const withAlbumUsers = (eb: ExpressionBuilder<DB, 'albums'>) => {
.selectFrom('albums_shared_users_users as album_users') .selectFrom('albums_shared_users_users as album_users')
.select('album_users.role') .select('album_users.role')
.select((eb) => .select((eb) =>
jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'album_users.usersId')).as( jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'album_users.usersId'))
'user', .$notNull()
), .as('user'),
) )
.whereRef('album_users.albumsId', '=', 'albums.id'), .whereRef('album_users.albumsId', '=', 'albums.id'),
).as('albumUsers'); )
.$notNull()
.as('albumUsers');
}; };
const withSharedLink = (eb: ExpressionBuilder<DB, 'albums'>) => { const withSharedLink = (eb: ExpressionBuilder<DB, 'albums'>) => {
@ -53,7 +54,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.leftJoin('exif', 'assets.id', 'exif.assetId') .leftJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.table('exif').as('exifInfo')) .select((eb) => eb.table('exif').$castTo<Exif>().as('exifInfo'))
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
@ -69,7 +70,7 @@ export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] }) @GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | undefined> { async getById(id: string, options: AlbumInfoOptions) {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
@ -79,11 +80,12 @@ export class AlbumRepository {
.select(withAlbumUsers) .select(withAlbumUsers)
.select(withSharedLink) .select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets)) .$if(options.withAssets, (eb) => eb.select(withAssets))
.executeTakeFirst() as Promise<AlbumEntity | undefined>; .$narrowType<{ assets: NotNull }>()
.executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> { async getByAssetId(ownerId: string, assetId: string) {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
@ -105,7 +107,7 @@ export class AlbumRepository {
.select(withOwner) .select(withOwner)
.select(withAlbumUsers) .select(withAlbumUsers)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>; .execute();
} }
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
@ -134,7 +136,7 @@ export class AlbumRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getOwned(ownerId: string): Promise<AlbumEntity[]> { async getOwned(ownerId: string) {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
@ -144,14 +146,14 @@ export class AlbumRepository {
.where('albums.ownerId', '=', ownerId) .where('albums.ownerId', '=', ownerId)
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>; .execute();
} }
/** /**
* Get albums shared with and shared by owner. * Get albums shared with and shared by owner.
*/ */
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getShared(ownerId: string): Promise<AlbumEntity[]> { async getShared(ownerId: string) {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
@ -176,14 +178,14 @@ export class AlbumRepository {
.select(withOwner) .select(withOwner)
.select(withSharedLink) .select(withSharedLink)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>; .execute();
} }
/** /**
* Get albums of owner that are _not_ shared * Get albums of owner that are _not_ shared
*/ */
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getNotShared(ownerId: string): Promise<AlbumEntity[]> { async getNotShared(ownerId: string) {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
@ -203,7 +205,7 @@ export class AlbumRepository {
) )
.select(withOwner) .select(withOwner)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>; .execute();
} }
async restoreAll(userId: string): Promise<void> { async restoreAll(userId: string): Promise<void> {
@ -262,7 +264,7 @@ export class AlbumRepository {
await this.addAssets(this.db, albumId, assetIds); await this.addAssets(this.db, albumId, assetIds);
} }
create(album: Insertable<Albums>, assetIds: string[], albumUsers: AlbumUserCreateDto[]): Promise<AlbumEntity> { create(album: Insertable<Albums>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
return this.db.transaction().execute(async (tx) => { return this.db.transaction().execute(async (tx) => {
const newAlbum = await tx.insertInto('albums').values(album).returning('albums.id').executeTakeFirst(); const newAlbum = await tx.insertInto('albums').values(album).returning('albums.id').executeTakeFirst();
@ -290,11 +292,12 @@ export class AlbumRepository {
.select(withOwner) .select(withOwner)
.select(withAssets) .select(withAssets)
.select(withAlbumUsers) .select(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>; .$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
}); });
} }
update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> { update(id: string, album: Updateable<Albums>) {
return this.db return this.db
.updateTable('albums') .updateTable('albums')
.set(album) .set(album)
@ -303,7 +306,7 @@ export class AlbumRepository {
.returning(withOwner) .returning(withOwner)
.returning(withSharedLink) .returning(withSharedLink)
.returning(withAlbumUsers) .returning(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>; .executeTakeFirstOrThrow();
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {

View File

@ -8,7 +8,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { withExifInner, withFaces, withFiles } from 'src/entities/asset.entity'; import { withExifInner, withFaces, withFiles } from 'src/entities/asset.entity';
import { AssetFileType } from 'src/enum'; import { AssetFileType } from 'src/enum';
import { StorageAsset } from 'src/types'; import { StorageAsset } from 'src/types';
import { asUuid } from 'src/utils/database'; import { anyUuid, asUuid } from 'src/utils/database';
@Injectable() @Injectable()
export class AssetJobRepository { export class AssetJobRepository {
@ -149,6 +149,21 @@ export class AssetJobRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
getForSyncAssets(ids: string[]) {
return this.db
.selectFrom('assets')
.select([
'assets.id',
'assets.isOffline',
'assets.libraryId',
'assets.originalPath',
'assets.status',
'assets.fileModifiedAt',
])
.where('assets.id', '=', anyUuid(ids))
.execute();
}
private storageTemplateAssetQuery() { private storageTemplateAssetQuery() {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')

View File

@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely'; import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { isEmpty, isUndefined, omitBy } from 'lodash'; import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { import {
AssetEntity, AssetEntity,
hasPeople, hasPeople,
@ -23,7 +25,7 @@ import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository'; import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository';
import { anyUuid, asUuid, removeUndefinedKeys, unnest } from 'src/utils/database'; import { anyUuid, asUuid, removeUndefinedKeys, unnest } from 'src/utils/database';
import { globToSqlPattern } from 'src/utils/misc'; import { globToSqlPattern } from 'src/utils/misc';
import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; import { PaginationOptions, paginationHelper } from 'src/utils/pagination';
export type AssetStats = Record<AssetType, number>; export type AssetStats = Record<AssetType, number>;
@ -141,12 +143,12 @@ export interface GetByIdsRelations {
export interface DuplicateGroup { export interface DuplicateGroup {
duplicateId: string; duplicateId: string;
assets: AssetEntity[]; assets: MapAsset[];
} }
export interface DayOfYearAssets { export interface DayOfYearAssets {
yearsAgo: number; yearsAgo: number;
assets: AssetEntity[]; assets: MapAsset[];
} }
@Injectable() @Injectable()
@ -234,12 +236,12 @@ export class AssetRepository {
.execute(); .execute();
} }
create(asset: Insertable<Assets>): Promise<AssetEntity> { create(asset: Insertable<Assets>) {
return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise<AssetEntity>; return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirstOrThrow();
} }
createAll(assets: Insertable<Assets>[]): Promise<AssetEntity[]> { createAll(assets: Insertable<Assets>[]) {
return this.db.insertInto('assets').values(assets).returningAll().execute() as any as Promise<AssetEntity[]>; return this.db.insertInto('assets').values(assets).returningAll().execute();
} }
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
@ -299,20 +301,13 @@ export class AssetRepository {
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray() @ChunkedArray()
getByIds(ids: string[]): Promise<AssetEntity[]> { getByIds(ids: string[]) {
return ( return this.db.selectFrom('assets').selectAll('assets').where('assets.id', '=', anyUuid(ids)).execute();
this.db
//
.selectFrom('assets')
.selectAll('assets')
.where('assets.id', '=', anyUuid(ids))
.execute() as Promise<AssetEntity[]>
);
} }
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray() @ChunkedArray()
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]> { getByIdsWithAllRelationsButStacks(ids: string[]) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
@ -320,23 +315,8 @@ export class AssetRepository {
.select(withTags) .select(withTags)
.$call(withExif) .$call(withExif)
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('assets as stacked')
.selectAll('asset_stack')
.select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets'))
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId')
.where('stacked.deletedAt', 'is', null)
.where('stacked.isArchived', '=', false)
.groupBy('asset_stack.id')
.as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
.where('assets.id', '=', anyUuid(ids)) .where('assets.id', '=', anyUuid(ids))
.execute() as any as Promise<AssetEntity[]>; .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
@ -356,36 +336,29 @@ export class AssetRepository {
return assets.map((asset) => asset.deviceAssetId); return assets.map((asset) => asset.deviceAssetId);
} }
getByUserId( getByUserId(pagination: PaginationOptions, userId: string, options: Omit<AssetSearchOptions, 'userIds'> = {}) {
pagination: PaginationOptions,
userId: string,
options: Omit<AssetSearchOptions, 'userIds'> = {},
): Paginated<AssetEntity> {
return this.getAll(pagination, { ...options, userIds: [userId] }); return this.getAll(pagination, { ...options, userIds: [userId] });
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | undefined> { getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.where('libraryId', '=', asUuid(libraryId)) .where('libraryId', '=', asUuid(libraryId))
.where('originalPath', '=', originalPath) .where('originalPath', '=', originalPath)
.limit(1) .limit(1)
.executeTakeFirst() as any as Promise<AssetEntity | undefined>; .executeTakeFirst();
} }
async getAll( async getAll(pagination: PaginationOptions, { orderDirection, ...options }: AssetSearchOptions = {}) {
pagination: PaginationOptions,
{ orderDirection, ...options }: AssetSearchOptions = {},
): Paginated<AssetEntity> {
const builder = searchAssetBuilder(this.db, options) const builder = searchAssetBuilder(this.db, options)
.select(withFiles) .select(withFiles)
.orderBy('assets.createdAt', orderDirection ?? 'asc') .orderBy('assets.createdAt', orderDirection ?? 'asc')
.limit(pagination.take + 1) .limit(pagination.take + 1)
.offset(pagination.skip ?? 0); .offset(pagination.skip ?? 0);
const items = await builder.execute(); const items = await builder.execute();
return paginationHelper(items as any as AssetEntity[], pagination.take); return paginationHelper(items, pagination.take);
} }
/** /**
@ -420,23 +393,22 @@ export class AssetRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getById( getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) {
id: string,
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {},
): Promise<AssetEntity | undefined> {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.where('assets.id', '=', asUuid(id)) .where('assets.id', '=', asUuid(id))
.$if(!!exifInfo, withExif) .$if(!!exifInfo, withExif)
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces)) .$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>())
.$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner)) .$if(!!owner, (qb) => qb.select(withOwner))
.$if(!!smartSearch, withSmartSearch) .$if(!!smartSearch, withSmartSearch)
.$if(!!stack, (qb) => .$if(!!stack, (qb) =>
qb qb
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
.$if(!stack!.assets, (qb) => qb.select((eb) => eb.fn.toJson(eb.table('asset_stack')).as('stack'))) .$if(!stack!.assets, (qb) =>
qb.select((eb) => eb.fn.toJson(eb.table('asset_stack')).$castTo<Stack | null>().as('stack')),
)
.$if(!!stack!.assets, (qb) => .$if(!!stack!.assets, (qb) =>
qb qb
.leftJoinLateral( .leftJoinLateral(
@ -453,13 +425,13 @@ export class AssetRepository {
.as('stacked_assets'), .as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null), (join) => join.on('asset_stack.id', 'is not', null),
) )
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack')),
), ),
) )
.$if(!!files, (qb) => qb.select(withFiles)) .$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags)) .$if(!!tags, (qb) => qb.select(withTags))
.limit(1) .limit(1)
.executeTakeFirst() as any as Promise<AssetEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] }) @GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
@ -488,7 +460,7 @@ export class AssetRepository {
.execute(); .execute();
} }
async update(asset: Updateable<Assets> & { id: string }): Promise<AssetEntity> { async update(asset: Updateable<Assets> & { id: string }) {
const value = omitBy(asset, isUndefined); const value = omitBy(asset, isUndefined);
delete value.id; delete value.id;
if (!isEmpty(value)) { if (!isEmpty(value)) {
@ -498,10 +470,10 @@ export class AssetRepository {
.selectAll('assets') .selectAll('assets')
.$call(withExif) .$call(withExif)
.$call((qb) => qb.select(withFacesAndPeople)) .$call((qb) => qb.select(withFacesAndPeople))
.executeTakeFirst() as Promise<AssetEntity>; .executeTakeFirst();
} }
return this.getById(asset.id, { exifInfo: true, faces: { person: true } }) as Promise<AssetEntity>; return this.getById(asset.id, { exifInfo: true, faces: { person: true } });
} }
async remove(asset: { id: string }): Promise<void> { async remove(asset: { id: string }): Promise<void> {
@ -509,7 +481,7 @@ export class AssetRepository {
} }
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] }) @GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] })
getByChecksum({ ownerId, libraryId, checksum }: AssetGetByChecksumOptions): Promise<AssetEntity | undefined> { getByChecksum({ ownerId, libraryId, checksum }: AssetGetByChecksumOptions) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
@ -517,7 +489,7 @@ export class AssetRepository {
.where('checksum', '=', checksum) .where('checksum', '=', checksum)
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null))) .$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
.limit(1) .limit(1)
.executeTakeFirst() as Promise<AssetEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.BUFFER]] }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.BUFFER]] })
@ -544,7 +516,7 @@ export class AssetRepository {
return asset?.id; return asset?.id;
} }
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | undefined> { findLivePhotoMatch(options: LivePhotoSearchOptions) {
const { ownerId, otherAssetId, livePhotoCID, type } = options; const { ownerId, otherAssetId, livePhotoCID, type } = options;
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
@ -555,7 +527,7 @@ export class AssetRepository {
.where('type', '=', type) .where('type', '=', type)
.where('exif.livePhotoCID', '=', livePhotoCID) .where('exif.livePhotoCID', '=', livePhotoCID)
.limit(1) .limit(1)
.executeTakeFirst() as Promise<AssetEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql( @GenerateSql(
@ -564,7 +536,7 @@ export class AssetRepository {
params: [DummyValue.PAGINATION, property], params: [DummyValue.PAGINATION, property],
})), })),
) )
async getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity> { async getWithout(pagination: PaginationOptions, property: WithoutProperty) {
const items = await this.db const items = await this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
@ -626,7 +598,7 @@ export class AssetRepository {
.orderBy('createdAt') .orderBy('createdAt')
.execute(); .execute();
return paginationHelper(items as any as AssetEntity[], pagination.take); return paginationHelper(items, pagination.take);
} }
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> { getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
@ -645,7 +617,7 @@ export class AssetRepository {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
getRandom(userIds: string[], take: number): Promise<AssetEntity[]> { getRandom(userIds: string[], take: number) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
@ -655,7 +627,7 @@ export class AssetRepository {
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy((eb) => eb.fn('random')) .orderBy((eb) => eb.fn('random'))
.limit(take) .limit(take)
.execute() as any as Promise<AssetEntity[]>; .execute();
} }
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
@ -708,7 +680,7 @@ export class AssetRepository {
} }
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] })
async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> { async getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
@ -741,7 +713,7 @@ export class AssetRepository {
.as('stacked_assets'), .as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null), (join) => join.on('asset_stack.id', 'is not', null),
) )
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')),
) )
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) => .$if(options.isDuplicate !== undefined, (qb) =>
@ -753,11 +725,11 @@ export class AssetRepository {
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, '')) .where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
.orderBy('assets.localDateTime', options.order ?? 'desc') .orderBy('assets.localDateTime', options.order ?? 'desc')
.execute() as any as Promise<AssetEntity[]>; .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getDuplicates(userId: string): Promise<DuplicateGroup[]> { getDuplicates(userId: string) {
return ( return (
this.db this.db
.with('duplicates', (qb) => .with('duplicates', (qb) =>
@ -774,9 +746,15 @@ export class AssetRepository {
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.select('assets.duplicateId') .select('assets.duplicateId')
.select((eb) => eb.fn('jsonb_agg', [eb.table('asset')]).as('assets')) .select((eb) =>
eb
.fn('jsonb_agg', [eb.table('asset')])
.$castTo<MapAsset[]>()
.as('assets'),
)
.where('assets.ownerId', '=', asUuid(userId)) .where('assets.ownerId', '=', asUuid(userId))
.where('assets.duplicateId', 'is not', null) .where('assets.duplicateId', 'is not', null)
.$narrowType<{ duplicateId: NotNull }>()
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.stackId', 'is', null) .where('assets.stackId', 'is', null)
@ -801,7 +779,7 @@ export class AssetRepository {
.where(({ not, exists }) => .where(({ not, exists }) =>
not(exists((eb) => eb.selectFrom('unique').whereRef('unique.duplicateId', '=', 'duplicates.duplicateId'))), not(exists((eb) => eb.selectFrom('unique').whereRef('unique.duplicateId', '=', 'duplicates.duplicateId'))),
) )
.execute() as any as Promise<DuplicateGroup[]> .execute()
); );
} }
@ -845,7 +823,7 @@ export class AssetRepository {
}, },
], ],
}) })
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]> { getAllForUserFullSync(options: AssetFullSyncOptions) {
const { ownerId, lastId, updatedUntil, limit } = options; const { ownerId, lastId, updatedUntil, limit } = options;
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
@ -863,18 +841,18 @@ export class AssetRepository {
.as('stacked_assets'), .as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null), (join) => join.on('asset_stack.id', 'is not', null),
) )
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack'))
.where('assets.ownerId', '=', asUuid(ownerId)) .where('assets.ownerId', '=', asUuid(ownerId))
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.updatedAt', '<=', updatedUntil) .where('assets.updatedAt', '<=', updatedUntil)
.$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!)) .$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!))
.orderBy('assets.id') .orderBy('assets.id')
.limit(limit) .limit(limit)
.execute() as any as Promise<AssetEntity[]>; .execute();
} }
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE, limit: 100 }] }) @GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE, limit: 100 }] })
async getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]> { async getChangedDeltaSync(options: AssetDeltaSyncOptions) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
@ -891,12 +869,12 @@ export class AssetRepository {
.as('stacked_assets'), .as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null), (join) => join.on('asset_stack.id', 'is not', null),
) )
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
.where('assets.ownerId', '=', anyUuid(options.userIds)) .where('assets.ownerId', '=', anyUuid(options.userIds))
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.updatedAt', '>', options.updatedAfter) .where('assets.updatedAt', '>', options.updatedAfter)
.limit(options.limit) .limit(options.limit)
.execute() as any as Promise<AssetEntity[]>; .execute();
} }
async upsertFile(file: Pick<Insertable<AssetFiles>, 'assetId' | 'path' | 'type'>): Promise<void> { async upsertFile(file: Pick<Insertable<AssetFiles>, 'assetId' | 'path' | 'type'>): Promise<void> {

View File

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Kysely, OrderByDirection, sql } from 'kysely'; import { Kysely, OrderByDirection, Selectable, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { DB } from 'src/db'; import { DB, Exif } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { searchAssetBuilder } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum'; import { AssetStatus, AssetType } from 'src/enum';
import { anyUuid, asUuid } from 'src/utils/database'; import { anyUuid, asUuid } from 'src/utils/database';
import { Paginated } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
export interface SearchResult<T> { export interface SearchResult<T> {
@ -216,7 +216,7 @@ export class SearchRepository {
}, },
], ],
}) })
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> { async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) {
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
const items = await searchAssetBuilder(this.db, options) const items = await searchAssetBuilder(this.db, options)
.orderBy('assets.fileCreatedAt', orderDirection) .orderBy('assets.fileCreatedAt', orderDirection)
@ -225,7 +225,7 @@ export class SearchRepository {
.execute(); .execute();
const hasNextPage = items.length > pagination.size; const hasNextPage = items.length > pagination.size;
items.splice(pagination.size); items.splice(pagination.size);
return { items: items as any as AssetEntity[], hasNextPage }; return { items, hasNextPage };
} }
@GenerateSql({ @GenerateSql({
@ -240,7 +240,7 @@ export class SearchRepository {
}, },
], ],
}) })
async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> { async searchRandom(size: number, options: AssetSearchOptions) {
const uuid = randomUUID(); const uuid = randomUUID();
const builder = searchAssetBuilder(this.db, options); const builder = searchAssetBuilder(this.db, options);
const lessThan = builder const lessThan = builder
@ -251,8 +251,8 @@ export class SearchRepository {
.where('assets.id', '>', uuid) .where('assets.id', '>', uuid)
.orderBy(sql`random()`) .orderBy(sql`random()`)
.limit(size); .limit(size);
const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db); const { rows } = await sql<MapAsset>`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
return rows as any as AssetEntity[]; return rows;
} }
@GenerateSql({ @GenerateSql({
@ -268,17 +268,17 @@ export class SearchRepository {
}, },
], ],
}) })
async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity> { async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) {
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) { if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
throw new Error(`Invalid value for 'size': ${pagination.size}`); throw new Error(`Invalid value for 'size': ${pagination.size}`);
} }
const items = (await searchAssetBuilder(this.db, options) const items = await searchAssetBuilder(this.db, options)
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`) .orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
.limit(pagination.size + 1) .limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size) .offset((pagination.page - 1) * pagination.size)
.execute()) as any as AssetEntity[]; .execute();
const hasNextPage = items.length > pagination.size; const hasNextPage = items.length > pagination.size;
items.splice(pagination.size); items.splice(pagination.size);
@ -392,7 +392,7 @@ export class SearchRepository {
} }
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> { getAssetsByCity(userIds: string[]) {
return this.db return this.db
.withRecursive('cte', (qb) => { .withRecursive('cte', (qb) => {
const base = qb const base = qb
@ -434,9 +434,14 @@ export class SearchRepository {
.innerJoin('exif', 'assets.id', 'exif.assetId') .innerJoin('exif', 'assets.id', 'exif.assetId')
.innerJoin('cte', 'assets.id', 'cte.assetId') .innerJoin('cte', 'assets.id', 'cte.assetId')
.selectAll('assets') .selectAll('assets')
.select((eb) => eb.fn('to_jsonb', [eb.table('exif')]).as('exifInfo')) .select((eb) =>
eb
.fn('to_jsonb', [eb.table('exif')])
.$castTo<Selectable<Exif>>()
.as('exifInfo'),
)
.orderBy('exif.city') .orderBy('exif.city')
.execute() as any as Promise<AssetEntity[]>; .execute();
} }
async upsert(assetId: string, embedding: string): Promise<void> { async upsert(assetId: string, embedding: string): Promise<void> {

View File

@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash'; import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { Album, columns } from 'src/database';
import { DB, SharedLinks } from 'src/db'; import { DB, SharedLinks } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
export type SharedLinkSearchOptions = { export type SharedLinkSearchOptions = {
@ -19,7 +19,7 @@ export class SharedLinkRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
get(userId: string, id: string): Promise<SharedLinkEntity | undefined> { get(userId: string, id: string) {
return this.db return this.db
.selectFrom('shared_links') .selectFrom('shared_links')
.selectAll('shared_links') .selectAll('shared_links')
@ -87,18 +87,23 @@ export class SharedLinkRepository {
.as('album'), .as('album'),
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.select((eb) => eb.fn.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`).as('assets')) .select((eb) =>
eb.fn
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
.$castTo<MapAsset[]>()
.as('assets'),
)
.groupBy(['shared_links.id', sql`"album".*`]) .groupBy(['shared_links.id', sql`"album".*`])
.select((eb) => eb.fn.toJson('album').as('album')) .select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.where('shared_links.id', '=', id) .where('shared_links.id', '=', id)
.where('shared_links.userId', '=', userId) .where('shared_links.userId', '=', userId)
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)]))
.orderBy('shared_links.createdAt', 'desc') .orderBy('shared_links.createdAt', 'desc')
.executeTakeFirst() as Promise<SharedLinkEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
getAll({ userId, albumId }: SharedLinkSearchOptions): Promise<SharedLinkEntity[]> { getAll({ userId, albumId }: SharedLinkSearchOptions) {
return this.db return this.db
.selectFrom('shared_links') .selectFrom('shared_links')
.selectAll('shared_links') .selectAll('shared_links')
@ -115,6 +120,7 @@ export class SharedLinkRepository {
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.select('assets.assets') .select('assets.assets')
.$narrowType<{ assets: NotNull }>()
.leftJoinLateral( .leftJoinLateral(
(eb) => (eb) =>
eb eb
@ -152,12 +158,12 @@ export class SharedLinkRepository {
.as('album'), .as('album'),
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.select((eb) => eb.fn.toJson('album').as('album')) .select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)]))
.$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!)) .$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!))
.orderBy('shared_links.createdAt', 'desc') .orderBy('shared_links.createdAt', 'desc')
.distinctOn(['shared_links.createdAt']) .distinctOn(['shared_links.createdAt'])
.execute() as unknown as Promise<SharedLinkEntity[]>; .execute();
} }
@GenerateSql({ params: [DummyValue.BUFFER] }) @GenerateSql({ params: [DummyValue.BUFFER] })
@ -177,7 +183,7 @@ export class SharedLinkRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
async create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity> { async create(entity: Insertable<SharedLinks> & { assetIds?: string[] }) {
const { id } = await this.db const { id } = await this.db
.insertInto('shared_links') .insertInto('shared_links')
.values(_.omit(entity, 'assetIds')) .values(_.omit(entity, 'assetIds'))
@ -194,7 +200,7 @@ export class SharedLinkRepository {
return this.getSharedLinks(id); return this.getSharedLinks(id);
} }
async update(entity: Updateable<SharedLinks> & { id: string; assetIds?: string[] }): Promise<SharedLinkEntity> { async update(entity: Updateable<SharedLinks> & { id: string; assetIds?: string[] }) {
const { id } = await this.db const { id } = await this.db
.updateTable('shared_links') .updateTable('shared_links')
.set(_.omit(entity, 'assets', 'album', 'assetIds')) .set(_.omit(entity, 'assets', 'album', 'assetIds'))
@ -212,8 +218,8 @@ export class SharedLinkRepository {
return this.getSharedLinks(id); return this.getSharedLinks(id);
} }
async remove(entity: SharedLinkEntity): Promise<void> { async remove(id: string): Promise<void> {
await this.db.deleteFrom('shared_links').where('shared_links.id', '=', entity.id).execute(); await this.db.deleteFrom('shared_links').where('shared_links.id', '=', id).execute();
} }
private getSharedLinks(id: string) { private getSharedLinks(id: string) {
@ -236,9 +242,12 @@ export class SharedLinkRepository {
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.select((eb) => .select((eb) =>
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'), eb.fn
.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`)
.$castTo<MapAsset[]>()
.as('assets'),
) )
.groupBy('shared_links.id') .groupBy('shared_links.id')
.executeTakeFirstOrThrow() as Promise<SharedLinkEntity>; .executeTakeFirstOrThrow();
} }
} }

View File

@ -5,7 +5,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
import { AssetStack, DB } from 'src/db'; import { AssetStack, DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
export interface StackSearch { export interface StackSearch {
@ -36,9 +35,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'asset_stack'>, withTags = false)
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')) .select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.whereRef('assets.stackId', '=', 'asset_stack.id'), .whereRef('assets.stackId', '=', 'asset_stack.id'),
) ).as('assets');
.$castTo<AssetEntity[]>()
.as('assets');
}; };
@Injectable() @Injectable()

View File

@ -6,15 +6,15 @@ import {
AlbumStatisticsResponseDto, AlbumStatisticsResponseDto,
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
mapAlbum, mapAlbum,
MapAlbumDto,
mapAlbumWithAssets, mapAlbumWithAssets,
mapAlbumWithoutAssets, mapAlbumWithoutAssets,
UpdateAlbumDto,
UpdateAlbumUserDto,
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@ -39,7 +39,7 @@ export class AlbumService extends BaseService {
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
let albums: AlbumEntity[]; let albums: MapAlbumDto[];
if (assetId) { if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId); albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (shared === true) { } else if (shared === true) {

View File

@ -8,6 +8,7 @@ import { Stats } from 'node:fs';
import { AssetFile } from 'src/database'; import { AssetFile } from 'src/database';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
@ -173,7 +174,7 @@ const assetEntity = Object.freeze({
}, },
livePhotoVideoId: null, livePhotoVideoId: null,
sidecarPath: null, sidecarPath: null,
}) as AssetEntity; } as MapAsset);
const existingAsset = Object.freeze({ const existingAsset = Object.freeze({
...assetEntity, ...assetEntity,
@ -182,18 +183,18 @@ const existingAsset = Object.freeze({
checksum: Buffer.from('_getExistingAsset', 'utf8'), checksum: Buffer.from('_getExistingAsset', 'utf8'),
libraryId: 'libraryId', libraryId: 'libraryId',
originalFileName: 'existing-filename.jpeg', originalFileName: 'existing-filename.jpeg',
}) as AssetEntity; }) as MapAsset;
const sidecarAsset = Object.freeze({ const sidecarAsset = Object.freeze({
...existingAsset, ...existingAsset,
sidecarPath: 'sidecar-path', sidecarPath: 'sidecar-path',
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
}) as AssetEntity; }) as MapAsset;
const copiedAsset = Object.freeze({ const copiedAsset = Object.freeze({
id: 'copied-asset', id: 'copied-asset',
originalPath: 'copied-path', originalPath: 'copied-path',
}) as AssetEntity; }) as MapAsset;
describe(AssetMediaService.name, () => { describe(AssetMediaService.name, () => {
let sut: AssetMediaService; let sut: AssetMediaService;

View File

@ -2,6 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path'; import { extname } from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import { import {
AssetBulkUploadCheckResponseDto, AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto, AssetMediaResponseDto,
@ -20,7 +21,7 @@ import {
UploadFieldName, UploadFieldName,
} from 'src/dtos/asset-media.dto'; } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum'; import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@ -212,7 +213,7 @@ export class AssetMediaService extends BaseService {
const asset = await this.findOrFail(id); const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL; const size = dto.size ?? AssetMediaSize.THUMBNAIL;
const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files); const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files ?? []);
let filepath = previewFile?.path; let filepath = previewFile?.path;
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) { if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
filepath = thumbnailFile.path; filepath = thumbnailFile.path;
@ -375,7 +376,7 @@ export class AssetMediaService extends BaseService {
* Uses only vital properties excluding things like: stacks, faces, smart search info, etc, * Uses only vital properties excluding things like: stacks, faces, smart search info, etc,
* and then queues a METADATA_EXTRACTION job. * and then queues a METADATA_EXTRACTION job.
*/ */
private async createCopy(asset: AssetEntity): Promise<AssetEntity> { private async createCopy(asset: Omit<Asset, 'id'>) {
const created = await this.assetRepository.create({ const created = await this.assetRepository.create({
ownerId: asset.ownerId, ownerId: asset.ownerId,
originalPath: asset.originalPath, originalPath: asset.originalPath,
@ -398,12 +399,7 @@ export class AssetMediaService extends BaseService {
return created; return created;
} }
private async create( private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
ownerId: string,
dto: AssetMediaCreateDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetEntity> {
const asset = await this.assetRepository.create({ const asset = await this.assetRepository.create({
ownerId, ownerId,
libraryId: null, libraryId: null,
@ -444,7 +440,7 @@ export class AssetMediaService extends BaseService {
} }
} }
private async findOrFail(id: string): Promise<AssetEntity> { private async findOrFail(id: string) {
const asset = await this.assetRepository.getById(id, { files: true }); const asset = await this.assetRepository.getById(id, { files: true });
if (!asset) { if (!asset) {
throw new NotFoundException('Asset not found'); throw new NotFoundException('Asset not found');

View File

@ -1,8 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum'; import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository'; import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
@ -35,7 +34,7 @@ describe(AssetService.name, () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
const mockGetById = (assets: AssetEntity[]) => { const mockGetById = (assets: MapAsset[]) => {
mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
}; };
@ -608,7 +607,7 @@ describe(AssetService.name, () => {
mocks.asset.getById.mockResolvedValue({ mocks.asset.getById.mockResolvedValue({
...assetStub.primaryImage, ...assetStub.primaryImage,
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
} as AssetEntity); });
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });

View File

@ -5,6 +5,7 @@ import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { import {
AssetResponseDto, AssetResponseDto,
MapAsset,
MemoryLaneResponseDto, MemoryLaneResponseDto,
SanitizedAssetResponseDto, SanitizedAssetResponseDto,
mapAsset, mapAsset,
@ -20,7 +21,6 @@ import {
} from 'src/dtos/asset.dto'; } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
@ -43,7 +43,7 @@ export class AssetService extends BaseService {
yearsAgo, yearsAgo,
// TODO move this to clients // TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })), assets: assets.map((asset) => mapAsset(asset, { auth })),
}; };
}); });
} }
@ -105,7 +105,7 @@ export class AssetService extends BaseService {
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository }; const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: AssetEntity | null = null; let previousMotion: MapAsset | null = null;
if (rest.livePhotoVideoId) { if (rest.livePhotoVideoId) {
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
} else if (rest.livePhotoVideoId === null) { } else if (rest.livePhotoVideoId === null) {
@ -233,7 +233,7 @@ export class AssetService extends BaseService {
} }
} }
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files); const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files ?? []);
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath]; const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
if (deleteOnDisk) { if (deleteOnDisk) {

View File

@ -68,7 +68,7 @@ export class DuplicateService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW); const previewFile = getAssetFile(asset.files || [], AssetFileType.PREVIEW);
if (!previewFile) { if (!previewFile) {
this.logger.warn(`Asset ${id} is missing preview image`); this.logger.warn(`Asset ${id} is missing preview image`);
return JobStatus.FAILED; return JobStatus.FAILED;

View File

@ -285,9 +285,9 @@ describe(JobService.name, () => {
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') {
if (item.data.id === 'asset-live-image') { if (item.data.id === 'asset-live-image') {
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.livePhotoStillAsset as any]);
} else { } else {
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.livePhotoMotionAsset as any]);
} }
} }

View File

@ -254,7 +254,7 @@ export class JobService extends BaseService {
case JobName.METADATA_EXTRACTION: { case JobName.METADATA_EXTRACTION: {
if (item.data.source === 'sidecar-write') { if (item.data.source === 'sidecar-write') {
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
if (asset) { if (asset) {
this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset));
} }
@ -284,7 +284,7 @@ export class JobService extends BaseService {
break; break;
} }
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
if (!asset) { if (!asset) {
this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`); this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`);
break; break;

View File

@ -350,7 +350,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.external]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -371,7 +371,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.external]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -392,7 +392,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -410,7 +410,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -431,7 +431,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -451,7 +451,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -471,7 +471,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.external]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -489,7 +489,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@ -518,7 +518,7 @@ describe(LibraryService.name, () => {
const mtime = new Date(assetStub.external.fileModifiedAt.getDate() + 1); const mtime = new Date(assetStub.external.fileModifiedAt.getDate() + 1);
mocks.asset.getByIds.mockResolvedValue([assetStub.external]); mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockResolvedValue({ mtime } as Stats); mocks.storage.stat.mockResolvedValue({ mtime } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);

View File

@ -18,7 +18,6 @@ import {
ValidateLibraryImportPathResponseDto, ValidateLibraryImportPathResponseDto,
ValidateLibraryResponseDto, ValidateLibraryResponseDto,
} from 'src/dtos/library.dto'; } from 'src/dtos/library.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetStatus, AssetType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { AssetSyncResult } from 'src/repositories/library.repository'; import { AssetSyncResult } from 'src/repositories/library.repository';
@ -467,7 +466,7 @@ export class LibraryService extends BaseService {
@OnJob({ name: JobName.LIBRARY_SYNC_ASSETS, queue: QueueName.LIBRARY }) @OnJob({ name: JobName.LIBRARY_SYNC_ASSETS, queue: QueueName.LIBRARY })
async handleSyncAssets(job: JobOf<JobName.LIBRARY_SYNC_ASSETS>): Promise<JobStatus> { async handleSyncAssets(job: JobOf<JobName.LIBRARY_SYNC_ASSETS>): Promise<JobStatus> {
const assets = await this.assetRepository.getByIds(job.assetIds); const assets = await this.assetJobRepository.getForSyncAssets(job.assetIds);
const assetIdsToOffline: string[] = []; const assetIdsToOffline: string[] = [];
const trashedAssetIdsToOffline: string[] = []; const trashedAssetIdsToOffline: string[] = [];
@ -561,7 +560,16 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
private checkExistingAsset(asset: AssetEntity, stat: Stats | null): AssetSyncResult { private checkExistingAsset(
asset: {
isOffline: boolean;
libraryId: string | null;
originalPath: string;
status: AssetStatus;
fileModifiedAt: Date;
},
stat: Stats | null,
): AssetSyncResult {
if (!stat) { if (!stat) {
// File not found on disk or permission error // File not found on disk or permission error
if (asset.isOffline) { if (asset.isOffline) {

View File

@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises'; import { constants } from 'node:fs/promises';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { AssetEntity } from 'src/entities/asset.entity'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository'; import { WithoutProperty } from 'src/repositories/asset.repository';
import { ImmichTags } from 'src/repositories/metadata.repository'; import { ImmichTags } from 'src/repositories/metadata.repository';
@ -549,7 +549,6 @@ describe(MetadataService.name, () => {
livePhotoVideoId: null, livePhotoVideoId: null,
libraryId: null, libraryId: null,
}); });
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
mocks.storage.stat.mockResolvedValue({ mocks.storage.stat.mockResolvedValue({
size: 123_456, size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
@ -719,7 +718,7 @@ describe(MetadataService.name, () => {
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockImplementation( mocks.asset.create.mockImplementation(
(asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise<AssetEntity>, (asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise<MapAsset>,
); );
const video = randomBytes(512); const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video); mocks.storage.readFile.mockResolvedValue(video);
@ -1394,7 +1393,7 @@ describe(MetadataService.name, () => {
}); });
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => { it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt as any]);
mocks.storage.checkFileExists.mockResolvedValueOnce(false); mocks.storage.checkFileExists.mockResolvedValueOnce(false);
mocks.storage.checkFileExists.mockResolvedValueOnce(true); mocks.storage.checkFileExists.mockResolvedValueOnce(true);
@ -1446,7 +1445,7 @@ describe(MetadataService.name, () => {
describe('handleSidecarDiscovery', () => { describe('handleSidecarDiscovery', () => {
it('should skip hidden assets', async () => { it('should skip hidden assets', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset as any]);
await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id }); await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id });
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
}); });

View File

@ -271,7 +271,7 @@ export class MetadataService extends BaseService {
]; ];
if (this.isMotionPhoto(asset, exifTags)) { if (this.isMotionPhoto(asset, exifTags)) {
promises.push(this.applyMotionPhotos(asset as unknown as Asset, exifTags, dates, stats)); promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
} }
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {

View File

@ -2,7 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Insertable, Updateable } from 'kysely'; import { Insertable, Updateable } from 'kysely';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetFaces, FaceSearch, Person } from 'src/db'; import { Person } from 'src/database';
import { AssetFaces, FaceSearch } from 'src/db';
import { Chunked, OnJob } from 'src/decorators'; import { Chunked, OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -315,6 +316,7 @@ export class PersonService extends BaseService {
const facesToAdd: (Insertable<AssetFaces> & { id: string })[] = []; const facesToAdd: (Insertable<AssetFaces> & { id: string })[] = [];
const embeddings: FaceSearch[] = []; const embeddings: FaceSearch[] = [];
const mlFaceIds = new Set<string>(); const mlFaceIds = new Set<string>();
for (const face of asset.faces) { for (const face of asset.faces) {
if (face.sourceType === SourceType.MACHINE_LEARNING) { if (face.sourceType === SourceType.MACHINE_LEARNING) {
mlFaceIds.add(face.id); mlFaceIds.add(face.id);
@ -477,7 +479,7 @@ export class PersonService extends BaseService {
embedding: face.faceSearch.embedding, embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance, maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces, numResults: machineLearning.facialRecognition.minFaces,
minBirthDate: face.asset.fileCreatedAt, minBirthDate: face.asset.fileCreatedAt ?? undefined,
}); });
// `matches` also includes the face itself // `matches` also includes the face itself
@ -503,7 +505,7 @@ export class PersonService extends BaseService {
maxDistance: machineLearning.facialRecognition.maxDistance, maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 1, numResults: 1,
hasPerson: true, hasPerson: true,
minBirthDate: face.asset.fileCreatedAt, minBirthDate: face.asset.fileCreatedAt ?? undefined,
}); });
if (matchWithPerson.length > 0) { if (matchWithPerson.length > 0) {

View File

@ -45,7 +45,7 @@ describe(SearchService.name, () => {
fieldName: 'exifInfo.city', fieldName: 'exifInfo.city',
items: [{ value: 'test-city', data: assetStub.withLocation.id }], items: [{ value: 'test-city', data: assetStub.withLocation.id }],
}); });
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]);
const expectedResponse = [ const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] }, { fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
]; ];

View File

@ -1,5 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import { import {
@ -14,7 +14,6 @@ import {
SearchSuggestionType, SearchSuggestionType,
SmartSearchDto, SmartSearchDto,
} from 'src/dtos/search.dto'; } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum'; import { AssetOrder } from 'src/enum';
import { SearchExploreItem } from 'src/repositories/search.repository'; import { SearchExploreItem } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@ -36,7 +35,7 @@ export class SearchService extends BaseService {
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> { async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
const options = { maxFields: 12, minAssetsPerField: 5 }; const options = { maxFields: 12, minAssetsPerField: 5 };
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options); const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
const assets = await this.assetRepository.getByIdsWithAllRelations(cities.items.map(({ data }) => data)); const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));
const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) })); const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
return [{ fieldName: cities.fieldName, items }]; return [{ fieldName: cities.fieldName, items }];
} }
@ -139,7 +138,7 @@ export class SearchService extends BaseService {
return [auth.user.id, ...partnerIds]; return [auth.user.id, ...partnerIds];
} }
private mapResponse(assets: AssetEntity[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto { private mapResponse(assets: MapAsset[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto {
return { return {
albums: { total: 0, count: 0, items: [], facets: [] }, albums: { total: 0, count: 0, items: [], facets: [] },
assets: { assets: {

View File

@ -244,7 +244,7 @@ describe(SharedLinkService.name, () => {
await sut.remove(authStub.user1, sharedLinkStub.valid.id); await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid); expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid.id);
}); });
}); });
@ -333,8 +333,7 @@ describe(SharedLinkService.name, () => {
}); });
it('should return metadata tags with a default image path if the asset id is not set', async () => { it('should return metadata tags with a default image path if the asset id is not set', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: null, assets: [] });
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '0 shared photos & videos', description: '0 shared photos & videos',
imageUrl: `https://my.immich.app/feature-panel.png`, imageUrl: `https://my.immich.app/feature-panel.png`,

View File

@ -1,4 +1,5 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { SharedLink } from 'src/database';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -11,7 +12,6 @@ import {
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkSearchDto, SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto'; } from 'src/dtos/shared-link.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum'; import { Permission, SharedLinkType } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getExternalDomain, OpenGraphTags } from 'src/utils/misc'; import { getExternalDomain, OpenGraphTags } from 'src/utils/misc';
@ -98,7 +98,7 @@ export class SharedLinkService extends BaseService {
async remove(auth: AuthDto, id: string): Promise<void> { async remove(auth: AuthDto, id: string): Promise<void> {
const sharedLink = await this.findOrFail(auth.user.id, id); const sharedLink = await this.findOrFail(auth.user.id, id);
await this.sharedLinkRepository.remove(sharedLink); await this.sharedLinkRepository.remove(sharedLink.id);
} }
// TODO: replace `userId` with permissions and access control checks // TODO: replace `userId` with permissions and access control checks
@ -182,7 +182,7 @@ export class SharedLinkService extends BaseService {
const config = await this.getConfig({ withCache: true }); const config = await this.getConfig({ withCache: true });
const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id); const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id);
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets.length || 0; const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets?.length || 0;
const imagePath = assetId const imagePath = assetId
? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}` ? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
: '/feature-panel.png'; : '/feature-panel.png';
@ -194,11 +194,11 @@ export class SharedLinkService extends BaseService {
}; };
} }
private mapToSharedLink(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) { private mapToSharedLink(sharedLink: SharedLink, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink); return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
} }
private validateAndRefreshToken(sharedLink: SharedLinkEntity, dto: SharedLinkPasswordDto): string { private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`); const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
const sharedLinkTokens = dto.token?.split(',') || []; const sharedLinkTokens = dto.token?.split(',') || [];
if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) { if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) {

View File

@ -1,5 +1,4 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { SyncService } from 'src/services/sync.service'; import { SyncService } from 'src/services/sync.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -63,7 +62,7 @@ describe(SyncService.name, () => {
it('should return a response requiring a full sync when there are too many changes', async () => { it('should return a response requiring a full sync when there are too many changes', async () => {
mocks.partner.getAll.mockResolvedValue([]); mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue( mocks.asset.getChangedDeltaSync.mockResolvedValue(
Array.from<AssetEntity>({ length: 10_000 }).fill(assetStub.image), Array.from<typeof assetStub.image>({ length: 10_000 }).fill(assetStub.image),
); );
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@Injectable() @Injectable()
@ -12,6 +11,6 @@ export class ViewService extends BaseService {
async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> { async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> {
const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path); const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path);
return assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })); return assets.map((asset) => mapAsset(asset, { auth }));
} }
} }

View File

@ -13,11 +13,8 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types'; import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
import { checkAccess } from 'src/utils/access'; import { checkAccess } from 'src/utils/access';
export const getAssetFile = <T extends { type: AssetFileType }>( export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
files: T[], return files.find((file) => file.type === type);
type: AssetFileType | GeneratedImageType,
) => {
return (files || []).find((file) => file.type === type);
}; };
export const getAssetFiles = (files: AssetFile[]) => ({ export const getAssetFiles = (files: AssetFile[]) => ({

View File

@ -1,11 +1,10 @@
import { AlbumEntity } from 'src/entities/album.entity';
import { AlbumUserRole, AssetOrder } from 'src/enum'; import { AlbumUserRole, AssetOrder } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
export const albumStub = { export const albumStub = {
empty: Object.freeze<AlbumEntity>({ empty: Object.freeze({
id: 'album-1', id: 'album-1',
albumName: 'Empty album', albumName: 'Empty album',
description: '', description: '',
@ -21,8 +20,9 @@ export const albumStub = {
albumUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
updateId: '42',
}), }),
sharedWithUser: Object.freeze<AlbumEntity>({ sharedWithUser: Object.freeze({
id: 'album-2', id: 'album-2',
albumName: 'Empty album shared with user', albumName: 'Empty album shared with user',
description: '', description: '',
@ -43,8 +43,9 @@ export const albumStub = {
], ],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
updateId: '42',
}), }),
sharedWithMultiple: Object.freeze<AlbumEntity>({ sharedWithMultiple: Object.freeze({
id: 'album-3', id: 'album-3',
albumName: 'Empty album shared with users', albumName: 'Empty album shared with users',
description: '', description: '',
@ -69,8 +70,9 @@ export const albumStub = {
], ],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
updateId: '42',
}), }),
sharedWithAdmin: Object.freeze<AlbumEntity>({ sharedWithAdmin: Object.freeze({
id: 'album-3', id: 'album-3',
albumName: 'Empty album shared with admin', albumName: 'Empty album shared with admin',
description: '', description: '',
@ -91,8 +93,9 @@ export const albumStub = {
], ],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
updateId: '42',
}), }),
oneAsset: Object.freeze<AlbumEntity>({ oneAsset: Object.freeze({
id: 'album-4', id: 'album-4',
albumName: 'Album with one asset', albumName: 'Album with one asset',
description: '', description: '',
@ -108,8 +111,9 @@ export const albumStub = {
albumUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
updateId: '42',
}), }),
twoAssets: Object.freeze<AlbumEntity>({ twoAssets: Object.freeze({
id: 'album-4a', id: 'album-4a',
albumName: 'Album with two assets', albumName: 'Album with two assets',
description: '', description: '',
@ -125,8 +129,9 @@ export const albumStub = {
albumUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
updateId: '42',
}), }),
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ emptyWithValidThumbnail: Object.freeze({
id: 'album-5', id: 'album-5',
albumName: 'Empty album with valid thumbnail', albumName: 'Empty album with valid thumbnail',
description: '', description: '',
@ -142,5 +147,6 @@ export const albumStub = {
albumUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
updateId: '42',
}), }),
}; };

View File

@ -1,5 +1,5 @@
import { AssetFile, Exif } from 'src/database'; import { AssetFace, AssetFile, Exif } from 'src/database';
import { AssetEntity } from 'src/entities/asset.entity'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { StorageAsset } from 'src/types'; import { StorageAsset } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -26,13 +26,15 @@ const fullsizeFile: AssetFile = {
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
export const stackStub = (stackId: string, assets: AssetEntity[]) => { export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
return { return {
id: stackId, id: stackId,
assets, assets,
ownerId: assets[0].ownerId, ownerId: assets[0].ownerId,
primaryAsset: assets[0], primaryAsset: assets[0],
primaryAssetId: assets[0].id, primaryAssetId: assets[0].id,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
}; };
}; };
@ -85,9 +87,12 @@ export const assetStub = {
isExternal: false, isExternal: false,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
}), }),
noWebpPath: Object.freeze<AssetEntity>({ noWebpPath: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -122,9 +127,12 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
}), }),
noThumbhash: Object.freeze<AssetEntity>({ noThumbhash: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -156,6 +164,9 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
}), }),
primaryImage: Object.freeze({ primaryImage: Object.freeze({
@ -195,12 +206,13 @@ export const assetStub = {
} as Exif, } as Exif,
stackId: 'stack-1', stackId: 'stack-1',
stack: stackStub('stack-1', [ stack: stackStub('stack-1', [
{ id: 'primary-asset-id' } as AssetEntity, { id: 'primary-asset-id' } as MapAsset & { exifInfo: Exif },
{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-1' } as MapAsset & { exifInfo: Exif },
{ id: 'stack-child-asset-2' } as AssetEntity, { id: 'stack-child-asset-2' } as MapAsset & { exifInfo: Exif },
]), ]),
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: '42',
libraryId: null, libraryId: null,
}), }),
@ -229,6 +241,9 @@ export const assetStub = {
isExternal: false, isExternal: false,
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',
faces: [], faces: [],
@ -241,10 +256,10 @@ export const assetStub = {
} as Exif, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
libraryId: null, stack: null,
}), }),
trashed: Object.freeze<AssetEntity>({ trashed: Object.freeze({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -281,9 +296,12 @@ export const assetStub = {
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
status: AssetStatus.TRASHED, status: AssetStatus.TRASHED,
libraryId: null,
stackId: null,
updateId: '42',
}), }),
trashedOffline: Object.freeze<AssetEntity>({ trashedOffline: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -321,8 +339,10 @@ export const assetStub = {
} as Exif, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: true, isOffline: true,
stackId: null,
updateId: '42',
}), }),
archived: Object.freeze<AssetEntity>({ archived: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -359,9 +379,12 @@ export const assetStub = {
} as Exif, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
}), }),
external: Object.freeze<AssetEntity>({ external: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -397,9 +420,12 @@ export const assetStub = {
} as Exif, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: '42',
stackId: null,
stack: null,
}), }),
image1: Object.freeze<AssetEntity>({ image1: Object.freeze({
id: 'asset-id-1', id: 'asset-id-1',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -434,9 +460,13 @@ export const assetStub = {
} as Exif, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: '42',
stackId: null,
libraryId: null,
stack: null,
}), }),
imageFrom2015: Object.freeze<AssetEntity>({ imageFrom2015: Object.freeze({
id: 'asset-id-1', id: 'asset-id-1',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -510,7 +540,9 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: '42',
libraryId: null, libraryId: null,
stackId: null,
}), }),
livePhotoMotionAsset: Object.freeze({ livePhotoMotionAsset: Object.freeze({
@ -527,7 +559,7 @@ export const assetStub = {
timeZone: `America/New_York`, timeZone: `America/New_York`,
}, },
libraryId: null, libraryId: null,
} as AssetEntity & { libraryId: string | null; files: AssetFile[]; exifInfo: Exif }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
livePhotoStillAsset: Object.freeze({ livePhotoStillAsset: Object.freeze({
id: 'live-photo-still-asset', id: 'live-photo-still-asset',
@ -544,7 +576,8 @@ export const assetStub = {
timeZone: `America/New_York`, timeZone: `America/New_York`,
}, },
files, files,
} as AssetEntity & { libraryId: string | null }), faces: [] as AssetFace[],
} as MapAsset & { faces: AssetFace[] }),
livePhotoWithOriginalFileName: Object.freeze({ livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset', id: 'live-photo-still-asset',
@ -562,7 +595,8 @@ export const assetStub = {
timeZone: `America/New_York`, timeZone: `America/New_York`,
}, },
libraryId: null, libraryId: null,
} as AssetEntity & { libraryId: string | null }), faces: [] as AssetFace[],
} as MapAsset & { faces: AssetFace[] }),
withLocation: Object.freeze({ withLocation: Object.freeze({
id: 'asset-with-favorite-id', id: 'asset-with-favorite-id',
@ -590,6 +624,9 @@ export const assetStub = {
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.ext', originalFileName: 'asset-id.ext',
faces: [], faces: [],
@ -604,7 +641,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
libraryId: null, tags: [],
}), }),
sidecar: Object.freeze({ sidecar: Object.freeze({
@ -639,10 +676,12 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: 'foo',
libraryId: null, libraryId: null,
stackId: null,
}), }),
sidecarWithoutExt: Object.freeze<AssetEntity>({ sidecarWithoutExt: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -676,7 +715,7 @@ export const assetStub = {
isOffline: false, isOffline: false,
}), }),
hasEncodedVideo: Object.freeze<AssetEntity>({ hasEncodedVideo: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
originalFileName: 'asset-id.ext', originalFileName: 'asset-id.ext',
@ -711,9 +750,13 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
stack: null,
}), }),
hasFileExtension: Object.freeze<AssetEntity>({ hasFileExtension: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -788,6 +831,9 @@ export const assetStub = {
} as Exif, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
}), }),
imageHif: Object.freeze({ imageHif: Object.freeze({
@ -827,5 +873,8 @@ export const assetStub = {
} as Exif, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
}), }),
}; };

View File

@ -1,6 +1,5 @@
import { Session } from 'src/database'; import { Session } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
const authUser = { const authUser = {
admin: { admin: {
@ -42,14 +41,16 @@ export const authStub = {
id: 'token-id', id: 'token-id',
} as Session, } as Session,
}), }),
adminSharedLink: Object.freeze<AuthDto>({ adminSharedLink: Object.freeze({
user: authUser.admin, user: authUser.admin,
sharedLink: { sharedLink: {
id: '123', id: '123',
showExif: true, showExif: true,
allowDownload: true, allowDownload: true,
allowUpload: true, allowUpload: true,
key: Buffer.from('shared-link-key'), expiresAt: null,
} as SharedLinkEntity, password: null,
userId: '42',
},
}), }),
}; };

View File

@ -1,10 +1,9 @@
import { UserAdmin } from 'src/database'; import { UserAdmin } from 'src/database';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, MapAsset } from 'src/dtos/asset-response.dto';
import { ExifResponseDto } from 'src/dtos/exif.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { mapUser } from 'src/dtos/user.dto'; import { mapUser } from 'src/dtos/user.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -113,12 +112,12 @@ export const sharedLinkStub = {
allowUpload: true, allowUpload: true,
allowDownload: true, allowDownload: true,
showExif: true, showExif: true,
album: undefined, albumId: null,
album: null,
description: null, description: null,
assets: [assetStub.image], assets: [assetStub.image],
password: 'password', password: 'password',
albumId: null, }),
} as SharedLinkEntity),
valid: Object.freeze({ valid: Object.freeze({
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
@ -130,12 +129,12 @@ export const sharedLinkStub = {
allowUpload: true, allowUpload: true,
allowDownload: true, allowDownload: true,
showExif: true, showExif: true,
album: undefined,
albumId: null, albumId: null,
description: null, description: null,
password: null, password: null,
assets: [], assets: [] as MapAsset[],
} as SharedLinkEntity), album: null,
}),
expired: Object.freeze({ expired: Object.freeze({
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
@ -150,9 +149,10 @@ export const sharedLinkStub = {
description: null, description: null,
password: null, password: null,
albumId: null, albumId: null,
assets: [], assets: [] as MapAsset[],
} as SharedLinkEntity), album: null,
readonlyNoExif: Object.freeze<SharedLinkEntity>({ }),
readonlyNoExif: Object.freeze({
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
key: sharedLinkBytes, key: sharedLinkBytes,
@ -168,6 +168,7 @@ export const sharedLinkStub = {
albumId: 'album-123', albumId: 'album-123',
album: { album: {
id: 'album-123', id: 'album-123',
updateId: '42',
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
owner: userStub.admin, owner: userStub.admin,
albumName: 'Test Album', albumName: 'Test Album',
@ -239,17 +240,22 @@ export const sharedLinkStub = {
colorspace: 'sRGB', colorspace: 'sRGB',
autoStackId: null, autoStackId: null,
rating: 3, rating: 3,
updatedAt: today,
updateId: '42',
}, },
sharedLinks: [], sharedLinks: [],
faces: [], faces: [],
sidecarPath: null, sidecarPath: null,
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
updateId: '42',
libraryId: null,
stackId: null,
}, },
], ],
}, },
}), }),
passwordRequired: Object.freeze<SharedLinkEntity>({ passwordRequired: Object.freeze({
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
key: sharedLinkBytes, key: sharedLinkBytes,
@ -263,6 +269,7 @@ export const sharedLinkStub = {
password: 'password', password: 'password',
assets: [], assets: [],
albumId: null, albumId: null,
album: null,
}), }),
}; };

View File

@ -39,9 +39,12 @@ describe(MemoryService.name, () => {
it('should create a memory from an asset', async () => { it('should create a memory from an asset', async () => {
const { sut, repos, getRepository } = createSut(); const { sut, repos, getRepository } = createSut();
const now = DateTime.fromObject({ year: 2025, month: 2, day: 25 }, { zone: 'utc' }); const now = DateTime.fromObject({ year: 2025, month: 2, day: 25 }, { zone: 'utc' }) as DateTime<true>;
const user = mediumFactory.userInsert(); const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({ ownerId: user.id, localDateTime: now.minus({ years: 1 }).toISO() }); const asset = mediumFactory.assetInsert({
ownerId: user.id,
localDateTime: now.minus({ years: 1 }).toISO(),
});
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: asset.id }); const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: asset.id });
const userRepo = getRepository('user'); const userRepo = getRepository('user');
@ -86,7 +89,7 @@ describe(MemoryService.name, () => {
it('should not generate a memory twice for the same day', async () => { it('should not generate a memory twice for the same day', async () => {
const { sut, repos, getRepository } = createSut(); const { sut, repos, getRepository } = createSut();
const now = DateTime.fromObject({ year: 2025, month: 2, day: 20 }, { zone: 'utc' }); const now = DateTime.fromObject({ year: 2025, month: 2, day: 20 }, { zone: 'utc' }) as DateTime<true>;
const assetRepo = getRepository('asset'); const assetRepo = getRepository('asset');
const memoryRepo = getRepository('memory'); const memoryRepo = getRepository('memory');

View File

@ -118,7 +118,7 @@ describe(MetadataService.name, () => {
process.env.TZ = serverTimeZone ?? undefined; process.env.TZ = serverTimeZone ?? undefined;
const { filePath } = await createTestFile(exifData); const { filePath } = await createTestFile(exifData);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as never); mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as any);
await sut.handleMetadataExtraction({ id: 'asset-1' }); await sut.handleMetadataExtraction({ id: 'asset-1' });

View File

@ -11,7 +11,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
upsertJobStatus: vitest.fn(), upsertJobStatus: vitest.fn(),
getByDayOfYear: vitest.fn(), getByDayOfYear: vitest.fn(),
getByIds: vitest.fn().mockResolvedValue([]), getByIds: vitest.fn().mockResolvedValue([]),
getByIdsWithAllRelations: vitest.fn().mockResolvedValue([]), getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
getByDeviceIds: vitest.fn(), getByDeviceIds: vitest.fn(),
getByUserId: vitest.fn(), getByUserId: vitest.fn(),
getById: vitest.fn(), getById: vitest.fn(),

View File

@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto';
import { import {
Activity, Activity,
ApiKey, ApiKey,
Asset,
AuthApiKey, AuthApiKey,
AuthSharedLink, AuthSharedLink,
AuthUser, AuthUser,
@ -14,6 +13,7 @@ import {
User, User,
UserAdmin, UserAdmin,
} from 'src/database'; } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
import { OnThisDayData } from 'src/types'; import { OnThisDayData } from 'src/types';
@ -184,7 +184,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
}; };
}; };
const assetFactory = (asset: Partial<Asset> = {}) => ({ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
id: newUuid(), id: newUuid(),
createdAt: newDate(), createdAt: newDate(),
updatedAt: newDate(), updatedAt: newDate(),