mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
refactor: remove album entity, update types (#17450)
This commit is contained in:
parent
854ea13d6a
commit
52ae06c119
@ -1,13 +1,14 @@
|
||||
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 {
|
||||
AlbumUserRole,
|
||||
AssetFileType,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
MemoryType,
|
||||
Permission,
|
||||
SharedLinkType,
|
||||
SourceType,
|
||||
UserStatus,
|
||||
} from 'src/enum';
|
||||
@ -44,7 +45,7 @@ export type Library = {
|
||||
exclusionPatterns: string[];
|
||||
deletedAt: Date | null;
|
||||
refreshedAt: Date | null;
|
||||
assets?: Asset[];
|
||||
assets?: MapAsset[];
|
||||
};
|
||||
|
||||
export type AuthApiKey = {
|
||||
@ -96,7 +97,26 @@ export type Memory = {
|
||||
data: OnThisDayData;
|
||||
ownerId: string;
|
||||
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 = {
|
||||
@ -128,39 +148,6 @@ export type StorageAsset = {
|
||||
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 = {
|
||||
id: string;
|
||||
sidecarPath: string | null;
|
||||
@ -173,7 +160,7 @@ export type Stack = {
|
||||
primaryAssetId: string;
|
||||
owner?: User;
|
||||
ownerId: string;
|
||||
assets: AssetEntity[];
|
||||
assets: MapAsset[];
|
||||
assetCount?: number;
|
||||
};
|
||||
|
||||
@ -187,6 +174,28 @@ export type AuthSharedLink = {
|
||||
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 = {
|
||||
id: string;
|
||||
};
|
||||
|
6
server/src/db.d.ts
vendored
6
server/src/db.d.ts
vendored
@ -143,8 +143,8 @@ export interface Assets {
|
||||
duplicateId: string | null;
|
||||
duration: string | null;
|
||||
encodedVideoPath: Generated<string | null>;
|
||||
fileCreatedAt: Timestamp | null;
|
||||
fileModifiedAt: Timestamp | null;
|
||||
fileCreatedAt: Timestamp;
|
||||
fileModifiedAt: Timestamp;
|
||||
id: Generated<string>;
|
||||
isArchived: Generated<boolean>;
|
||||
isExternal: Generated<boolean>;
|
||||
@ -153,7 +153,7 @@ export interface Assets {
|
||||
isVisible: Generated<boolean>;
|
||||
libraryId: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
localDateTime: Timestamp | null;
|
||||
localDateTime: Timestamp;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
ownerId: string;
|
||||
|
@ -2,10 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsEnum, IsString, ValidateNested } from 'class-validator';
|
||||
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 { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
|
||||
@ -142,7 +142,23 @@ export class AlbumResponseDto {
|
||||
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[] = [];
|
||||
|
||||
if (entity.albumUsers) {
|
||||
@ -159,7 +175,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
|
||||
|
||||
const assets = entity.assets || [];
|
||||
|
||||
const hasSharedLink = entity.sharedLinks?.length > 0;
|
||||
const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
|
||||
const hasSharedUser = albumUsers.length > 0;
|
||||
|
||||
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 mapAlbumWithoutAssets = (entity: AlbumEntity) => mapAlbum(entity, false);
|
||||
export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true);
|
||||
export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false);
|
||||
|
@ -1,5 +1,6 @@
|
||||
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 { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
||||
@ -11,8 +12,7 @@ import {
|
||||
} from 'src/dtos/person.dto';
|
||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { AssetStatus, AssetType } from 'src/enum';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
export class SanitizedAssetResponseDto {
|
||||
@ -56,6 +56,44 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
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 {
|
||||
id!: string;
|
||||
|
||||
@ -72,7 +110,7 @@ export type AssetMapOptions = {
|
||||
};
|
||||
|
||||
// TODO: this is inefficient
|
||||
const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => {
|
||||
const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => {
|
||||
const result: PersonWithFacesResponseDto[] = [];
|
||||
if (faces) {
|
||||
for (const face of faces) {
|
||||
@ -90,7 +128,7 @@ const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const mapStack = (entity: AssetEntity) => {
|
||||
const mapStack = (entity: { stack?: Stack | null }) => {
|
||||
if (!entity.stack) {
|
||||
return null;
|
||||
}
|
||||
@ -111,7 +149,7 @@ export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
|
||||
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;
|
||||
|
||||
if (stripMetadata) {
|
||||
|
@ -4,7 +4,6 @@ import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-valid
|
||||
import { Memory } from 'src/database';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { MemoryType } from 'src/enum';
|
||||
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,
|
||||
data: entity.data as unknown as MemoryData,
|
||||
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 })),
|
||||
};
|
||||
};
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsString } from 'class-validator';
|
||||
import _ from 'lodash';
|
||||
import { SharedLink } from 'src/database';
|
||||
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
|
||||
@ -102,7 +102,7 @@ export class SharedLinkResponseDto {
|
||||
showMetadata!: boolean;
|
||||
}
|
||||
|
||||
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
|
||||
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
|
||||
const linkAssets = sharedLink.assets || [];
|
||||
|
||||
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 albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
|
||||
|
||||
@ -137,7 +137,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
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,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
|
@ -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;
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
|
||||
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 { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.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';
|
||||
|
||||
@ -37,13 +37,12 @@ export class AssetEntity {
|
||||
checksum!: Buffer; // sha1 checksum
|
||||
duration!: string | null;
|
||||
isVisible!: boolean;
|
||||
livePhotoVideo!: AssetEntity | null;
|
||||
livePhotoVideo!: MapAsset | null;
|
||||
livePhotoVideoId!: string | null;
|
||||
originalFileName!: string;
|
||||
sidecarPath!: string | null;
|
||||
exifInfo?: Exif;
|
||||
tags?: Tag[];
|
||||
sharedLinks!: SharedLinkEntity[];
|
||||
faces!: AssetFace[];
|
||||
stackId?: string | null;
|
||||
stack?: Stack | null;
|
||||
@ -51,6 +50,7 @@ export class AssetEntity {
|
||||
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>) {
|
||||
return qb
|
||||
.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>) {
|
||||
return qb
|
||||
.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) {
|
||||
@ -99,7 +99,7 @@ export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDele
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.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')
|
||||
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
|
||||
).as('faces');
|
||||
@ -136,13 +136,15 @@ export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: stri
|
||||
}
|
||||
|
||||
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'>) {
|
||||
return jsonObjectFrom(eb.selectFrom('libraries').selectAll().whereRef('libraries.id', '=', 'assets.libraryId')).as(
|
||||
'library',
|
||||
);
|
||||
return jsonObjectFrom(
|
||||
eb.selectFrom('libraries').selectAll('libraries').whereRef('libraries.id', '=', 'assets.libraryId'),
|
||||
).as('library');
|
||||
}
|
||||
|
||||
export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
|
||||
|
@ -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;
|
||||
}
|
@ -82,7 +82,7 @@ from
|
||||
where
|
||||
"assets"."id" = any ($1::uuid[])
|
||||
|
||||
-- AssetRepository.getByIdsWithAllRelations
|
||||
-- AssetRepository.getByIdsWithAllRelationsButStacks
|
||||
select
|
||||
"assets".*,
|
||||
(
|
||||
@ -127,28 +127,13 @@ select
|
||||
"assets"."id" = "tag_asset"."assetsId"
|
||||
) as agg
|
||||
) as "tags",
|
||||
to_json("exif") as "exifInfo",
|
||||
to_json("stacked_assets") as "stack"
|
||||
to_json("exif") as "exifInfo"
|
||||
from
|
||||
"assets"
|
||||
left join "exif" on "assets"."id" = "exif"."assetId"
|
||||
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
|
||||
"assets"."id" = any ($2::uuid[])
|
||||
"assets"."id" = any ($1::uuid[])
|
||||
|
||||
-- AssetRepository.deleteAll
|
||||
delete from "assets"
|
||||
|
@ -1,12 +1,11 @@
|
||||
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 { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { columns, Exif } from 'src/database';
|
||||
import { Albums, DB } from 'src/db';
|
||||
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
|
||||
export interface AlbumAssetCount {
|
||||
albumId: string;
|
||||
@ -21,9 +20,9 @@ export interface AlbumInfoOptions {
|
||||
}
|
||||
|
||||
const withOwner = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
||||
return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'albums.ownerId')).as(
|
||||
'owner',
|
||||
);
|
||||
return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'albums.ownerId'))
|
||||
.$notNull()
|
||||
.as('owner');
|
||||
};
|
||||
|
||||
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
||||
@ -32,12 +31,14 @@ const withAlbumUsers = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
||||
.selectFrom('albums_shared_users_users as album_users')
|
||||
.select('album_users.role')
|
||||
.select((eb) =>
|
||||
jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'album_users.usersId')).as(
|
||||
'user',
|
||||
),
|
||||
jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'album_users.usersId'))
|
||||
.$notNull()
|
||||
.as('user'),
|
||||
)
|
||||
.whereRef('album_users.albumsId', '=', 'albums.id'),
|
||||
).as('albumUsers');
|
||||
)
|
||||
.$notNull()
|
||||
.as('albumUsers');
|
||||
};
|
||||
|
||||
const withSharedLink = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
||||
@ -53,7 +54,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.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')
|
||||
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
@ -69,7 +70,7 @@ export class AlbumRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
|
||||
async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | undefined> {
|
||||
async getById(id: string, options: AlbumInfoOptions) {
|
||||
return this.db
|
||||
.selectFrom('albums')
|
||||
.selectAll('albums')
|
||||
@ -79,11 +80,12 @@ export class AlbumRepository {
|
||||
.select(withAlbumUsers)
|
||||
.select(withSharedLink)
|
||||
.$if(options.withAssets, (eb) => eb.select(withAssets))
|
||||
.executeTakeFirst() as Promise<AlbumEntity | undefined>;
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
|
||||
async getByAssetId(ownerId: string, assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('albums')
|
||||
.selectAll('albums')
|
||||
@ -105,7 +107,7 @@ export class AlbumRepository {
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.orderBy('albums.createdAt', 'desc')
|
||||
.execute() as unknown as Promise<AlbumEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@ -134,7 +136,7 @@ export class AlbumRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getOwned(ownerId: string): Promise<AlbumEntity[]> {
|
||||
async getOwned(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('albums')
|
||||
.selectAll('albums')
|
||||
@ -144,14 +146,14 @@ export class AlbumRepository {
|
||||
.where('albums.ownerId', '=', ownerId)
|
||||
.where('albums.deletedAt', 'is', null)
|
||||
.orderBy('albums.createdAt', 'desc')
|
||||
.execute() as unknown as Promise<AlbumEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums shared with and shared by owner.
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getShared(ownerId: string): Promise<AlbumEntity[]> {
|
||||
async getShared(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('albums')
|
||||
.selectAll('albums')
|
||||
@ -176,14 +178,14 @@ export class AlbumRepository {
|
||||
.select(withOwner)
|
||||
.select(withSharedLink)
|
||||
.orderBy('albums.createdAt', 'desc')
|
||||
.execute() as unknown as Promise<AlbumEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums of owner that are _not_ shared
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getNotShared(ownerId: string): Promise<AlbumEntity[]> {
|
||||
async getNotShared(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('albums')
|
||||
.selectAll('albums')
|
||||
@ -203,7 +205,7 @@ export class AlbumRepository {
|
||||
)
|
||||
.select(withOwner)
|
||||
.orderBy('albums.createdAt', 'desc')
|
||||
.execute() as unknown as Promise<AlbumEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
async restoreAll(userId: string): Promise<void> {
|
||||
@ -262,7 +264,7 @@ export class AlbumRepository {
|
||||
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) => {
|
||||
const newAlbum = await tx.insertInto('albums').values(album).returning('albums.id').executeTakeFirst();
|
||||
|
||||
@ -290,11 +292,12 @@ export class AlbumRepository {
|
||||
.select(withOwner)
|
||||
.select(withAssets)
|
||||
.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
|
||||
.updateTable('albums')
|
||||
.set(album)
|
||||
@ -303,7 +306,7 @@ export class AlbumRepository {
|
||||
.returning(withOwner)
|
||||
.returning(withSharedLink)
|
||||
.returning(withAlbumUsers)
|
||||
.executeTakeFirst() as unknown as Promise<AlbumEntity>;
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
|
@ -8,7 +8,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { withExifInner, withFaces, withFiles } from 'src/entities/asset.entity';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { StorageAsset } from 'src/types';
|
||||
import { asUuid } from 'src/utils/database';
|
||||
import { anyUuid, asUuid } from 'src/utils/database';
|
||||
|
||||
@Injectable()
|
||||
export class AssetJobRepository {
|
||||
@ -149,6 +149,21 @@ export class AssetJobRepository {
|
||||
.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() {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
|
@ -1,9 +1,11 @@
|
||||
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 { InjectKysely } from 'nestjs-kysely';
|
||||
import { Stack } from 'src/database';
|
||||
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetEntity,
|
||||
hasPeople,
|
||||
@ -23,7 +25,7 @@ import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
|
||||
import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository';
|
||||
import { anyUuid, asUuid, removeUndefinedKeys, unnest } from 'src/utils/database';
|
||||
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>;
|
||||
|
||||
@ -141,12 +143,12 @@ export interface GetByIdsRelations {
|
||||
|
||||
export interface DuplicateGroup {
|
||||
duplicateId: string;
|
||||
assets: AssetEntity[];
|
||||
assets: MapAsset[];
|
||||
}
|
||||
|
||||
export interface DayOfYearAssets {
|
||||
yearsAgo: number;
|
||||
assets: AssetEntity[];
|
||||
assets: MapAsset[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@ -234,12 +236,12 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
create(asset: Insertable<Assets>): Promise<AssetEntity> {
|
||||
return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise<AssetEntity>;
|
||||
create(asset: Insertable<Assets>) {
|
||||
return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
createAll(assets: Insertable<Assets>[]): Promise<AssetEntity[]> {
|
||||
return this.db.insertInto('assets').values(assets).returningAll().execute() as any as Promise<AssetEntity[]>;
|
||||
createAll(assets: Insertable<Assets>[]) {
|
||||
return this.db.insertInto('assets').values(assets).returningAll().execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
|
||||
@ -299,20 +301,13 @@ export class AssetRepository {
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@ChunkedArray()
|
||||
getByIds(ids: string[]): Promise<AssetEntity[]> {
|
||||
return (
|
||||
this.db
|
||||
//
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.where('assets.id', '=', anyUuid(ids))
|
||||
.execute() as Promise<AssetEntity[]>
|
||||
);
|
||||
getByIds(ids: string[]) {
|
||||
return this.db.selectFrom('assets').selectAll('assets').where('assets.id', '=', anyUuid(ids)).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@ChunkedArray()
|
||||
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]> {
|
||||
getByIdsWithAllRelationsButStacks(ids: string[]) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
@ -320,23 +315,8 @@ export class AssetRepository {
|
||||
.select(withTags)
|
||||
.$call(withExif)
|
||||
.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))
|
||||
.execute() as any as Promise<AssetEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
@ -356,36 +336,29 @@ export class AssetRepository {
|
||||
return assets.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
getByUserId(
|
||||
pagination: PaginationOptions,
|
||||
userId: string,
|
||||
options: Omit<AssetSearchOptions, 'userIds'> = {},
|
||||
): Paginated<AssetEntity> {
|
||||
getByUserId(pagination: PaginationOptions, userId: string, options: Omit<AssetSearchOptions, 'userIds'> = {}) {
|
||||
return this.getAll(pagination, { ...options, userIds: [userId] });
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | undefined> {
|
||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.where('libraryId', '=', asUuid(libraryId))
|
||||
.where('originalPath', '=', originalPath)
|
||||
.limit(1)
|
||||
.executeTakeFirst() as any as Promise<AssetEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getAll(
|
||||
pagination: PaginationOptions,
|
||||
{ orderDirection, ...options }: AssetSearchOptions = {},
|
||||
): Paginated<AssetEntity> {
|
||||
async getAll(pagination: PaginationOptions, { orderDirection, ...options }: AssetSearchOptions = {}) {
|
||||
const builder = searchAssetBuilder(this.db, options)
|
||||
.select(withFiles)
|
||||
.orderBy('assets.createdAt', orderDirection ?? 'asc')
|
||||
.limit(pagination.take + 1)
|
||||
.offset(pagination.skip ?? 0);
|
||||
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] })
|
||||
getById(
|
||||
id: string,
|
||||
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {},
|
||||
): Promise<AssetEntity | undefined> {
|
||||
getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.where('assets.id', '=', asUuid(id))
|
||||
.$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(!!owner, (qb) => qb.select(withOwner))
|
||||
.$if(!!smartSearch, withSmartSearch)
|
||||
.$if(!!stack, (qb) =>
|
||||
qb
|
||||
.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) =>
|
||||
qb
|
||||
.leftJoinLateral(
|
||||
@ -453,13 +425,13 @@ export class AssetRepository {
|
||||
.as('stacked_assets'),
|
||||
(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(!!tags, (qb) => qb.select(withTags))
|
||||
.limit(1)
|
||||
.executeTakeFirst() as any as Promise<AssetEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
|
||||
@ -488,7 +460,7 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async update(asset: Updateable<Assets> & { id: string }): Promise<AssetEntity> {
|
||||
async update(asset: Updateable<Assets> & { id: string }) {
|
||||
const value = omitBy(asset, isUndefined);
|
||||
delete value.id;
|
||||
if (!isEmpty(value)) {
|
||||
@ -498,10 +470,10 @@ export class AssetRepository {
|
||||
.selectAll('assets')
|
||||
.$call(withExif)
|
||||
.$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> {
|
||||
@ -509,7 +481,7 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
@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
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
@ -517,7 +489,7 @@ export class AssetRepository {
|
||||
.where('checksum', '=', checksum)
|
||||
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
|
||||
.limit(1)
|
||||
.executeTakeFirst() as Promise<AssetEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.BUFFER]] })
|
||||
@ -544,7 +516,7 @@ export class AssetRepository {
|
||||
return asset?.id;
|
||||
}
|
||||
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | undefined> {
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions) {
|
||||
const { ownerId, otherAssetId, livePhotoCID, type } = options;
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
@ -555,7 +527,7 @@ export class AssetRepository {
|
||||
.where('type', '=', type)
|
||||
.where('exif.livePhotoCID', '=', livePhotoCID)
|
||||
.limit(1)
|
||||
.executeTakeFirst() as Promise<AssetEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql(
|
||||
@ -564,7 +536,7 @@ export class AssetRepository {
|
||||
params: [DummyValue.PAGINATION, property],
|
||||
})),
|
||||
)
|
||||
async getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity> {
|
||||
async getWithout(pagination: PaginationOptions, property: WithoutProperty) {
|
||||
const items = await this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
@ -626,7 +598,7 @@ export class AssetRepository {
|
||||
.orderBy('createdAt')
|
||||
.execute();
|
||||
|
||||
return paginationHelper(items as any as AssetEntity[], pagination.take);
|
||||
return paginationHelper(items, pagination.take);
|
||||
}
|
||||
|
||||
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
|
||||
@ -645,7 +617,7 @@ export class AssetRepository {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
getRandom(userIds: string[], take: number): Promise<AssetEntity[]> {
|
||||
getRandom(userIds: string[], take: number) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
@ -655,7 +627,7 @@ export class AssetRepository {
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy((eb) => eb.fn('random'))
|
||||
.limit(take)
|
||||
.execute() as any as Promise<AssetEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
|
||||
@ -708,7 +680,7 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
@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
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
@ -741,7 +713,7 @@ export class AssetRepository {
|
||||
.as('stacked_assets'),
|
||||
(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.isDuplicate !== undefined, (qb) =>
|
||||
@ -753,11 +725,11 @@ export class AssetRepository {
|
||||
.where('assets.isVisible', '=', true)
|
||||
.where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.orderBy('assets.localDateTime', options.order ?? 'desc')
|
||||
.execute() as any as Promise<AssetEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getDuplicates(userId: string): Promise<DuplicateGroup[]> {
|
||||
getDuplicates(userId: string) {
|
||||
return (
|
||||
this.db
|
||||
.with('duplicates', (qb) =>
|
||||
@ -774,9 +746,15 @@ export class AssetRepository {
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.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.duplicateId', 'is not', null)
|
||||
.$narrowType<{ duplicateId: NotNull }>()
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.where('assets.isVisible', '=', true)
|
||||
.where('assets.stackId', 'is', null)
|
||||
@ -801,7 +779,7 @@ export class AssetRepository {
|
||||
.where(({ not, exists }) =>
|
||||
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;
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
@ -863,18 +841,18 @@ export class AssetRepository {
|
||||
.as('stacked_assets'),
|
||||
(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.isVisible', '=', true)
|
||||
.where('assets.updatedAt', '<=', updatedUntil)
|
||||
.$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!))
|
||||
.orderBy('assets.id')
|
||||
.limit(limit)
|
||||
.execute() as any as Promise<AssetEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE, limit: 100 }] })
|
||||
async getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]> {
|
||||
async getChangedDeltaSync(options: AssetDeltaSyncOptions) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
@ -891,12 +869,12 @@ export class AssetRepository {
|
||||
.as('stacked_assets'),
|
||||
(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.isVisible', '=', true)
|
||||
.where('assets.updatedAt', '>', options.updatedAfter)
|
||||
.limit(options.limit)
|
||||
.execute() as any as Promise<AssetEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFile(file: Pick<Insertable<AssetFiles>, 'assetId' | 'path' | 'type'>): Promise<void> {
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, OrderByDirection, sql } from 'kysely';
|
||||
import { Kysely, OrderByDirection, Selectable, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DB } from 'src/db';
|
||||
import { DB, Exif } from 'src/db';
|
||||
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 { anyUuid, asUuid } from 'src/utils/database';
|
||||
import { Paginated } from 'src/utils/pagination';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
|
||||
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 items = await searchAssetBuilder(this.db, options)
|
||||
.orderBy('assets.fileCreatedAt', orderDirection)
|
||||
@ -225,7 +225,7 @@ export class SearchRepository {
|
||||
.execute();
|
||||
const hasNextPage = items.length > pagination.size;
|
||||
items.splice(pagination.size);
|
||||
return { items: items as any as AssetEntity[], hasNextPage };
|
||||
return { items, hasNextPage };
|
||||
}
|
||||
|
||||
@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 builder = searchAssetBuilder(this.db, options);
|
||||
const lessThan = builder
|
||||
@ -251,8 +251,8 @@ export class SearchRepository {
|
||||
.where('assets.id', '>', uuid)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(size);
|
||||
const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
|
||||
return rows as any as AssetEntity[];
|
||||
const { rows } = await sql<MapAsset>`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@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 })) {
|
||||
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')
|
||||
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute()) as any as AssetEntity[];
|
||||
.execute();
|
||||
|
||||
const hasNextPage = items.length > pagination.size;
|
||||
items.splice(pagination.size);
|
||||
@ -392,7 +392,7 @@ export class SearchRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
|
||||
getAssetsByCity(userIds: string[]) {
|
||||
return this.db
|
||||
.withRecursive('cte', (qb) => {
|
||||
const base = qb
|
||||
@ -434,9 +434,14 @@ export class SearchRepository {
|
||||
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
||||
.innerJoin('cte', 'assets.id', 'cte.assetId')
|
||||
.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')
|
||||
.execute() as any as Promise<AssetEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsert(assetId: string, embedding: string): Promise<void> {
|
||||
|
@ -1,12 +1,12 @@
|
||||
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 _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { Album, columns } from 'src/database';
|
||||
import { DB, SharedLinks } from 'src/db';
|
||||
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';
|
||||
|
||||
export type SharedLinkSearchOptions = {
|
||||
@ -19,7 +19,7 @@ export class SharedLinkRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
get(userId: string, id: string): Promise<SharedLinkEntity | undefined> {
|
||||
get(userId: string, id: string) {
|
||||
return this.db
|
||||
.selectFrom('shared_links')
|
||||
.selectAll('shared_links')
|
||||
@ -87,18 +87,23 @@ export class SharedLinkRepository {
|
||||
.as('album'),
|
||||
(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".*`])
|
||||
.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.userId', '=', userId)
|
||||
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)]))
|
||||
.orderBy('shared_links.createdAt', 'desc')
|
||||
.executeTakeFirst() as Promise<SharedLinkEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
|
||||
getAll({ userId, albumId }: SharedLinkSearchOptions): Promise<SharedLinkEntity[]> {
|
||||
getAll({ userId, albumId }: SharedLinkSearchOptions) {
|
||||
return this.db
|
||||
.selectFrom('shared_links')
|
||||
.selectAll('shared_links')
|
||||
@ -115,6 +120,7 @@ export class SharedLinkRepository {
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select('assets.assets')
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
@ -152,12 +158,12 @@ export class SharedLinkRepository {
|
||||
.as('album'),
|
||||
(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)]))
|
||||
.$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!))
|
||||
.orderBy('shared_links.createdAt', 'desc')
|
||||
.distinctOn(['shared_links.createdAt'])
|
||||
.execute() as unknown as Promise<SharedLinkEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.BUFFER] })
|
||||
@ -177,7 +183,7 @@ export class SharedLinkRepository {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity> {
|
||||
async create(entity: Insertable<SharedLinks> & { assetIds?: string[] }) {
|
||||
const { id } = await this.db
|
||||
.insertInto('shared_links')
|
||||
.values(_.omit(entity, 'assetIds'))
|
||||
@ -194,7 +200,7 @@ export class SharedLinkRepository {
|
||||
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
|
||||
.updateTable('shared_links')
|
||||
.set(_.omit(entity, 'assets', 'album', 'assetIds'))
|
||||
@ -212,8 +218,8 @@ export class SharedLinkRepository {
|
||||
return this.getSharedLinks(id);
|
||||
}
|
||||
|
||||
async remove(entity: SharedLinkEntity): Promise<void> {
|
||||
await this.db.deleteFrom('shared_links').where('shared_links.id', '=', entity.id).execute();
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('shared_links').where('shared_links.id', '=', id).execute();
|
||||
}
|
||||
|
||||
private getSharedLinks(id: string) {
|
||||
@ -236,9 +242,12 @@ export class SharedLinkRepository {
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.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')
|
||||
.executeTakeFirstOrThrow() as Promise<SharedLinkEntity>;
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { AssetStack, DB } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { asUuid } from 'src/utils/database';
|
||||
|
||||
export interface StackSearch {
|
||||
@ -36,9 +35,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'asset_stack'>, withTags = false)
|
||||
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.whereRef('assets.stackId', '=', 'asset_stack.id'),
|
||||
)
|
||||
.$castTo<AssetEntity[]>()
|
||||
.as('assets');
|
||||
).as('assets');
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
@ -6,15 +6,15 @@ import {
|
||||
AlbumStatisticsResponseDto,
|
||||
CreateAlbumDto,
|
||||
GetAlbumsDto,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
mapAlbum,
|
||||
MapAlbumDto,
|
||||
mapAlbumWithAssets,
|
||||
mapAlbumWithoutAssets,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { Permission } from 'src/enum';
|
||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||
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[]> {
|
||||
await this.albumRepository.updateThumbnails();
|
||||
|
||||
let albums: AlbumEntity[];
|
||||
let albums: MapAlbumDto[];
|
||||
if (assetId) {
|
||||
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
|
||||
} else if (shared === true) {
|
||||
|
@ -8,6 +8,7 @@ import { Stats } from 'node:fs';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.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 { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
@ -173,7 +174,7 @@ const assetEntity = Object.freeze({
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
sidecarPath: null,
|
||||
}) as AssetEntity;
|
||||
} as MapAsset);
|
||||
|
||||
const existingAsset = Object.freeze({
|
||||
...assetEntity,
|
||||
@ -182,18 +183,18 @@ const existingAsset = Object.freeze({
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
}) as AssetEntity;
|
||||
}) as MapAsset;
|
||||
|
||||
const sidecarAsset = Object.freeze({
|
||||
...existingAsset,
|
||||
sidecarPath: 'sidecar-path',
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
}) as AssetEntity;
|
||||
}) as MapAsset;
|
||||
|
||||
const copiedAsset = Object.freeze({
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
}) as AssetEntity;
|
||||
}) as MapAsset;
|
||||
|
||||
describe(AssetMediaService.name, () => {
|
||||
let sut: AssetMediaService;
|
||||
|
@ -2,6 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset } from 'src/database';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
@ -20,7 +21,7 @@ import {
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.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 { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@ -212,7 +213,7 @@ export class AssetMediaService extends BaseService {
|
||||
const asset = await this.findOrFail(id);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
|
||||
const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files);
|
||||
const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files ?? []);
|
||||
let filepath = previewFile?.path;
|
||||
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
|
||||
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,
|
||||
* 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({
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: asset.originalPath,
|
||||
@ -398,12 +399,7 @@ export class AssetMediaService extends BaseService {
|
||||
return created;
|
||||
}
|
||||
|
||||
private async create(
|
||||
ownerId: string,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetEntity> {
|
||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
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 });
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
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 { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
@ -35,7 +34,7 @@ describe(AssetService.name, () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
const mockGetById = (assets: AssetEntity[]) => {
|
||||
const mockGetById = (assets: MapAsset[]) => {
|
||||
mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
|
||||
};
|
||||
|
||||
@ -608,7 +607,7 @@ describe(AssetService.name, () => {
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...assetStub.primaryImage,
|
||||
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
|
||||
} as AssetEntity);
|
||||
});
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import {
|
||||
AssetResponseDto,
|
||||
MapAsset,
|
||||
MemoryLaneResponseDto,
|
||||
SanitizedAssetResponseDto,
|
||||
mapAsset,
|
||||
@ -20,7 +21,6 @@ import {
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.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 { BaseService } from 'src/services/base.service';
|
||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||
@ -43,7 +43,7 @@ export class AssetService extends BaseService {
|
||||
yearsAgo,
|
||||
// TODO move this to clients
|
||||
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 repos = { asset: this.assetRepository, event: this.eventRepository };
|
||||
|
||||
let previousMotion: AssetEntity | null = null;
|
||||
let previousMotion: MapAsset | null = null;
|
||||
if (rest.livePhotoVideoId) {
|
||||
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
|
||||
} 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];
|
||||
|
||||
if (deleteOnDisk) {
|
||||
|
@ -68,7 +68,7 @@ export class DuplicateService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||
const previewFile = getAssetFile(asset.files || [], AssetFileType.PREVIEW);
|
||||
if (!previewFile) {
|
||||
this.logger.warn(`Asset ${id} is missing preview image`);
|
||||
return JobStatus.FAILED;
|
||||
|
@ -285,9 +285,9 @@ describe(JobService.name, () => {
|
||||
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.data.id === 'asset-live-image') {
|
||||
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]);
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.livePhotoStillAsset as any]);
|
||||
} else {
|
||||
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]);
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.livePhotoMotionAsset as any]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -254,7 +254,7 @@ export class JobService extends BaseService {
|
||||
|
||||
case JobName.METADATA_EXTRACTION: {
|
||||
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) {
|
||||
this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
@ -284,7 +284,7 @@ export class JobService extends BaseService {
|
||||
break;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
|
||||
if (!asset) {
|
||||
this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`);
|
||||
break;
|
||||
|
@ -350,7 +350,7 @@ describe(LibraryService.name, () => {
|
||||
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'));
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
@ -371,7 +371,7 @@ describe(LibraryService.name, () => {
|
||||
progressCounter: 0,
|
||||
};
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.external]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
|
||||
mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
@ -392,7 +392,7 @@ describe(LibraryService.name, () => {
|
||||
progressCounter: 0,
|
||||
};
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
@ -410,7 +410,7 @@ describe(LibraryService.name, () => {
|
||||
progressCounter: 0,
|
||||
};
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
@ -431,7 +431,7 @@ describe(LibraryService.name, () => {
|
||||
progressCounter: 0,
|
||||
};
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
@ -451,7 +451,7 @@ describe(LibraryService.name, () => {
|
||||
progressCounter: 0,
|
||||
};
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
@ -471,7 +471,7 @@ describe(LibraryService.name, () => {
|
||||
progressCounter: 0,
|
||||
};
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.external]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
|
||||
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
@ -489,7 +489,7 @@ describe(LibraryService.name, () => {
|
||||
progressCounter: 0,
|
||||
};
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
|
||||
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats);
|
||||
|
||||
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);
|
||||
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.external]);
|
||||
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
|
||||
mocks.storage.stat.mockResolvedValue({ mtime } as Stats);
|
||||
|
||||
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
|
||||
|
@ -18,7 +18,6 @@ import {
|
||||
ValidateLibraryImportPathResponseDto,
|
||||
ValidateLibraryResponseDto,
|
||||
} from 'src/dtos/library.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetStatus, AssetType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.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 })
|
||||
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 trashedAssetIdsToOffline: string[] = [];
|
||||
@ -561,7 +560,16 @@ export class LibraryService extends BaseService {
|
||||
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) {
|
||||
// File not found on disk or permission error
|
||||
if (asset.isOffline) {
|
||||
|
@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { Stats } from 'node:fs';
|
||||
import { constants } from 'node:fs/promises';
|
||||
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 { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
@ -549,7 +549,6 @@ describe(MetadataService.name, () => {
|
||||
livePhotoVideoId: null,
|
||||
libraryId: null,
|
||||
});
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
|
||||
@ -719,7 +718,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
|
||||
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);
|
||||
mocks.storage.readFile.mockResolvedValue(video);
|
||||
@ -1394,7 +1393,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
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(true);
|
||||
|
||||
@ -1446,7 +1445,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
describe('handleSidecarDiscovery', () => {
|
||||
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 });
|
||||
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
@ -271,7 +271,7 @@ export class MetadataService extends BaseService {
|
||||
];
|
||||
|
||||
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)) {
|
||||
|
@ -2,7 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { Insertable, Updateable } from 'kysely';
|
||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
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 { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@ -315,6 +316,7 @@ export class PersonService extends BaseService {
|
||||
const facesToAdd: (Insertable<AssetFaces> & { id: string })[] = [];
|
||||
const embeddings: FaceSearch[] = [];
|
||||
const mlFaceIds = new Set<string>();
|
||||
|
||||
for (const face of asset.faces) {
|
||||
if (face.sourceType === SourceType.MACHINE_LEARNING) {
|
||||
mlFaceIds.add(face.id);
|
||||
@ -477,7 +479,7 @@ export class PersonService extends BaseService {
|
||||
embedding: face.faceSearch.embedding,
|
||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||
numResults: machineLearning.facialRecognition.minFaces,
|
||||
minBirthDate: face.asset.fileCreatedAt,
|
||||
minBirthDate: face.asset.fileCreatedAt ?? undefined,
|
||||
});
|
||||
|
||||
// `matches` also includes the face itself
|
||||
@ -503,7 +505,7 @@ export class PersonService extends BaseService {
|
||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||
numResults: 1,
|
||||
hasPerson: true,
|
||||
minBirthDate: face.asset.fileCreatedAt,
|
||||
minBirthDate: face.asset.fileCreatedAt ?? undefined,
|
||||
});
|
||||
|
||||
if (matchWithPerson.length > 0) {
|
||||
|
@ -45,7 +45,7 @@ describe(SearchService.name, () => {
|
||||
fieldName: 'exifInfo.city',
|
||||
items: [{ value: 'test-city', data: assetStub.withLocation.id }],
|
||||
});
|
||||
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]);
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]);
|
||||
const expectedResponse = [
|
||||
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
|
||||
];
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import {
|
||||
@ -14,7 +14,6 @@ import {
|
||||
SearchSuggestionType,
|
||||
SmartSearchDto,
|
||||
} from 'src/dtos/search.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
import { SearchExploreItem } from 'src/repositories/search.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@ -36,7 +35,7 @@ export class SearchService extends BaseService {
|
||||
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
|
||||
const options = { maxFields: 12, minAssetsPerField: 5 };
|
||||
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 }) }));
|
||||
return [{ fieldName: cities.fieldName, items }];
|
||||
}
|
||||
@ -139,7 +138,7 @@ export class SearchService extends BaseService {
|
||||
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 {
|
||||
albums: { total: 0, count: 0, items: [], facets: [] },
|
||||
assets: {
|
||||
|
@ -244,7 +244,7 @@ describe(SharedLinkService.name, () => {
|
||||
await sut.remove(authStub.user1, 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 () => {
|
||||
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({
|
||||
description: '0 shared photos & videos',
|
||||
imageUrl: `https://my.immich.app/feature-panel.png`,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { SharedLink } from 'src/database';
|
||||
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@ -11,7 +12,6 @@ import {
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { Permission, SharedLinkType } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getExternalDomain, OpenGraphTags } from 'src/utils/misc';
|
||||
@ -98,7 +98,7 @@ export class SharedLinkService extends BaseService {
|
||||
|
||||
async remove(auth: AuthDto, id: string): Promise<void> {
|
||||
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
|
||||
@ -182,7 +182,7 @@ export class SharedLinkService extends BaseService {
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.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
|
||||
? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
|
||||
: '/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);
|
||||
}
|
||||
|
||||
private validateAndRefreshToken(sharedLink: SharedLinkEntity, dto: SharedLinkPasswordDto): string {
|
||||
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
|
||||
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
||||
const sharedLinkTokens = dto.token?.split(',') || [];
|
||||
if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { assetStub } from 'test/fixtures/asset.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 () => {
|
||||
mocks.partner.getAll.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(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
||||
@Injectable()
|
||||
@ -12,6 +11,6 @@ export class ViewService extends BaseService {
|
||||
|
||||
async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
@ -13,11 +13,8 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
|
||||
import { checkAccess } from 'src/utils/access';
|
||||
|
||||
export const getAssetFile = <T extends { type: AssetFileType }>(
|
||||
files: T[],
|
||||
type: AssetFileType | GeneratedImageType,
|
||||
) => {
|
||||
return (files || []).find((file) => file.type === type);
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
|
||||
return files.find((file) => file.type === type);
|
||||
};
|
||||
|
||||
export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
|
22
server/test/fixtures/album.stub.ts
vendored
22
server/test/fixtures/album.stub.ts
vendored
@ -1,11 +1,10 @@
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
|
||||
export const albumStub = {
|
||||
empty: Object.freeze<AlbumEntity>({
|
||||
empty: Object.freeze({
|
||||
id: 'album-1',
|
||||
albumName: 'Empty album',
|
||||
description: '',
|
||||
@ -21,8 +20,9 @@ export const albumStub = {
|
||||
albumUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
updateId: '42',
|
||||
}),
|
||||
sharedWithUser: Object.freeze<AlbumEntity>({
|
||||
sharedWithUser: Object.freeze({
|
||||
id: 'album-2',
|
||||
albumName: 'Empty album shared with user',
|
||||
description: '',
|
||||
@ -43,8 +43,9 @@ export const albumStub = {
|
||||
],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
updateId: '42',
|
||||
}),
|
||||
sharedWithMultiple: Object.freeze<AlbumEntity>({
|
||||
sharedWithMultiple: Object.freeze({
|
||||
id: 'album-3',
|
||||
albumName: 'Empty album shared with users',
|
||||
description: '',
|
||||
@ -69,8 +70,9 @@ export const albumStub = {
|
||||
],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
updateId: '42',
|
||||
}),
|
||||
sharedWithAdmin: Object.freeze<AlbumEntity>({
|
||||
sharedWithAdmin: Object.freeze({
|
||||
id: 'album-3',
|
||||
albumName: 'Empty album shared with admin',
|
||||
description: '',
|
||||
@ -91,8 +93,9 @@ export const albumStub = {
|
||||
],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
updateId: '42',
|
||||
}),
|
||||
oneAsset: Object.freeze<AlbumEntity>({
|
||||
oneAsset: Object.freeze({
|
||||
id: 'album-4',
|
||||
albumName: 'Album with one asset',
|
||||
description: '',
|
||||
@ -108,8 +111,9 @@ export const albumStub = {
|
||||
albumUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
updateId: '42',
|
||||
}),
|
||||
twoAssets: Object.freeze<AlbumEntity>({
|
||||
twoAssets: Object.freeze({
|
||||
id: 'album-4a',
|
||||
albumName: 'Album with two assets',
|
||||
description: '',
|
||||
@ -125,8 +129,9 @@ export const albumStub = {
|
||||
albumUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
updateId: '42',
|
||||
}),
|
||||
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
|
||||
emptyWithValidThumbnail: Object.freeze({
|
||||
id: 'album-5',
|
||||
albumName: 'Empty album with valid thumbnail',
|
||||
description: '',
|
||||
@ -142,5 +147,6 @@ export const albumStub = {
|
||||
albumUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
updateId: '42',
|
||||
}),
|
||||
};
|
||||
|
93
server/test/fixtures/asset.stub.ts
vendored
93
server/test/fixtures/asset.stub.ts
vendored
@ -1,5 +1,5 @@
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetFace, AssetFile, Exif } from 'src/database';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
||||
import { StorageAsset } from 'src/types';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
@ -26,13 +26,15 @@ const fullsizeFile: AssetFile = {
|
||||
|
||||
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||
|
||||
export const stackStub = (stackId: string, assets: AssetEntity[]) => {
|
||||
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
|
||||
return {
|
||||
id: stackId,
|
||||
assets,
|
||||
ownerId: assets[0].ownerId,
|
||||
primaryAsset: assets[0],
|
||||
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,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
}),
|
||||
|
||||
noWebpPath: Object.freeze<AssetEntity>({
|
||||
noWebpPath: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -122,9 +127,12 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
}),
|
||||
|
||||
noThumbhash: Object.freeze<AssetEntity>({
|
||||
noThumbhash: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -156,6 +164,9 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
}),
|
||||
|
||||
primaryImage: Object.freeze({
|
||||
@ -195,12 +206,13 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
stackId: 'stack-1',
|
||||
stack: stackStub('stack-1', [
|
||||
{ id: 'primary-asset-id' } as AssetEntity,
|
||||
{ id: 'stack-child-asset-1' } as AssetEntity,
|
||||
{ id: 'stack-child-asset-2' } as AssetEntity,
|
||||
{ id: 'primary-asset-id' } as MapAsset & { exifInfo: Exif },
|
||||
{ id: 'stack-child-asset-1' } as MapAsset & { exifInfo: Exif },
|
||||
{ id: 'stack-child-asset-2' } as MapAsset & { exifInfo: Exif },
|
||||
]),
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
}),
|
||||
|
||||
@ -229,6 +241,9 @@ export const assetStub = {
|
||||
isExternal: false,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
updateId: 'foo',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.jpg',
|
||||
faces: [],
|
||||
@ -241,10 +256,10 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
stack: null,
|
||||
}),
|
||||
|
||||
trashed: Object.freeze<AssetEntity>({
|
||||
trashed: Object.freeze({
|
||||
id: 'asset-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
@ -281,9 +296,12 @@ export const assetStub = {
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
status: AssetStatus.TRASHED,
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
}),
|
||||
|
||||
trashedOffline: Object.freeze<AssetEntity>({
|
||||
trashedOffline: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -321,8 +339,10 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: true,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
}),
|
||||
archived: Object.freeze<AssetEntity>({
|
||||
archived: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -359,9 +379,12 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
}),
|
||||
|
||||
external: Object.freeze<AssetEntity>({
|
||||
external: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -397,9 +420,12 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
stackId: null,
|
||||
stack: null,
|
||||
}),
|
||||
|
||||
image1: Object.freeze<AssetEntity>({
|
||||
image1: Object.freeze({
|
||||
id: 'asset-id-1',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -434,9 +460,13 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
stackId: null,
|
||||
libraryId: null,
|
||||
stack: null,
|
||||
}),
|
||||
|
||||
imageFrom2015: Object.freeze<AssetEntity>({
|
||||
imageFrom2015: Object.freeze({
|
||||
id: 'asset-id-1',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -510,7 +540,9 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
}),
|
||||
|
||||
livePhotoMotionAsset: Object.freeze({
|
||||
@ -527,7 +559,7 @@ export const assetStub = {
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
libraryId: null,
|
||||
} as AssetEntity & { libraryId: string | null; files: AssetFile[]; exifInfo: Exif }),
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
|
||||
|
||||
livePhotoStillAsset: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
@ -544,7 +576,8 @@ export const assetStub = {
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
files,
|
||||
} as AssetEntity & { libraryId: string | null }),
|
||||
faces: [] as AssetFace[],
|
||||
} as MapAsset & { faces: AssetFace[] }),
|
||||
|
||||
livePhotoWithOriginalFileName: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
@ -562,7 +595,8 @@ export const assetStub = {
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
libraryId: null,
|
||||
} as AssetEntity & { libraryId: string | null }),
|
||||
faces: [] as AssetFace[],
|
||||
} as MapAsset & { faces: AssetFace[] }),
|
||||
|
||||
withLocation: Object.freeze({
|
||||
id: 'asset-with-favorite-id',
|
||||
@ -590,6 +624,9 @@ export const assetStub = {
|
||||
isVisible: true,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
updateId: 'foo',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
faces: [],
|
||||
@ -604,7 +641,7 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
tags: [],
|
||||
}),
|
||||
|
||||
sidecar: Object.freeze({
|
||||
@ -639,10 +676,12 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: 'foo',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
}),
|
||||
|
||||
sidecarWithoutExt: Object.freeze<AssetEntity>({
|
||||
sidecarWithoutExt: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -676,7 +715,7 @@ export const assetStub = {
|
||||
isOffline: false,
|
||||
}),
|
||||
|
||||
hasEncodedVideo: Object.freeze<AssetEntity>({
|
||||
hasEncodedVideo: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
originalFileName: 'asset-id.ext',
|
||||
@ -711,9 +750,13 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
stack: null,
|
||||
}),
|
||||
|
||||
hasFileExtension: Object.freeze<AssetEntity>({
|
||||
hasFileExtension: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -788,6 +831,9 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
}),
|
||||
|
||||
imageHif: Object.freeze({
|
||||
@ -827,5 +873,8 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
}),
|
||||
};
|
||||
|
9
server/test/fixtures/auth.stub.ts
vendored
9
server/test/fixtures/auth.stub.ts
vendored
@ -1,6 +1,5 @@
|
||||
import { Session } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
|
||||
const authUser = {
|
||||
admin: {
|
||||
@ -42,14 +41,16 @@ export const authStub = {
|
||||
id: 'token-id',
|
||||
} as Session,
|
||||
}),
|
||||
adminSharedLink: Object.freeze<AuthDto>({
|
||||
adminSharedLink: Object.freeze({
|
||||
user: authUser.admin,
|
||||
sharedLink: {
|
||||
id: '123',
|
||||
showExif: true,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
key: Buffer.from('shared-link-key'),
|
||||
} as SharedLinkEntity,
|
||||
expiresAt: null,
|
||||
password: null,
|
||||
userId: '42',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
31
server/test/fixtures/shared-link.stub.ts
vendored
31
server/test/fixtures/shared-link.stub.ts
vendored
@ -1,10 +1,9 @@
|
||||
import { UserAdmin } from 'src/database';
|
||||
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 { SharedLinkResponseDto } from 'src/dtos/shared-link.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 { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
@ -113,12 +112,12 @@ export const sharedLinkStub = {
|
||||
allowUpload: true,
|
||||
allowDownload: true,
|
||||
showExif: true,
|
||||
album: undefined,
|
||||
albumId: null,
|
||||
album: null,
|
||||
description: null,
|
||||
assets: [assetStub.image],
|
||||
password: 'password',
|
||||
albumId: null,
|
||||
} as SharedLinkEntity),
|
||||
}),
|
||||
valid: Object.freeze({
|
||||
id: '123',
|
||||
userId: authStub.admin.user.id,
|
||||
@ -130,12 +129,12 @@ export const sharedLinkStub = {
|
||||
allowUpload: true,
|
||||
allowDownload: true,
|
||||
showExif: true,
|
||||
album: undefined,
|
||||
albumId: null,
|
||||
description: null,
|
||||
password: null,
|
||||
assets: [],
|
||||
} as SharedLinkEntity),
|
||||
assets: [] as MapAsset[],
|
||||
album: null,
|
||||
}),
|
||||
expired: Object.freeze({
|
||||
id: '123',
|
||||
userId: authStub.admin.user.id,
|
||||
@ -150,9 +149,10 @@ export const sharedLinkStub = {
|
||||
description: null,
|
||||
password: null,
|
||||
albumId: null,
|
||||
assets: [],
|
||||
} as SharedLinkEntity),
|
||||
readonlyNoExif: Object.freeze<SharedLinkEntity>({
|
||||
assets: [] as MapAsset[],
|
||||
album: null,
|
||||
}),
|
||||
readonlyNoExif: Object.freeze({
|
||||
id: '123',
|
||||
userId: authStub.admin.user.id,
|
||||
key: sharedLinkBytes,
|
||||
@ -168,6 +168,7 @@ export const sharedLinkStub = {
|
||||
albumId: 'album-123',
|
||||
album: {
|
||||
id: 'album-123',
|
||||
updateId: '42',
|
||||
ownerId: authStub.admin.user.id,
|
||||
owner: userStub.admin,
|
||||
albumName: 'Test Album',
|
||||
@ -239,17 +240,22 @@ export const sharedLinkStub = {
|
||||
colorspace: 'sRGB',
|
||||
autoStackId: null,
|
||||
rating: 3,
|
||||
updatedAt: today,
|
||||
updateId: '42',
|
||||
},
|
||||
sharedLinks: [],
|
||||
faces: [],
|
||||
sidecarPath: null,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
passwordRequired: Object.freeze<SharedLinkEntity>({
|
||||
passwordRequired: Object.freeze({
|
||||
id: '123',
|
||||
userId: authStub.admin.user.id,
|
||||
key: sharedLinkBytes,
|
||||
@ -263,6 +269,7 @@ export const sharedLinkStub = {
|
||||
password: 'password',
|
||||
assets: [],
|
||||
albumId: null,
|
||||
album: null,
|
||||
}),
|
||||
};
|
||||
|
||||
|
@ -39,9 +39,12 @@ describe(MemoryService.name, () => {
|
||||
it('should create a memory from an asset', async () => {
|
||||
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 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 userRepo = getRepository('user');
|
||||
@ -86,7 +89,7 @@ describe(MemoryService.name, () => {
|
||||
it('should not generate a memory twice for the same day', async () => {
|
||||
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 memoryRepo = getRepository('memory');
|
||||
|
@ -118,7 +118,7 @@ describe(MetadataService.name, () => {
|
||||
process.env.TZ = serverTimeZone ?? undefined;
|
||||
|
||||
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' });
|
||||
|
||||
|
@ -11,7 +11,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
upsertJobStatus: vitest.fn(),
|
||||
getByDayOfYear: vitest.fn(),
|
||||
getByIds: vitest.fn().mockResolvedValue([]),
|
||||
getByIdsWithAllRelations: vitest.fn().mockResolvedValue([]),
|
||||
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
|
||||
getByDeviceIds: vitest.fn(),
|
||||
getByUserId: vitest.fn(),
|
||||
getById: vitest.fn(),
|
||||
|
@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
Activity,
|
||||
ApiKey,
|
||||
Asset,
|
||||
AuthApiKey,
|
||||
AuthSharedLink,
|
||||
AuthUser,
|
||||
@ -14,6 +13,7 @@ import {
|
||||
User,
|
||||
UserAdmin,
|
||||
} from 'src/database';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
|
||||
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(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
|
Loading…
x
Reference in New Issue
Block a user