refactor(server): flatten infra folders (#2120)

* refactor: flatten infra folders

* fix: database migrations

* fix: test related import

* fix: github actions workflow

* chore: rename schemas to typesense-schemas
This commit is contained in:
Jason Rasmussen
2023-03-30 15:38:55 -04:00
committed by GitHub
parent 468e620372
commit 34d300d1da
176 changed files with 185 additions and 176 deletions

View File

@@ -0,0 +1,130 @@
import { AlbumAssetCount, IAlbumRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { dataSource } from '../database.config';
import { AlbumEntity } from '../entities';
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>) {}
getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({
where: {
id: In(ids),
},
relations: {
owner: true,
},
});
}
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
return this.repository.find({
where: { ownerId, assets: { id: assetId } },
relations: { owner: true, sharedUsers: true },
order: { createdAt: 'DESC' },
});
}
async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> {
// Guard against running invalid query when ids list is empty.
if (!ids.length) {
return [];
}
// Only possible with query builder because of GROUP BY.
const countByAlbums = await this.repository
.createQueryBuilder('album')
.select('album.id')
.addSelect('COUNT(albums_assets.assetsId)', 'asset_count')
.leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id')
.where('album.id IN (:...ids)', { ids })
.groupBy('album.id')
.getRawMany();
return countByAlbums.map<AlbumAssetCount>((albumCount) => ({
albumId: albumCount['album_id'],
assetCount: Number(albumCount['asset_count']),
}));
}
/**
* Returns the album IDs that have an invalid thumbnail, when:
* - Thumbnail references an asset outside the album
* - Empty album still has a thumbnail set
*/
async getInvalidThumbnail(): Promise<string[]> {
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = dataSource
.createQueryBuilder()
.select('1')
.from('albums_assets_assets', 'albums_assets')
.where('"albums"."id" = "albums_assets"."albumsId"');
const albumContainsThumbnail = albumHasAssets
.clone()
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
const albums = await this.repository
.createQueryBuilder('albums')
.select('albums.id')
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`)
.getMany();
return albums.map((album) => album.id);
}
getOwned(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: { ownerId },
order: { createdAt: 'DESC' },
});
}
/**
* Get albums shared with and shared by owner.
*/
getShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: [
{ sharedUsers: { id: ownerId } },
{ sharedLinks: { userId: ownerId } },
{ ownerId, sharedUsers: { id: Not(IsNull()) } },
],
order: { createdAt: 'DESC' },
});
}
/**
* Get albums of owner that are _not_ shared
*/
getNotShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } },
order: { createdAt: 'DESC' },
});
}
async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ ownerId: userId });
}
getAll(): Promise<AlbumEntity[]> {
return this.repository.find({
relations: {
owner: true,
},
});
}
async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({ where: { id } });
}
}

View File

@@ -0,0 +1,49 @@
import { IKeyRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { APIKeyEntity } from '../entities';
@Injectable()
export class APIKeyRepository implements IKeyRepository {
constructor(@InjectRepository(APIKeyEntity) private repository: Repository<APIKeyEntity>) {}
async create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
return this.repository.save(dto);
}
async update(userId: string, id: string, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
await this.repository.update({ userId, id }, dto);
return this.repository.findOneOrFail({ where: { id: dto.id } });
}
async delete(userId: string, id: string): Promise<void> {
await this.repository.delete({ userId, id });
}
async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ userId });
}
getKey(hashedToken: string): Promise<APIKeyEntity | null> {
return this.repository.findOne({
select: {
id: true,
key: true,
userId: true,
},
where: { key: hashedToken },
relations: {
user: true,
},
});
}
getById(userId: string, id: string): Promise<APIKeyEntity | null> {
return this.repository.findOne({ where: { userId, id } });
}
getByUserId(userId: string): Promise<APIKeyEntity[]> {
return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } });
}
}

View File

@@ -0,0 +1,145 @@
import { AssetSearchOptions, IAssetRepository, WithoutProperty } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '../entities';
@Injectable()
export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>) {}
getByIds(ids: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { id: In(ids) },
relations: {
exifInfo: true,
smartInfo: true,
tags: true,
},
});
}
async deleteAll(ownerId: string): Promise<void> {
await this.repository.delete({ ownerId });
}
getAll(options?: AssetSearchOptions | undefined): Promise<AssetEntity[]> {
options = options || {};
return this.repository.find({
where: {
isVisible: options.isVisible,
type: options.type,
},
relations: {
exifInfo: true,
smartInfo: true,
tags: true,
},
});
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
const { id } = await this.repository.save(asset);
return this.repository.findOneOrFail({
where: { id },
relations: {
exifInfo: true,
owner: true,
smartInfo: true,
tags: true,
},
});
}
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
return this.repository.findOne({
where: {
id: Not(otherAssetId),
type,
exifInfo: {
livePhotoCID,
},
},
relations: {
exifInfo: true,
},
});
}
getWithout(property: WithoutProperty): Promise<AssetEntity[]> {
let relations: FindOptionsRelations<AssetEntity> = {};
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithoutProperty.THUMBNAIL:
where = [
{ resizePath: IsNull(), isVisible: true },
{ resizePath: '', isVisible: true },
{ webpPath: IsNull(), isVisible: true },
{ webpPath: '', isVisible: true },
];
break;
case WithoutProperty.ENCODED_VIDEO:
where = [
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() },
{ type: AssetType.VIDEO, encodedVideoPath: '' },
];
break;
case WithoutProperty.EXIF:
relations = {
exifInfo: true,
};
where = {
isVisible: true,
resizePath: Not(IsNull()),
exifInfo: {
assetId: IsNull(),
},
};
break;
case WithoutProperty.CLIP_ENCODING:
relations = {
smartInfo: true,
};
where = {
isVisible: true,
smartInfo: {
clipEmbedding: IsNull(),
},
};
break;
case WithoutProperty.OBJECT_TAGS:
relations = {
smartInfo: true,
};
where = {
resizePath: IsNull(),
isVisible: true,
smartInfo: {
tags: IsNull(),
},
};
break;
default:
throw new Error(`Invalid getWithout property: ${property}`);
}
return this.repository.find({
relations,
where,
});
}
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
order: { fileCreatedAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,12 @@
import { CommunicationEvent } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { CommunicationGateway } from '../communication.gateway';
@Injectable()
export class CommunicationRepository {
constructor(private ws: CommunicationGateway) {}
send(event: CommunicationEvent, userId: string, data: any) {
this.ws.server.to(userId).emit(event, JSON.stringify(data));
}
}

View File

@@ -0,0 +1,16 @@
import { ICryptoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import { randomBytes, createHash } from 'crypto';
@Injectable()
export class CryptoRepository implements ICryptoRepository {
randomBytes = randomBytes;
hashBcrypt = hash;
compareBcrypt = compareSync;
hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64');
}
}

View File

@@ -0,0 +1,16 @@
import { IDeviceInfoRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeviceInfoEntity } from '../entities';
export class DeviceInfoRepository implements IDeviceInfoRepository {
constructor(@InjectRepository(DeviceInfoEntity) private repository: Repository<DeviceInfoEntity>) {}
get(userId: string, deviceId: string): Promise<DeviceInfoEntity | null> {
return this.repository.findOne({ where: { userId, deviceId } });
}
save(entity: Partial<DeviceInfoEntity>): Promise<DeviceInfoEntity> {
return this.repository.save(entity);
}
}

View File

@@ -0,0 +1,74 @@
import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs from 'fs/promises';
import mv from 'mv';
import { promisify } from 'node:util';
import diskUsage from 'diskusage';
import path from 'path';
const moveFile = promisify<string, string, mv.Options>(mv);
export class FilesystemProvider implements IStorageRepository {
async createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream> {
const { size } = await fs.stat(filepath);
await fs.access(filepath, constants.R_OK | constants.W_OK);
return {
stream: createReadStream(filepath),
length: size,
type: mimeType,
};
}
async moveFile(source: string, destination: string): Promise<void> {
await moveFile(source, destination, { mkdirp: true, clobber: false });
}
async checkFileExists(filepath: string): Promise<boolean> {
try {
await fs.access(filepath, constants.F_OK);
return true;
} catch (_) {
return false;
}
}
async unlink(file: string) {
await fs.unlink(file);
}
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
await fs.rm(folder, options);
}
async removeEmptyDirs(directory: string) {
this._removeEmptyDirs(directory, false);
}
private async _removeEmptyDirs(directory: string, self: boolean) {
// lstat does not follow symlinks (in contrast to stat)
const stats = await fs.lstat(directory);
if (!stats.isDirectory()) {
return;
}
const files = await fs.readdir(directory);
await Promise.all(files.map((file) => this._removeEmptyDirs(path.join(directory, file), true)));
if (self) {
const updated = await fs.readdir(directory);
if (updated.length === 0) {
await fs.rmdir(directory);
}
}
}
mkdirSync(filepath: string): void {
if (!existsSync(filepath)) {
mkdirSync(filepath, { recursive: true });
}
}
checkDiskUsage(folder: string): Promise<DiskUsage> {
return diskUsage.check(folder);
}
}

View File

@@ -0,0 +1,16 @@
export * from './album.repository';
export * from './api-key.repository';
export * from './asset.repository';
export * from './communication.repository';
export * from './crypto.repository';
export * from './device-info.repository';
export * from './filesystem.provider';
export * from './job.repository';
export * from './machine-learning.repository';
export * from './media.repository';
export * from './shared-link.repository';
export * from './smart-info.repository';
export * from './system-config.repository';
export * from './typesense.repository';
export * from './user-token.repository';
export * from './user.repository';

View File

@@ -0,0 +1,133 @@
import {
IAssetJob,
IBaseJob,
IJobRepository,
IMetadataExtractionJob,
JobCounts,
JobItem,
JobName,
QueueName,
} from '@app/domain';
import { InjectQueue } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Queue, type JobCounts as BullJobCounts } from 'bull';
export class JobRepository implements IJobRepository {
private logger = new Logger(JobRepository.name);
private queueMap: Record<QueueName, Queue> = {
[QueueName.STORAGE_TEMPLATE_MIGRATION]: this.storageTemplateMigration,
[QueueName.THUMBNAIL_GENERATION]: this.generateThumbnail,
[QueueName.METADATA_EXTRACTION]: this.metadataExtraction,
[QueueName.OBJECT_TAGGING]: this.objectTagging,
[QueueName.CLIP_ENCODING]: this.clipEmbedding,
[QueueName.VIDEO_CONVERSION]: this.videoTranscode,
[QueueName.BACKGROUND_TASK]: this.backgroundTask,
[QueueName.SEARCH]: this.searchIndex,
};
constructor(
@InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue,
@InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.CLIP_ENCODING) private clipEmbedding: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IMetadataExtractionJob | IBaseJob>,
@InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
) {}
async isActive(name: QueueName): Promise<boolean> {
const counts = await this.getJobCounts(name);
return !!counts.active;
}
pause(name: QueueName) {
return this.queueMap[name].pause();
}
resume(name: QueueName) {
return this.queueMap[name].resume();
}
empty(name: QueueName) {
return this.queueMap[name].empty();
}
getJobCounts(name: QueueName): Promise<JobCounts> {
// Typecast needed because the `paused` key is missing from Bull's
// type definition. Can be removed once fixed upstream.
return this.queueMap[name].getJobCounts() as Promise<BullJobCounts & { paused: number }>;
}
async queue(item: JobItem): Promise<void> {
switch (item.name) {
case JobName.ASSET_UPLOADED:
await this.backgroundTask.add(item.name, item.data, { jobId: item.data.asset.id });
break;
case JobName.DELETE_FILES:
await this.backgroundTask.add(item.name, item.data);
break;
case JobName.QUEUE_OBJECT_TAGGING:
case JobName.DETECT_OBJECTS:
case JobName.CLASSIFY_IMAGE:
await this.objectTagging.add(item.name, item.data);
break;
case JobName.QUEUE_ENCODE_CLIP:
case JobName.ENCODE_CLIP:
await this.clipEmbedding.add(item.name, item.data);
break;
case JobName.QUEUE_METADATA_EXTRACTION:
case JobName.EXIF_EXTRACTION:
case JobName.EXTRACT_VIDEO_METADATA:
case JobName.REVERSE_GEOCODING:
await this.metadataExtraction.add(item.name, item.data);
break;
case JobName.QUEUE_GENERATE_THUMBNAILS:
case JobName.GENERATE_JPEG_THUMBNAIL:
case JobName.GENERATE_WEBP_THUMBNAIL:
await this.generateThumbnail.add(item.name, item.data);
break;
case JobName.USER_DELETION:
await this.backgroundTask.add(item.name, item.data);
break;
case JobName.STORAGE_TEMPLATE_MIGRATION:
await this.storageTemplateMigration.add(item.name);
break;
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE:
await this.storageTemplateMigration.add(item.name, item.data);
break;
case JobName.SYSTEM_CONFIG_CHANGE:
await this.backgroundTask.add(item.name, {});
break;
case JobName.QUEUE_VIDEO_CONVERSION:
case JobName.VIDEO_CONVERSION:
await this.videoTranscode.add(item.name, item.data);
break;
case JobName.SEARCH_INDEX_ASSETS:
case JobName.SEARCH_INDEX_ALBUMS:
await this.searchIndex.add(item.name, {});
break;
case JobName.SEARCH_INDEX_ASSET:
case JobName.SEARCH_INDEX_ALBUM:
case JobName.SEARCH_REMOVE_ALBUM:
case JobName.SEARCH_REMOVE_ASSET:
await this.searchIndex.add(item.name, item.data);
break;
default:
this.logger.error('Invalid job', item);
}
}
}

View File

@@ -0,0 +1,24 @@
import { IMachineLearningRepository, MachineLearningInput, MACHINE_LEARNING_URL } from '@app/domain';
import { Injectable } from '@nestjs/common';
import axios from 'axios';
const client = axios.create({ baseURL: MACHINE_LEARNING_URL });
@Injectable()
export class MachineLearningRepository implements IMachineLearningRepository {
classifyImage(input: MachineLearningInput): Promise<string[]> {
return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
}
detectObjects(input: MachineLearningInput): Promise<string[]> {
return client.post<string[]>('/object-detection/detect-object', input).then((res) => res.data);
}
encodeImage(input: MachineLearningInput): Promise<number[]> {
return client.post<number[]>('/sentence-transformer/encode-image', input).then((res) => res.data);
}
encodeText(input: string): Promise<number[]> {
return client.post<number[]>('/sentence-transformer/encode-text', { text: input }).then((res) => res.data);
}
}

View File

@@ -0,0 +1,37 @@
import { IMediaRepository, ResizeOptions } from '@app/domain';
import { exiftool } from 'exiftool-vendored';
import ffmpeg from 'fluent-ffmpeg';
import sharp from 'sharp';
export class MediaRepository implements IMediaRepository {
extractThumbnailFromExif(input: string, output: string): Promise<void> {
return exiftool.extractThumbnail(input, output);
}
async resize(input: string, output: string, options: ResizeOptions): Promise<void> {
switch (options.format) {
case 'webp':
await sharp(input, { failOnError: false }).resize(250).webp().rotate().toFile(output);
return;
case 'jpeg':
await sharp(input, { failOnError: false })
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.jpeg()
.rotate()
.toFile(output);
return;
}
}
extractVideoThumbnail(input: string, output: string) {
return new Promise<void>((resolve, reject) => {
ffmpeg(input)
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
.output(output)
.on('error', reject)
.on('end', resolve)
.run();
});
}
}

View File

@@ -0,0 +1,117 @@
import { ISharedLinkRepository } from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SharedLinkEntity } from '../entities';
@Injectable()
export class SharedLinkRepository implements ISharedLinkRepository {
readonly logger = new Logger(SharedLinkRepository.name);
constructor(
@InjectRepository(SharedLinkEntity)
private readonly repository: Repository<SharedLinkEntity>,
) {}
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
assets: {
exifInfo: true,
},
album: {
assets: {
exifInfo: true,
},
owner: true,
},
},
order: {
createdAt: 'DESC',
assets: {
fileCreatedAt: 'ASC',
},
album: {
assets: {
fileCreatedAt: 'ASC',
},
},
},
});
}
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
assets: true,
album: {
owner: true,
},
},
order: {
createdAt: 'DESC',
},
});
}
async getByKey(key: string): Promise<SharedLinkEntity | null> {
return await this.repository.findOne({
where: {
key: Buffer.from(key, 'hex'),
},
relations: {
assets: true,
album: {
assets: true,
},
user: true,
},
order: {
createdAt: 'DESC',
},
});
}
create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity> {
return this.repository.save(entity);
}
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return this.repository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
const count1 = await this.repository.count({
where: {
id,
assets: {
id: assetId,
},
},
});
const count2 = await this.repository.count({
where: {
id,
album: {
assets: {
id: assetId,
},
},
},
});
return Boolean(count1 + count2);
}
}

View File

@@ -0,0 +1,14 @@
import { ISmartInfoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SmartInfoEntity } from '../entities';
@Injectable()
export class SmartInfoRepository implements ISmartInfoRepository {
constructor(@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>) {}
async upsert(info: Partial<SmartInfoEntity>): Promise<void> {
await this.repository.upsert(info, { conflictPaths: ['assetId'] });
}
}

View File

@@ -0,0 +1,23 @@
import { ISystemConfigRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { SystemConfigEntity } from '../entities';
export class SystemConfigRepository implements ISystemConfigRepository {
constructor(
@InjectRepository(SystemConfigEntity)
private repository: Repository<SystemConfigEntity>,
) {}
load(): Promise<SystemConfigEntity<string | boolean>[]> {
return this.repository.find();
}
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);
}
async deleteKeys(keys: string[]): Promise<void> {
await this.repository.delete({ key: In(keys) });
}
}

View File

@@ -0,0 +1,406 @@
import {
ISearchRepository,
SearchCollection,
SearchCollectionIndexStatus,
SearchExploreItem,
SearchFilter,
SearchResult,
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import _, { Dictionary } from 'lodash';
import { catchError, filter, firstValueFrom, from, map, mergeMap, of, toArray } from 'rxjs';
import { Client } from 'typesense';
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
import { AlbumEntity, AssetEntity } from '../entities';
import { typesenseConfig } from '../infra.config';
import { albumSchema, assetSchema } from '../typesense-schemas';
function removeNil<T extends Dictionary<any>>(item: T): T {
_.forOwn(item, (value, key) => {
if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) {
delete item[key];
}
});
return item;
}
interface CustomAssetEntity extends AssetEntity {
geo?: [number, number];
motion?: boolean;
}
const schemaMap: Record<SearchCollection, CollectionCreateSchema> = {
[SearchCollection.ASSETS]: assetSchema,
[SearchCollection.ALBUMS]: albumSchema,
};
const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][];
@Injectable()
export class TypesenseRepository implements ISearchRepository {
private logger = new Logger(TypesenseRepository.name);
private _client: Client | null = null;
private get client(): Client {
if (!this._client) {
throw new Error('Typesense client not available (no apiKey was provided)');
}
return this._client;
}
constructor() {
if (!typesenseConfig.apiKey) {
return;
}
this._client = new Client(typesenseConfig);
}
async setup(): Promise<void> {
const collections = await this.client.collections().retrieve();
for (const collection of collections) {
this.logger.debug(`${collection.name} => ${collection.num_documents}`);
// await this.client.collections(collection.name).delete();
}
// upsert collections
for (const [collectionName, schema] of schemas) {
const collection = await this.client
.collections(schema.name)
.retrieve()
.catch(() => null);
if (!collection) {
this.logger.log(`Creating schema: ${collectionName}/${schema.name}`);
await this.client.collections().create(schema);
} else {
this.logger.log(`Schema up to date: ${collectionName}/${schema.name}`);
}
}
}
async checkMigrationStatus(): Promise<SearchCollectionIndexStatus> {
const migrationMap: SearchCollectionIndexStatus = {
[SearchCollection.ASSETS]: false,
[SearchCollection.ALBUMS]: false,
};
// check if alias is using the current schema
const { aliases } = await this.client.aliases().retrieve();
this.logger.log(`Alias mapping: ${JSON.stringify(aliases)}`);
for (const [aliasName, schema] of schemas) {
const match = aliases.find((alias) => alias.name === aliasName);
if (!match || match.collection_name !== schema.name) {
migrationMap[aliasName] = true;
}
}
this.logger.log(`Collections needing migration: ${JSON.stringify(migrationMap)}`);
return migrationMap;
}
async importAlbums(items: AlbumEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.ALBUMS, items, done);
}
async importAssets(items: AssetEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.ASSETS, items, done);
}
private async import(
collection: SearchCollection,
items: AlbumEntity[] | AssetEntity[],
done: boolean,
): Promise<void> {
try {
if (items.length > 0) {
await this.client.collections(schemaMap[collection].name).documents().import(this.patch(collection, items), {
action: 'upsert',
dirty_values: 'coerce_or_drop',
});
}
if (done) {
await this.updateAlias(collection);
}
} catch (error: any) {
this.handleError(error);
}
}
async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> {
const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve();
const common = {
q: '*',
filter_by: this.buildFilterBy('ownerId', userId, true),
per_page: 100,
};
const asset$ = this.client.collections<AssetEntity>(alias.collection_name).documents();
const { facet_counts: facets } = await asset$.search({
...common,
query_by: 'exifInfo.imageName',
facet_by: 'exifInfo.city,smartInfo.objects',
max_facet_values: 12,
});
return firstValueFrom(
from(facets || []).pipe(
mergeMap(
(facet) =>
from(facet.counts).pipe(
mergeMap((count) => {
const config = {
...common,
query_by: 'exifInfo.imageName',
filter_by: [
this.buildFilterBy('ownerId', userId, true),
this.buildFilterBy(facet.field_name, count.value, true),
].join(' && '),
per_page: 1,
};
this.logger.verbose(`Explore subquery: "filter_by:${config.filter_by}" (count:${count.count})`);
return from(asset$.search(config)).pipe(
catchError((error: any) => {
this.logger.warn(`Explore subquery error: ${error}`, error?.stack);
return of({ hits: [] });
}),
map((result) => ({
value: count.value,
data: result.hits?.[0]?.document as AssetEntity,
})),
filter((item) => !!item.data),
);
}, 5),
toArray(),
map((items) => ({
fieldName: facet.field_name as string,
items,
})),
),
3,
),
toArray(),
),
);
}
async deleteAlbums(ids: string[]): Promise<void> {
await this.delete(SearchCollection.ALBUMS, ids);
}
async deleteAssets(ids: string[]): Promise<void> {
await this.delete(SearchCollection.ASSETS, ids);
}
async delete(collection: SearchCollection, ids: string[]): Promise<void> {
await this.client
.collections(schemaMap[collection].name)
.documents()
.delete({ filter_by: this.buildFilterBy('id', ids, true) });
}
async searchAlbums(query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>> {
const alias = await this.client.aliases(SearchCollection.ALBUMS).retrieve();
const results = await this.client
.collections<AlbumEntity>(alias.collection_name)
.documents()
.search({
q: query,
query_by: 'albumName',
filter_by: this.getAlbumFilters(filters),
});
return this.asResponse(results, filters.debug);
}
async searchAssets(query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve();
const results = await this.client
.collections<AssetEntity>(alias.collection_name)
.documents()
.search({
q: query,
query_by: [
'exifInfo.imageName',
'exifInfo.country',
'exifInfo.state',
'exifInfo.city',
'exifInfo.description',
'smartInfo.tags',
'smartInfo.objects',
].join(','),
per_page: 250,
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
filter_by: this.getAssetFilters(filters),
sort_by: filters.recent ? 'createdAt:desc' : undefined,
});
return this.asResponse(results, filters.debug);
}
async vectorSearch(input: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve();
const { results } = await this.client.multiSearch.perform({
searches: [
{
collection: alias.collection_name,
q: '*',
vector_query: `smartInfo.clipEmbedding:([${input.join(',')}], k:100)`,
per_page: 250,
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
filter_by: this.getAssetFilters(filters),
} as any,
],
});
return this.asResponse(results[0] as SearchResponse<AssetEntity>, filters.debug);
}
private asResponse<T extends DocumentSchema>(results: SearchResponse<T>, debug?: boolean): SearchResult<T> {
return {
page: results.page,
total: results.found,
count: results.out_of,
items: (results.hits || []).map((hit) => hit.document),
facets: (results.facet_counts || []).map((facet) => ({
counts: facet.counts.map((item) => ({ count: item.count, value: item.value })),
fieldName: facet.field_name as string,
})),
debug: debug ? results : undefined,
} as SearchResult<T>;
}
private handleError(error: any) {
this.logger.error('Unable to index documents');
const results = error.importResults || [];
for (const result of results) {
try {
result.document = JSON.parse(result.document);
if (result.document?.smartInfo?.clipEmbedding) {
result.document.smartInfo.clipEmbedding = '<truncated>';
}
} catch {}
}
this.logger.verbose(JSON.stringify(results, null, 2));
}
private async updateAlias(collection: SearchCollection) {
const schema = schemaMap[collection];
const alias = await this.client
.aliases(collection)
.retrieve()
.catch(() => null);
// update alias to current collection
this.logger.log(`Using new schema: ${alias?.collection_name || '(unset)'} => ${schema.name}`);
await this.client.aliases().upsert(collection, { collection_name: schema.name });
// delete previous collection
if (alias && alias.collection_name !== schema.name) {
this.logger.log(`Deleting old schema: ${alias.collection_name}`);
await this.client.collections(alias.collection_name).delete();
}
}
private patch(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[]) {
return items.map((item) =>
collection === SearchCollection.ASSETS
? this.patchAsset(item as AssetEntity)
: this.patchAlbum(item as AlbumEntity),
);
}
private patchAlbum(album: AlbumEntity): AlbumEntity {
return removeNil(album);
}
private patchAsset(asset: AssetEntity): CustomAssetEntity {
let custom = asset as CustomAssetEntity;
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng && lat !== 0 && lng !== 0) {
custom = { ...custom, geo: [lat, lng] };
}
return removeNil({ ...custom, motion: !!asset.livePhotoVideoId });
}
private getFacetFieldNames(collection: SearchCollection) {
return (schemaMap[collection].fields || [])
.filter((field) => field.facet)
.map((field) => field.name)
.join(',');
}
private getAlbumFilters(filters: SearchFilter) {
const { userId } = filters;
const _filters = [this.buildFilterBy('ownerId', userId, true)];
if (filters.id) {
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of albumSchema.fields || []) {
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(this.buildFilterBy(item.name, value));
}
}
const result = _filters.join(' && ');
this.logger.debug(`Album filters are: ${result}`);
return result;
}
private getAssetFilters(filters: SearchFilter) {
const { userId } = filters;
const _filters = [this.buildFilterBy('ownerId', userId, true)];
if (filters.id) {
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of assetSchema.fields || []) {
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(this.buildFilterBy(item.name, value));
}
}
const result = _filters.join(' && ');
this.logger.debug(`Asset filters are: ${result}`);
return result;
}
private buildFilterBy(key: string, values: boolean | string | string[], exact?: boolean) {
const token = exact ? ':=' : ':';
const _values = (Array.isArray(values) ? values : [values]).map((value) => {
if (typeof value === 'boolean' || value === 'true' || value === 'false') {
return value;
}
return '`' + value + '`';
});
const value = _values.length > 1 ? `[${_values.join(',')}]` : _values[0];
return `${key}${token}${value}`;
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserTokenEntity } from '../entities';
import { IUserTokenRepository } from '@app/domain/user-token';
@Injectable()
export class UserTokenRepository implements IUserTokenRepository {
constructor(
@InjectRepository(UserTokenEntity)
private userTokenRepository: Repository<UserTokenEntity>,
) {}
async get(userToken: string): Promise<UserTokenEntity | null> {
return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } });
}
async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.userTokenRepository.save(userToken);
}
async delete(id: string): Promise<void> {
await this.userTokenRepository.delete(id);
}
async deleteAll(userId: string): Promise<void> {
await this.userTokenRepository.delete({ user: { id: userId } });
}
}

View File

@@ -0,0 +1,103 @@
import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { UserEntity } from '../entities';
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
}
async getAdmin(): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { isAdmin: true } });
}
async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
let builder = this.userRepository.createQueryBuilder('user').where({ email });
if (withPassword) {
builder = builder.addSelect('user.password');
}
return builder.getOne();
}
async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { oauthId } });
}
async getDeletedUsers(): Promise<UserEntity[]> {
return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
}
async getList({ excludeId }: UserListFilter = {}): Promise<UserEntity[]> {
if (!excludeId) {
return this.userRepository.find(); // TODO: this should also be ordered the same as below
}
return this.userRepository.find({
where: { id: Not(excludeId) },
withDeleted: true,
order: {
createdAt: 'DESC',
},
});
}
async create(user: Partial<UserEntity>): Promise<UserEntity> {
return this.userRepository.save(user);
}
async update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
user.id = id;
await this.userRepository.save(user);
const updatedUser = await this.get(id);
if (!updatedUser) {
throw new InternalServerErrorException('Cannot reload user after update');
}
return updatedUser;
}
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
if (hard) {
return this.userRepository.remove(user);
} else {
return this.userRepository.softRemove(user);
}
}
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.firstName', 'userFirstName')
.addSelect('users.lastName', 'userLastName')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id')
.orderBy('users.createdAt', 'ASC')
.getRawMany();
for (const stat of stats) {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
}
return stats;
}
}