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