chore: migrate database files (#8126)

This commit is contained in:
Jason Rasmussen
2024-03-20 16:02:51 -05:00
committed by GitHub
parent 84f7ca855a
commit c1402eee8e
310 changed files with 358 additions and 362 deletions

View File

@@ -0,0 +1,443 @@
import { InjectRepository } from '@nestjs/typeorm';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { ActivityEntity } from 'src/entities/activity.entity';
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { IAccessRepository } from 'src/interfaces/access.repository';
import { Brackets, In, Repository } from 'typeorm';
type IActivityAccess = IAccessRepository['activity'];
type IAlbumAccess = IAccessRepository['album'];
type IAssetAccess = IAccessRepository['asset'];
type IAuthDeviceAccess = IAccessRepository['authDevice'];
type ILibraryAccess = IAccessRepository['library'];
type ITimelineAccess = IAccessRepository['timeline'];
type IPersonAccess = IAccessRepository['person'];
type IPartnerAccess = IAccessRepository['partner'];
@Instrumentation()
class ActivityAccess implements IActivityAccess {
constructor(
private activityRepository: Repository<ActivityEntity>,
private albumRepository: Repository<AlbumEntity>,
) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>> {
if (activityIds.size === 0) {
return new Set();
}
return this.activityRepository
.find({
select: { id: true },
where: {
id: In([...activityIds]),
userId,
},
})
.then((activities) => new Set(activities.map((activity) => activity.id)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkAlbumOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>> {
if (activityIds.size === 0) {
return new Set();
}
return this.activityRepository
.find({
select: { id: true },
where: {
id: In([...activityIds]),
album: {
ownerId: userId,
},
},
})
.then((activities) => new Set(activities.map((activity) => activity.id)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkCreateAccess(userId: string, albumIds: Set<string>): Promise<Set<string>> {
if (albumIds.size === 0) {
return new Set();
}
return this.albumRepository
.createQueryBuilder('album')
.select('album.id')
.leftJoin('album.sharedUsers', 'sharedUsers')
.where('album.id IN (:...albumIds)', { albumIds: [...albumIds] })
.andWhere('album.isActivityEnabled = true')
.andWhere(
new Brackets((qb) => {
qb.where('album.ownerId = :userId', { userId }).orWhere('sharedUsers.id = :userId', { userId });
}),
)
.getMany()
.then((albums) => new Set(albums.map((album) => album.id)));
}
}
class AlbumAccess implements IAlbumAccess {
constructor(
private albumRepository: Repository<AlbumEntity>,
private sharedLinkRepository: Repository<SharedLinkEntity>,
) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, albumIds: Set<string>): Promise<Set<string>> {
if (albumIds.size === 0) {
return new Set();
}
return this.albumRepository
.find({
select: { id: true },
where: {
id: In([...albumIds]),
ownerId: userId,
},
})
.then((albums) => new Set(albums.map((album) => album.id)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkSharedAlbumAccess(userId: string, albumIds: Set<string>): Promise<Set<string>> {
if (albumIds.size === 0) {
return new Set();
}
return this.albumRepository
.find({
select: { id: true },
where: {
id: In([...albumIds]),
sharedUsers: {
id: userId,
},
},
})
.then((albums) => new Set(albums.map((album) => album.id)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>> {
if (albumIds.size === 0) {
return new Set();
}
return this.sharedLinkRepository
.find({
select: { albumId: true },
where: {
id: sharedLinkId,
albumId: In([...albumIds]),
},
})
.then(
(sharedLinks) => new Set(sharedLinks.flatMap((sharedLink) => (sharedLink.albumId ? [sharedLink.albumId] : []))),
);
}
}
class AssetAccess implements IAssetAccess {
constructor(
private albumRepository: Repository<AlbumEntity>,
private assetRepository: Repository<AssetEntity>,
private partnerRepository: Repository<PartnerEntity>,
private sharedLinkRepository: Repository<SharedLinkEntity>,
) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkAlbumAccess(userId: string, assetIds: Set<string>): Promise<Set<string>> {
if (assetIds.size === 0) {
return new Set();
}
return this.albumRepository
.createQueryBuilder('album')
.innerJoin('album.assets', 'asset')
.leftJoin('album.sharedUsers', 'sharedUsers')
.select('asset.id', 'assetId')
.addSelect('asset.livePhotoVideoId', 'livePhotoVideoId')
.where('array["asset"."id", "asset"."livePhotoVideoId"] && array[:...assetIds]::uuid[]', {
assetIds: [...assetIds],
})
.andWhere(
new Brackets((qb) => {
qb.where('album.ownerId = :userId', { userId }).orWhere('sharedUsers.id = :userId', { userId });
}),
)
.getRawMany()
.then((rows) => {
const allowedIds = new Set<string>();
for (const row of rows) {
if (row.assetId && assetIds.has(row.assetId)) {
allowedIds.add(row.assetId);
}
if (row.livePhotoVideoId && assetIds.has(row.livePhotoVideoId)) {
allowedIds.add(row.livePhotoVideoId);
}
}
return allowedIds;
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>> {
if (assetIds.size === 0) {
return new Set();
}
return this.assetRepository
.find({
select: { id: true },
where: {
id: In([...assetIds]),
ownerId: userId,
},
withDeleted: true,
})
.then((assets) => new Set(assets.map((asset) => asset.id)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkPartnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>> {
if (assetIds.size === 0) {
return new Set();
}
return this.partnerRepository
.createQueryBuilder('partner')
.innerJoin('partner.sharedBy', 'sharedBy')
.innerJoin('sharedBy.assets', 'asset')
.select('asset.id', 'assetId')
.where('partner.sharedWithId = :userId', { userId })
.andWhere('asset.id IN (:...assetIds)', { assetIds: [...assetIds] })
.getRawMany()
.then((rows) => new Set(rows.map((row) => row.assetId)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkSharedLinkAccess(sharedLinkId: string, assetIds: Set<string>): Promise<Set<string>> {
if (assetIds.size === 0) {
return new Set();
}
return this.sharedLinkRepository
.createQueryBuilder('sharedLink')
.leftJoin('sharedLink.album', 'album')
.leftJoin('sharedLink.assets', 'assets')
.leftJoin('album.assets', 'albumAssets')
.select('assets.id', 'assetId')
.addSelect('albumAssets.id', 'albumAssetId')
.addSelect('assets.livePhotoVideoId', 'assetLivePhotoVideoId')
.addSelect('albumAssets.livePhotoVideoId', 'albumAssetLivePhotoVideoId')
.where('sharedLink.id = :sharedLinkId', { sharedLinkId })
.andWhere(
'array["assets"."id", "assets"."livePhotoVideoId", "albumAssets"."id", "albumAssets"."livePhotoVideoId"] && array[:...assetIds]::uuid[]',
{
assetIds: [...assetIds],
},
)
.getRawMany()
.then((rows) => {
const allowedIds = new Set<string>();
for (const row of rows) {
if (row.assetId && assetIds.has(row.assetId)) {
allowedIds.add(row.assetId);
}
if (row.assetLivePhotoVideoId && assetIds.has(row.assetLivePhotoVideoId)) {
allowedIds.add(row.assetLivePhotoVideoId);
}
if (row.albumAssetId && assetIds.has(row.albumAssetId)) {
allowedIds.add(row.albumAssetId);
}
if (row.albumAssetLivePhotoVideoId && assetIds.has(row.albumAssetLivePhotoVideoId)) {
allowedIds.add(row.albumAssetLivePhotoVideoId);
}
}
return allowedIds;
});
}
}
class AuthDeviceAccess implements IAuthDeviceAccess {
constructor(private tokenRepository: Repository<UserTokenEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, deviceIds: Set<string>): Promise<Set<string>> {
if (deviceIds.size === 0) {
return new Set();
}
return this.tokenRepository
.find({
select: { id: true },
where: {
userId,
id: In([...deviceIds]),
},
})
.then((tokens) => new Set(tokens.map((token) => token.id)));
}
}
class LibraryAccess implements ILibraryAccess {
constructor(private libraryRepository: Repository<LibraryEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>> {
if (libraryIds.size === 0) {
return new Set();
}
return this.libraryRepository
.find({
select: { id: true },
where: {
id: In([...libraryIds]),
ownerId: userId,
},
})
.then((libraries) => new Set(libraries.map((library) => library.id)));
}
}
class TimelineAccess implements ITimelineAccess {
constructor(private partnerRepository: Repository<PartnerEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> {
if (partnerIds.size === 0) {
return new Set();
}
return this.partnerRepository
.createQueryBuilder('partner')
.select('partner.sharedById')
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
.andWhere('partner.sharedWithId = :userId', { userId })
.getMany()
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
}
}
class PersonAccess implements IPersonAccess {
constructor(
private assetFaceRepository: Repository<AssetFaceEntity>,
private personRepository: Repository<PersonEntity>,
) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>> {
if (personIds.size === 0) {
return new Set();
}
return this.personRepository
.find({
select: { id: true },
where: {
id: In([...personIds]),
ownerId: userId,
},
})
.then((persons) => new Set(persons.map((person) => person.id)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkFaceOwnerAccess(userId: string, assetFaceIds: Set<string>): Promise<Set<string>> {
if (assetFaceIds.size === 0) {
return new Set();
}
return this.assetFaceRepository
.find({
select: { id: true },
where: {
id: In([...assetFaceIds]),
asset: {
ownerId: userId,
},
},
})
.then((faces) => new Set(faces.map((face) => face.id)));
}
}
class PartnerAccess implements IPartnerAccess {
constructor(private partnerRepository: Repository<PartnerEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkUpdateAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> {
if (partnerIds.size === 0) {
return new Set();
}
return this.partnerRepository
.createQueryBuilder('partner')
.select('partner.sharedById')
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
.andWhere('partner.sharedWithId = :userId', { userId })
.getMany()
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
}
}
export class AccessRepository implements IAccessRepository {
activity: IActivityAccess;
album: IAlbumAccess;
asset: IAssetAccess;
authDevice: IAuthDeviceAccess;
library: ILibraryAccess;
person: IPersonAccess;
partner: IPartnerAccess;
timeline: ITimelineAccess;
constructor(
@InjectRepository(ActivityEntity) activityRepository: Repository<ActivityEntity>,
@InjectRepository(AssetEntity) assetRepository: Repository<AssetEntity>,
@InjectRepository(AlbumEntity) albumRepository: Repository<AlbumEntity>,
@InjectRepository(LibraryEntity) libraryRepository: Repository<LibraryEntity>,
@InjectRepository(PartnerEntity) partnerRepository: Repository<PartnerEntity>,
@InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(UserTokenEntity) tokenRepository: Repository<UserTokenEntity>,
) {
this.activity = new ActivityAccess(activityRepository, albumRepository);
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
this.authDevice = new AuthDeviceAccess(tokenRepository);
this.library = new LibraryAccess(libraryRepository);
this.person = new PersonAccess(assetFaceRepository, personRepository);
this.partner = new PartnerAccess(partnerRepository);
this.timeline = new TimelineAccess(partnerRepository);
}
}

View File

@@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { ActivityEntity } from 'src/entities/activity.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { IActivityRepository } from 'src/interfaces/activity.repository';
import { IsNull, Repository } from 'typeorm';
export interface ActivitySearch {
albumId?: string;
assetId?: string | null;
userId?: string;
isLiked?: boolean;
}
@Instrumentation()
@Injectable()
export class ActivityRepository implements IActivityRepository {
constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {}
@GenerateSql({ params: [{ albumId: DummyValue.UUID }] })
search(options: ActivitySearch): Promise<ActivityEntity[]> {
const { userId, assetId, albumId, isLiked } = options;
return this.repository.find({
where: {
userId,
assetId: assetId === null ? IsNull() : assetId,
albumId,
isLiked,
},
relations: {
user: true,
},
order: {
createdAt: 'ASC',
},
});
}
create(entity: Partial<ActivityEntity>): Promise<ActivityEntity> {
return this.save(entity);
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getStatistics(assetId: string, albumId: string): Promise<number> {
return this.repository.count({
where: { assetId, albumId, isLiked: false },
relations: {
user: true,
},
});
}
private async save(entity: Partial<ActivityEntity>) {
const { id } = await this.repository.save(entity);
return this.repository.findOneOrFail({
where: {
id,
},
relations: {
user: true,
},
});
}
}

View File

@@ -0,0 +1,340 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
import { Chunked, ChunkedArray, DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { dataSource } from 'src/infra/database.config';
import { Instrumentation } from 'src/infra/instrumentation';
import {
AlbumAsset,
AlbumAssetCount,
AlbumAssets,
AlbumInfoOptions,
IAlbumRepository,
} from 'src/interfaces/album.repository';
import { setUnion } from 'src/utils';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
@InjectDataSource() private dataSource: DataSource,
) {}
@GenerateSql({ params: [DummyValue.UUID, {}] })
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> {
const relations: FindOptionsRelations<AlbumEntity> = {
owner: true,
sharedUsers: true,
assets: false,
sharedLinks: true,
};
const order: FindOptionsOrder<AlbumEntity> = {};
if (options.withAssets) {
relations.assets = {
exifInfo: true,
};
order.assets = {
fileCreatedAt: 'DESC',
};
}
return this.repository.findOne({ where: { id }, relations, order });
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({
where: {
id: In(ids),
},
relations: {
owner: true,
sharedUsers: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
return this.repository.find({
where: [
{ ownerId, assets: { id: assetId } },
{ sharedUsers: { id: ownerId }, assets: { id: assetId } },
],
relations: { owner: true, sharedUsers: true },
order: { createdAt: 'DESC' },
});
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
// Guard against running invalid query when ids list is empty.
if (ids.length === 0) {
return [];
}
// Only possible with query builder because of GROUP BY.
const albumMetadatas = await this.repository
.createQueryBuilder('album')
.select('album.id')
.addSelect('MIN(assets.fileCreatedAt)', 'start_date')
.addSelect('MAX(assets.fileCreatedAt)', 'end_date')
.addSelect('COUNT(assets.id)', 'asset_count')
.leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id')
.leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId')
.where('album.id IN (:...ids)', { ids })
.groupBy('album.id')
.getRawMany();
return albumMetadatas.map<AlbumAssetCount>((metadatas) => ({
albumId: metadatas['album_id'],
assetCount: Number(metadatas['asset_count']),
startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined,
endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined,
}));
}
/**
* Returns the album IDs that have an invalid thumbnail, when:
* - Thumbnail references an asset outside the album
* - Empty album still has a thumbnail set
*/
@GenerateSql()
async getInvalidThumbnail(): Promise<string[]> {
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = this.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);
}
@GenerateSql({ params: [DummyValue.UUID] })
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.
*/
@GenerateSql({ params: [DummyValue.UUID] })
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
*/
@GenerateSql({ params: [DummyValue.UUID] })
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 restoreAll(userId: string): Promise<void> {
await this.repository.restore({ ownerId: userId });
}
async softDeleteAll(userId: string): Promise<void> {
await this.repository.softDelete({ ownerId: userId });
}
async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ ownerId: userId });
}
@GenerateSql()
getAll(): Promise<AlbumEntity[]> {
return this.repository.find({
relations: {
owner: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
async removeAsset(assetId: string): Promise<void> {
// Using dataSource, because there is no direct access to albums_assets_assets.
await this.dataSource
.createQueryBuilder()
.delete()
.from('albums_assets_assets')
.where('"albums_assets_assets"."assetsId" = :assetId', { assetId })
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
async removeAssets(albumId: string, assetIds: string[]): Promise<void> {
await this.dataSource
.createQueryBuilder()
.delete()
.from('albums_assets_assets')
.where({
albumsId: albumId,
assetsId: In(assetIds),
})
.execute();
}
/**
* Get asset IDs for the given album ID.
*
* @param albumId Album ID to get asset IDs for.
* @param assetIds Optional list of asset IDs to filter on.
* @returns Set of Asset IDs for the given album ID.
*/
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }, { name: 'no assets', params: [DummyValue.UUID] })
async getAssetIds(albumId: string, assetIds?: string[]): Promise<Set<string>> {
const query = this.dataSource
.createQueryBuilder()
.select('albums_assets.assetsId', 'assetId')
.from('albums_assets_assets', 'albums_assets')
.where('"albums_assets"."albumsId" = :albumId', { albumId });
if (!assetIds?.length) {
const result = await query.getRawMany();
return new Set(result.map((row) => row['assetId']));
}
return Promise.all(
_.chunk(assetIds, DATABASE_PARAMETER_CHUNK_SIZE).map((idChunk) =>
query
.andWhere('"albums_assets"."assetsId" IN (:...assetIds)', { assetIds: idChunk })
.getRawMany()
.then((result) => new Set(result.map((row) => row['assetId']))),
),
).then((results) => setUnion(...results));
}
@GenerateSql({ params: [{ albumId: DummyValue.UUID, assetId: DummyValue.UUID }] })
hasAsset(asset: AlbumAsset): Promise<boolean> {
return this.repository.exist({
where: {
id: asset.albumId,
assets: {
id: asset.assetId,
},
},
relations: {
assets: true,
},
});
}
@GenerateSql({ params: [{ albumId: DummyValue.UUID, assetIds: [DummyValue.UUID] }] })
async addAssets({ albumId, assetIds }: AlbumAssets): Promise<void> {
await this.dataSource
.createQueryBuilder()
.insert()
.into('albums_assets_assets', ['albumsId', 'assetsId'])
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
.execute();
}
async create(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album);
}
async update(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album);
}
async delete(album: AlbumEntity): Promise<void> {
await this.repository.remove(album);
}
private async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({
where: { id },
relations: {
owner: true,
sharedUsers: true,
sharedLinks: true,
assets: true,
},
});
}
/**
* Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets
* - Removing references of thumbnails to assets outside the album
* - Setting a thumbnail when none is set and the album contains assets
*
* @returns Amount of updated album thumbnails or undefined when unknown
*/
@GenerateSql()
async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail.
const newThumbnail = this.assetRepository
.createQueryBuilder('assets')
.select('albums_assets2.assetsId')
.addFrom('albums_assets_assets', 'albums_assets2')
.where('albums_assets2.assetsId = assets.id')
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
.orderBy('assets.fileCreatedAt', 'DESC')
.limit(1);
// 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 updateAlbums = this.repository
.createQueryBuilder('albums')
.update(AlbumEntity)
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
const result = await updateAlbums.execute();
return result.affected;
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { IKeyRepository } from 'src/interfaces/api-key.repository';
import { Repository } from 'typeorm';
@Instrumentation()
@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 });
}
@GenerateSql({ params: [DummyValue.STRING] })
getKey(hashedToken: string): Promise<APIKeyEntity | null> {
return this.repository.findOne({
select: {
id: true,
key: true,
userId: true,
},
where: { key: hashedToken },
relations: {
user: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getById(userId: string, id: string): Promise<APIKeyEntity | null> {
return this.repository.findOne({ where: { userId, id } });
}
@GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string): Promise<APIKeyEntity[]> {
return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } });
}
}

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AssetStackEntity } from 'src/entities/asset-stack.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.repository';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class AssetStackRepository implements IAssetStackRepository {
constructor(@InjectRepository(AssetStackEntity) private repository: Repository<AssetStackEntity>) {}
create(entity: Partial<AssetStackEntity>) {
return this.save(entity);
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
update(entity: Partial<AssetStackEntity>) {
return this.save(entity);
}
async getById(id: string): Promise<AssetStackEntity | null> {
return this.repository.findOne({
where: {
id,
},
relations: {
primaryAsset: true,
assets: true,
},
});
}
private async save(entity: Partial<AssetStackEntity>) {
const { id } = await this.repository.save(entity);
return this.repository.findOneOrFail({
where: {
id,
},
relations: {
primaryAsset: true,
assets: true,
},
});
}
}

View File

@@ -0,0 +1,820 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DateTime } from 'luxon';
import path from 'node:path';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetOrder } from 'src/entities/album.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils';
import { Instrumentation } from 'src/infra/instrumentation';
import {
AssetBuilderOptions,
AssetCreate,
AssetExploreFieldOptions,
AssetPathEntity,
AssetStats,
AssetStatsOptions,
AssetUpdateAllOptions,
AssetUpdateOptions,
IAssetRepository,
LivePhotoSearchOptions,
MapMarker,
MapMarkerSearchOptions,
MetadataSearchOptions,
MonthDay,
TimeBucketItem,
TimeBucketOptions,
TimeBucketSize,
WithProperty,
WithoutProperty,
} from 'src/interfaces/asset.repository';
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.repository';
import { Paginated, PaginationMode, PaginationOptions } from 'src/utils';
import {
Brackets,
FindOptionsRelations,
FindOptionsSelect,
FindOptionsWhere,
In,
IsNull,
Not,
Repository,
} from 'typeorm';
const truncateMap: Record<TimeBucketSize, string> = {
[TimeBucketSize.DAY]: 'day',
[TimeBucketSize.MONTH]: 'month',
};
const dateTrunc = (options: TimeBucketOptions) =>
`(date_trunc('${
truncateMap[options.size]
}', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`;
@Instrumentation()
@Injectable()
export class AssetRepository implements IAssetRepository {
constructor(
@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
@InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository<SmartInfoEntity>,
) {}
async upsertExif(exif: Partial<ExifEntity>): Promise<void> {
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
}
async upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void> {
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
}
create(asset: AssetCreate): Promise<AssetEntity> {
return this.repository.save(asset);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> {
// For reference of a correct approach although slower
// let builder = this.repository
// .createQueryBuilder('asset')
// .leftJoin('asset.exifInfo', 'exifInfo')
// .where('asset.ownerId = :ownerId', { ownerId })
// .andWhere(
// `coalesce(date_trunc('day', asset."fileCreatedAt", "exifInfo"."timeZone") at TIME ZONE "exifInfo"."timeZone", date_trunc('day', asset."fileCreatedAt")) IN (:date)`,
// { date },
// )
// .andWhere('asset.isVisible = true')
// .andWhere('asset.isArchived = false')
// .orderBy('asset.fileCreatedAt', 'DESC');
// return builder.getMany();
return this.repository.find({
where: {
ownerId,
isVisible: true,
isArchived: false,
resizePath: Not(IsNull()),
fileCreatedAt: OptionalBetween(date, DateTime.fromJSDate(date).plus({ day: 1 }).toJSDate()),
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
}
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<AssetEntity[]> {
return this.repository
.createQueryBuilder('entity')
.where(
`entity.ownerId IN (:...ownerIds)
AND entity.isVisible = true
AND entity.isArchived = false
AND entity.resizePath IS NOT NULL
AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day
AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`,
{
ownerIds,
day,
month,
},
)
.leftJoinAndSelect('entity.exifInfo', 'exifInfo')
.orderBy('entity.localDateTime', 'DESC')
.getMany();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIds(
ids: string[],
relations?: FindOptionsRelations<AssetEntity>,
select?: FindOptionsSelect<AssetEntity>,
): Promise<AssetEntity[]> {
return this.repository.find({
where: { id: In(ids) },
relations,
select,
withDeleted: true,
});
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { id: In(ids) },
relations: {
exifInfo: true,
smartInfo: true,
tags: true,
faces: {
person: true,
},
stack: {
assets: true,
},
},
withDeleted: true,
});
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteAll(ownerId: string): Promise<void> {
await this.repository.delete({ ownerId });
}
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {
albums: {
id: albumId,
},
},
relations: {
albums: true,
exifInfo: true,
},
});
}
getByUserId(
pagination: PaginationOptions,
userId: string,
options: Omit<AssetSearchOptions, 'userIds'> = {},
): Paginated<AssetEntity> {
return this.getAll(pagination, { ...options, userIds: [userId] });
}
@GenerateSql({ params: [[DummyValue.UUID]] })
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity> {
return paginate(this.repository, pagination, {
select: { id: true, originalPath: true, isOffline: true },
where: { library: { id: libraryId } },
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { library: { id: libraryId }, originalPath: originalPath },
});
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] })
@ChunkedArray({ paramIndex: 1 })
async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise<string[]> {
const result = await this.repository.query(
`
WITH paths AS (SELECT unnest($2::text[]) AS path)
SELECT path FROM paths
WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path);
`,
[libraryId, originalPaths],
);
return result.map((row: { path: string }) => row.path);
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] })
@ChunkedArray({ paramIndex: 1 })
async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise<void> {
await this.repository.update(
{ library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false },
{ isOffline: true },
);
}
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
let builder = this.repository.createQueryBuilder('asset');
builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC');
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.SKIP_TAKE,
skip: pagination.skip,
take: pagination.take,
});
}
/**
* Get assets by device's Id on the database
* @param ownerId
* @param deviceId
*
* @returns Promise<string[]> - Array of assetIds belong to the device
*/
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
const items = await this.repository.find({
select: { deviceAssetId: true },
where: {
ownerId,
deviceId,
isVisible: true,
},
withDeleted: true,
});
return items.map((asset) => asset.deviceAssetId);
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string, relations: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { id },
relations,
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
});
}
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
@Chunked()
async updateAll(ids: string[], options: AssetUpdateAllOptions): Promise<void> {
await this.repository.update({ id: In(ids) }, options);
}
@Chunked()
async softDeleteAll(ids: string[]): Promise<void> {
await this.repository.softDelete({ id: In(ids), isExternal: false });
}
@Chunked()
async restoreAll(ids: string[]): Promise<void> {
await this.repository.restore({ id: In(ids) });
}
async update(asset: AssetUpdateOptions): Promise<void> {
await this.repository.update(asset.id, asset);
}
async remove(asset: AssetEntity): Promise<void> {
await this.repository.remove(asset);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
return this.repository.findOne({ where: { ownerId: userId, checksum } });
}
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null> {
const { ownerId, otherAssetId, livePhotoCID, type } = options;
return this.repository.findOne({
where: {
id: Not(otherAssetId),
ownerId,
type,
exifInfo: {
livePhotoCID,
},
},
relations: {
exifInfo: true,
},
});
}
@GenerateSql(
...Object.values(WithProperty)
.filter((property) => property !== WithProperty.IS_OFFLINE)
.map((property) => ({
name: property,
params: [DummyValue.PAGINATION, property],
})),
)
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<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 },
{ thumbhash: IsNull(), isVisible: true },
];
break;
}
case WithoutProperty.ENCODED_VIDEO: {
where = [
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() },
{ type: AssetType.VIDEO, encodedVideoPath: '' },
];
break;
}
case WithoutProperty.EXIF: {
relations = {
exifInfo: true,
jobStatus: true,
};
where = {
isVisible: true,
jobStatus: {
metadataExtractedAt: IsNull(),
},
};
break;
}
case WithoutProperty.SMART_SEARCH: {
relations = {
smartSearch: true,
};
where = {
isVisible: true,
resizePath: Not(IsNull()),
smartSearch: {
embedding: IsNull(),
},
};
break;
}
case WithoutProperty.OBJECT_TAGS: {
relations = {
smartInfo: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
smartInfo: {
tags: IsNull(),
},
};
break;
}
case WithoutProperty.FACES: {
relations = {
faces: true,
jobStatus: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
faces: {
assetId: IsNull(),
personId: IsNull(),
},
jobStatus: {
facesRecognizedAt: IsNull(),
},
};
break;
}
case WithoutProperty.PERSON: {
relations = {
faces: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
faces: {
assetId: Not(IsNull()),
personId: IsNull(),
},
};
break;
}
case WithoutProperty.SIDECAR: {
where = [
{ sidecarPath: IsNull(), isVisible: true },
{ sidecarPath: '', isVisible: true },
];
break;
}
default: {
throw new Error(`Invalid getWithout property: ${property}`);
}
}
return paginate(this.repository, pagination, {
relations,
where,
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity> {
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithProperty.SIDECAR: {
where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
break;
}
case WithProperty.IS_OFFLINE: {
if (!libraryId) {
throw new Error('Library id is required when finding offline assets');
}
where = [{ isOffline: true, libraryId: libraryId }];
break;
}
default: {
throw new Error(`Invalid getWith property: ${property}`);
}
}
return paginate(this.repository, pagination, {
where,
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
order: { fileCreatedAt: 'DESC' },
});
}
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
order: { updatedAt: 'DESC' },
});
}
async getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
const assets = await this.repository.find({
select: {
id: true,
exifInfo: {
city: true,
state: true,
country: true,
latitude: true,
longitude: true,
},
},
where: {
ownerId: In([...ownerIds]),
isVisible: true,
isArchived,
exifInfo: {
latitude: Not(IsNull()),
longitude: Not(IsNull()),
},
isFavorite,
fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore),
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
return assets.map((asset) => ({
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
}));
}
async getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats> {
let builder = this.repository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"ownerId" = :ownerId', { ownerId })
.andWhere('asset.isVisible = true')
.groupBy('asset.type');
const { isArchived, isFavorite, isTrashed } = options;
if (isArchived !== undefined) {
builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived });
}
if (isFavorite !== undefined) {
builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite });
}
if (isTrashed !== undefined) {
builder = builder.withDeleted().andWhere(`asset.deletedAt is not null`);
}
const items = await builder.getRawMany();
const result: AssetStats = {
[AssetType.AUDIO]: 0,
[AssetType.IMAGE]: 0,
[AssetType.VIDEO]: 0,
[AssetType.OTHER]: 0,
};
for (const item of items) {
result[item.type as AssetType] = Number(item.count) || 0;
}
return result;
}
getRandom(ownerId: string, count: number): Promise<AssetEntity[]> {
// can't use queryBuilder because of custom OFFSET clause
return this.repository.query(
`SELECT *
FROM assets
WHERE "ownerId" = $1
OFFSET FLOOR(RANDOM() * (SELECT GREATEST(COUNT(*) - $2, 0) FROM ASSETS WHERE "ownerId" = $1)) LIMIT $2`,
[ownerId, count],
);
}
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
const truncated = dateTrunc(options);
return this.getBuilder(options)
.select(`COUNT(asset.id)::int`, 'count')
.addSelect(truncated, 'timeBucket')
.groupBy(truncated)
.orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
.getRawMany();
}
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH }] })
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> {
const truncated = dateTrunc(options);
return (
this.getBuilder(options)
.andWhere(`${truncated} = :timeBucket`, { timeBucket: timeBucket.replace(/^[+-]/, '') })
// First sort by the day in localtime (put it in the right bucket)
.orderBy(truncated, 'DESC')
// and then sort by the actual time
.addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
.getMany()
);
}
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByCity(
ownerId: string,
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
): Promise<SearchExploreItem<string>> {
const cte = this.exifRepository
.createQueryBuilder('e')
.select('city')
.groupBy('city')
.having('count(city) >= :minAssetsPerField', { minAssetsPerField });
const items = await this.getBuilder({
userIds: [ownerId],
exifInfo: false,
assetType: AssetType.IMAGE,
isArchived: false,
})
.select('c.city', 'value')
.addSelect('asset.id', 'data')
.distinctOn(['c.city'])
.innerJoin('exif', 'e', 'asset.id = e."assetId"')
.addCommonTableExpression(cte, 'cities')
.innerJoin('cities', 'c', 'c.city = e.city')
.limit(maxFields)
.getRawMany();
return { fieldName: 'exifInfo.city', items };
}
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByTag(
ownerId: string,
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
): Promise<SearchExploreItem<string>> {
const cte = this.smartInfoRepository
.createQueryBuilder('si')
.select('unnest(tags)', 'tag')
.groupBy('tag')
.having('count(*) >= :minAssetsPerField', { minAssetsPerField });
const items = await this.getBuilder({
userIds: [ownerId],
exifInfo: false,
assetType: AssetType.IMAGE,
isArchived: false,
})
.select('unnest(si.tags)', 'value')
.addSelect('asset.id', 'data')
.distinctOn(['unnest(si.tags)'])
.innerJoin('smart_info', 'si', 'asset.id = si."assetId"')
.addCommonTableExpression(cte, 'random_tags')
.innerJoin('random_tags', 't', 'si.tags @> ARRAY[t.tag]')
.limit(maxFields)
.getRawMany();
return { fieldName: 'smartInfo.tags', items };
}
private getBuilder(options: AssetBuilderOptions) {
const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options;
let builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true');
if (assetType !== undefined) {
builder = builder.andWhere('asset.type = :assetType', { assetType });
}
let stackJoined = false;
if (exifInfo !== false) {
stackJoined = true;
builder = builder
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.leftJoinAndSelect('asset.stack', 'stack')
.leftJoinAndSelect('stack.assets', 'stackedAssets');
}
if (albumId) {
builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId });
}
if (userIds) {
builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds });
}
if (isArchived !== undefined) {
builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived });
}
if (isFavorite !== undefined) {
builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite });
}
if (isTrashed !== undefined) {
builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
}
if (personId !== undefined) {
builder = builder
.innerJoin('asset.faces', 'faces')
.innerJoin('faces.person', 'person')
.andWhere('person.id = :personId', { personId });
}
if (withStacked) {
if (!stackJoined) {
builder = builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets');
}
builder = builder.andWhere(
new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL')),
);
}
return builder;
}
@GenerateSql({ params: [DummyValue.STRING, [DummyValue.UUID], { numResults: 250 }] })
async searchMetadata(
query: string,
userIds: string[],
{ numResults }: MetadataSearchOptions,
): Promise<AssetEntity[]> {
const rows = await this.getBuilder({
userIds: userIds,
exifInfo: false,
isArchived: false,
})
.select('asset.*')
.addSelect('e.*')
.addSelect('COALESCE(si.tags, array[]::text[])', 'tags')
.addSelect('COALESCE(si.objects, array[]::text[])', 'objects')
.innerJoin('exif', 'e', 'asset."id" = e."assetId"')
.leftJoin('smart_info', 'si', 'si."assetId" = asset."id"')
.andWhere(
new Brackets((qb) => {
qb.where(
`(e."exifTextSearchableColumn" || COALESCE(si."smartInfoTextSearchableColumn", to_tsvector('english', '')))
@@ PLAINTO_TSQUERY('english', :query)`,
{ query },
).orWhere('asset."originalFileName" = :path', { path: path.parse(query).name });
}),
)
.addOrderBy('asset.fileCreatedAt', 'DESC')
.limit(numResults)
.getRawMany();
return rows.map(
({
tags,
objects,
country,
state,
city,
description,
model,
make,
dateTimeOriginal,
exifImageHeight,
exifImageWidth,
exposureTime,
fNumber,
fileSizeInByte,
focalLength,
iso,
latitude,
lensModel,
longitude,
modifyDate,
projectionType,
timeZone,
...assetInfo
}) =>
({
exifInfo: {
city,
country,
dateTimeOriginal,
description,
exifImageHeight,
exifImageWidth,
exposureTime,
fNumber,
fileSizeInByte,
focalLength,
iso,
latitude,
lensModel,
longitude,
make,
model,
modifyDate,
projectionType,
state,
timeZone,
},
smartInfo: {
tags,
objects,
},
...assetInfo,
}) as AssetEntity,
);
}
}

View File

@@ -0,0 +1,28 @@
import { InjectRepository } from '@nestjs/typeorm';
import { AuditEntity } from 'src/entities/audit.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { AuditSearch, IAuditRepository } from 'src/interfaces/audit.repository';
import { LessThan, MoreThan, Repository } from 'typeorm';
@Instrumentation()
export class AuditRepository implements IAuditRepository {
constructor(@InjectRepository(AuditEntity) private repository: Repository<AuditEntity>) {}
getAfter(since: Date, options: AuditSearch): Promise<AuditEntity[]> {
return this.repository
.createQueryBuilder('audit')
.where({
createdAt: MoreThan(since),
action: options.action,
entityType: options.entityType,
ownerId: options.ownerId,
})
.distinctOn(['audit.entityId', 'audit.entityType'])
.orderBy('audit.entityId, audit.entityType, audit.createdAt', 'DESC')
.getMany();
}
async removeBefore(before: Date): Promise<void> {
await this.repository.delete({ createdAt: LessThan(before) });
}
}

View File

@@ -0,0 +1,113 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AuthService } from 'src/domain/auth/auth.service';
import { Instrumentation } from 'src/infra/instrumentation';
import { ImmichLogger } from 'src/infra/logger';
import {
ClientEvent,
ICommunicationRepository,
InternalEventMap,
OnConnectCallback,
OnServerEventCallback,
ServerEvent,
} from 'src/interfaces/communication.repository';
@Instrumentation()
@WebSocketGateway({
cors: true,
path: '/api/socket.io',
transports: ['websocket'],
})
export class CommunicationRepository
implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, ICommunicationRepository
{
private logger = new ImmichLogger(CommunicationRepository.name);
private onConnectCallbacks: OnConnectCallback[] = [];
private onServerEventCallbacks: Record<ServerEvent, OnServerEventCallback[]> = {
[ServerEvent.CONFIG_UPDATE]: [],
};
@WebSocketServer()
private server?: Server;
constructor(
private authService: AuthService,
private eventEmitter: EventEmitter2,
) {}
afterInit(server: Server) {
this.logger.log('Initialized websocket server');
for (const event of Object.values(ServerEvent)) {
server.on(event, async () => {
this.logger.debug(`Server event: ${event} (receive)`);
const callbacks = this.onServerEventCallbacks[event];
for (const callback of callbacks) {
await callback();
}
});
}
}
on(event: 'connect' | ServerEvent, callback: OnConnectCallback | OnServerEventCallback) {
switch (event) {
case 'connect': {
this.onConnectCallbacks.push(callback);
break;
}
default: {
this.onServerEventCallbacks[event].push(callback as OnServerEventCallback);
break;
}
}
}
async handleConnection(client: Socket) {
try {
this.logger.log(`Websocket Connect: ${client.id}`);
const auth = await this.authService.validate(client.request.headers, {});
await client.join(auth.user.id);
for (const callback of this.onConnectCallbacks) {
await callback(auth.user.id);
}
} catch (error: Error | any) {
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
client.emit('error', 'unauthorized');
client.disconnect();
}
}
async handleDisconnect(client: Socket) {
this.logger.log(`Websocket Disconnect: ${client.id}`);
await client.leave(client.nsp.name);
}
send(event: ClientEvent, userId: string, data: any) {
this.server?.to(userId).emit(event, data);
}
broadcast(event: ClientEvent, data: any) {
this.server?.emit(event, data);
}
sendServerEvent(event: ServerEvent) {
this.logger.debug(`Server event: ${event} (send)`);
this.server?.serverSideEmit(event);
}
emit<E extends keyof InternalEventMap>(event: E, data: InternalEventMap[E]): boolean {
return this.eventEmitter.emit(event, data);
}
emitAsync<E extends keyof InternalEventMap, R = any[]>(event: E, data: InternalEventMap[E]): Promise<R> {
return this.eventEmitter.emitAsync(event, data) as Promise<R>;
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { Instrumentation } from 'src/infra/instrumentation';
import { ICryptoRepository } from 'src/interfaces/crypto.repository';
@Instrumentation()
@Injectable()
export class CryptoRepository implements ICryptoRepository {
randomUUID() {
return randomUUID();
}
randomBytes(size: number) {
return randomBytes(size);
}
hashBcrypt(data: string | Buffer, saltOrRounds: string | number) {
return hash(data, saltOrRounds);
}
compareBcrypt(data: string | Buffer, encrypted: string) {
return compareSync(data, encrypted);
}
hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64');
}
hashSha1(value: string | Buffer): Buffer {
return createHash('sha1').update(value).digest();
}
hashFile(filepath: string | Buffer): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const hash = createHash('sha1');
const stream = createReadStream(filepath);
stream.on('error', (error) => reject(error));
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest()));
});
}
}

View File

@@ -0,0 +1,239 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import AsyncLock from 'async-lock';
import { Version, VersionType } from 'src/domain/domain.constant';
import { vectorExt } from 'src/infra/database.config';
import { Instrumentation } from 'src/infra/instrumentation';
import { ImmichLogger } from 'src/infra/logger';
import {
DatabaseExtension,
DatabaseLock,
IDatabaseRepository,
VectorExtension,
VectorIndex,
VectorUpdateResult,
extName,
} from 'src/interfaces/database.repository';
import { isValidInteger } from 'src/validation';
import { DataSource, EntityManager, QueryRunner } from 'typeorm';
@Instrumentation()
@Injectable()
export class DatabaseRepository implements IDatabaseRepository {
private logger = new ImmichLogger(DatabaseRepository.name);
readonly asyncLock = new AsyncLock();
constructor(@InjectDataSource() private dataSource: DataSource) {}
async getExtensionVersion(extension: DatabaseExtension): Promise<Version | null> {
const res = await this.dataSource.query(`SELECT extversion FROM pg_extension WHERE extname = $1`, [extension]);
const extVersion = res[0]?.['extversion'];
if (extVersion == null) {
return null;
}
const version = Version.fromString(extVersion);
if (version.isEqual(new Version(0, 1, 1))) {
return new Version(0, 1, 11);
}
return version;
}
async getAvailableExtensionVersion(extension: DatabaseExtension): Promise<Version | null> {
const res = await this.dataSource.query(
`
SELECT version FROM pg_available_extension_versions
WHERE name = $1 AND installed = false
ORDER BY version DESC`,
[extension],
);
const version = res[0]?.['version'];
return version == null ? null : Version.fromString(version);
}
getPreferredVectorExtension(): VectorExtension {
return vectorExt;
}
async getPostgresVersion(): Promise<Version> {
const res = await this.dataSource.query(`SHOW server_version`);
return Version.fromString(res[0]['server_version']);
}
async createExtension(extension: DatabaseExtension): Promise<void> {
await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`);
}
async updateExtension(extension: DatabaseExtension, version?: Version): Promise<void> {
await this.dataSource.query(`ALTER EXTENSION ${extension} UPDATE${version ? ` TO '${version}'` : ''}`);
}
async updateVectorExtension(extension: VectorExtension, version?: Version): Promise<VectorUpdateResult> {
const curVersion = await this.getExtensionVersion(extension);
if (!curVersion) {
throw new Error(`${extName[extension]} extension is not installed`);
}
const minorOrMajor = version && curVersion.isOlderThan(version) >= VersionType.MINOR;
const isVectors = extension === DatabaseExtension.VECTORS;
let restartRequired = false;
await this.dataSource.manager.transaction(async (manager) => {
await this.setSearchPath(manager);
if (minorOrMajor && isVectors) {
await this.updateVectorsSchema(manager, curVersion);
}
await manager.query(`ALTER EXTENSION ${extension} UPDATE${version ? ` TO '${version}'` : ''}`);
if (!minorOrMajor) {
return;
}
if (isVectors) {
await manager.query('SELECT pgvectors_upgrade()');
restartRequired = true;
} else {
await this.reindex(VectorIndex.CLIP);
await this.reindex(VectorIndex.FACE);
}
});
return { restartRequired };
}
async reindex(index: VectorIndex): Promise<void> {
try {
await this.dataSource.query(`REINDEX INDEX ${index}`);
} catch (error) {
if (vectorExt === DatabaseExtension.VECTORS) {
this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`);
const table = index === VectorIndex.CLIP ? 'smart_search' : 'asset_faces';
const dimSize = await this.getDimSize(table);
await this.dataSource.manager.transaction(async (manager) => {
await this.setSearchPath(manager);
await manager.query(`DROP INDEX IF EXISTS ${index}`);
await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE real[]`);
await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
await manager.query(`SET vectors.pgvector_compatibility=on`);
await manager.query(`
CREATE INDEX IF NOT EXISTS ${index} ON ${table}
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`);
});
} else {
throw error;
}
}
}
async shouldReindex(name: VectorIndex): Promise<boolean> {
if (vectorExt !== DatabaseExtension.VECTORS) {
return false;
}
try {
const res = await this.dataSource.query(
`
SELECT idx_status
FROM pg_vector_index_stat
WHERE indexname = $1`,
[name],
);
return res[0]?.['idx_status'] === 'UPGRADE';
} catch (error) {
const message: string = (error as any).message;
if (message.includes('index is not existing')) {
return true;
} else if (message.includes('relation "pg_vector_index_stat" does not exist')) {
return false;
}
throw error;
}
}
private async setSearchPath(manager: EntityManager): Promise<void> {
await manager.query(`SET search_path TO "$user", public, vectors`);
}
private async updateVectorsSchema(manager: EntityManager, curVersion: Version): Promise<void> {
await manager.query('CREATE SCHEMA IF NOT EXISTS vectors');
await manager.query(`UPDATE pg_catalog.pg_extension SET extversion = $1 WHERE extname = $2`, [
curVersion.toString(),
DatabaseExtension.VECTORS,
]);
await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = $1', [
DatabaseExtension.VECTORS,
]);
await manager.query('ALTER EXTENSION vectors SET SCHEMA vectors');
await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = $1', [
DatabaseExtension.VECTORS,
]);
}
private async getDimSize(table: string, column = 'embedding'): Promise<number> {
const res = await this.dataSource.query(`
SELECT atttypmod as dimsize
FROM pg_attribute f
JOIN pg_class c ON c.oid = f.attrelid
WHERE c.relkind = 'r'::char
AND f.attnum > 0
AND c.relname = '${table}'
AND f.attname = '${column}'`);
const dimSize = res[0]['dimsize'];
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
throw new Error(`Could not retrieve dimension size`);
}
return dimSize;
}
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void> {
await this.dataSource.runMigrations(options);
}
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {
let res;
await this.asyncLock.acquire(DatabaseLock[lock], async () => {
const queryRunner = this.dataSource.createQueryRunner();
try {
await this.acquireLock(lock, queryRunner);
res = await callback();
} finally {
try {
await this.releaseLock(lock, queryRunner);
} finally {
await queryRunner.release();
}
}
});
return res as R;
}
async tryLock(lock: DatabaseLock): Promise<boolean> {
const queryRunner = this.dataSource.createQueryRunner();
return await this.acquireTryLock(lock, queryRunner);
}
isBusy(lock: DatabaseLock): boolean {
return this.asyncLock.isBusy(DatabaseLock[lock]);
}
async wait(lock: DatabaseLock): Promise<void> {
await this.asyncLock.acquire(DatabaseLock[lock], () => {});
}
private async acquireLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise<void> {
return queryRunner.query('SELECT pg_advisory_lock($1)', [lock]);
}
private async acquireTryLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise<boolean> {
const lockResult = await queryRunner.query('SELECT pg_try_advisory_lock($1)', [lock]);
return lockResult[0].pg_try_advisory_lock;
}
private async releaseLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise<void> {
return queryRunner.query('SELECT pg_advisory_unlock($1)', [lock]);
}
}

View File

@@ -0,0 +1,207 @@
import mockfs from 'mock-fs';
import { CrawlOptionsDto } from 'src/domain/library/library.dto';
import { FilesystemProvider } from 'src/repositories/filesystem.provider';
interface Test {
test: string;
options: CrawlOptionsDto;
files: Record<string, boolean>;
}
const cwd = process.cwd();
const tests: Test[] = [
{
test: 'should return empty when crawling an empty path list',
options: {
pathsToCrawl: [],
},
files: {},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should exclude by file extension',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.tif'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by file extension without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.TIF'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by folder',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/raw/**'],
},
files: {
'/photos/image.jpg': true,
'/photos/raw/image.jpg': false,
'/photos/raw2/image.jpg': true,
'/photos/folder/raw/image.jpg': false,
'/photos/crawl/image.jpg': true,
},
},
{
test: 'should crawl multiple paths',
options: {
pathsToCrawl: ['/photos/', '/images/', '/albums/'],
},
files: {
'/photos/image1.jpg': true,
'/images/image2.jpg': true,
'/albums/image3.jpg': true,
},
},
{
test: 'should support globbing paths',
options: {
pathsToCrawl: ['/photos*'],
},
files: {
'/photos1/image1.jpg': true,
'/photos2/image2.jpg': true,
'/images/image3.jpg': false,
},
},
{
test: 'should crawl a single path without trailing slash',
options: {
pathsToCrawl: ['/photos'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/subfolder/image1.jpg': true,
'/photos/subfolder/image2.jpg': true,
'/image1.jpg': false,
},
},
{
test: 'should filter file extensions',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.txt': false,
'/photos/1': false,
},
},
{
test: 'should include photo and video extensions',
options: {
pathsToCrawl: ['/photos/', '/videos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.jpeg': true,
'/photos/image.heic': true,
'/photos/image.heif': true,
'/photos/image.png': true,
'/photos/image.gif': true,
'/photos/image.tif': true,
'/photos/image.tiff': true,
'/photos/image.webp': true,
'/photos/image.dng': true,
'/photos/image.nef': true,
'/videos/video.mp4': true,
'/videos/video.mov': true,
'/videos/video.webm': true,
},
},
{
test: 'should check file extensions without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.Jpg': true,
'/photos/image.jpG': true,
'/photos/image.JPG': true,
'/photos/image.jpEg': true,
'/photos/image.TIFF': true,
'/photos/image.tif': true,
'/photos/image.dng': true,
'/photos/image.NEF': true,
},
},
{
test: 'should normalize the path',
options: {
pathsToCrawl: ['/photos/1/../2'],
},
files: {
'/photos/1/image.jpg': false,
'/photos/2/image.jpg': true,
},
},
{
test: 'should return absolute paths',
options: {
pathsToCrawl: ['photos'],
},
files: {
[`${cwd}/photos/1.jpg`]: true,
[`${cwd}/photos/2.jpg`]: true,
[`/photos/3.jpg`]: false,
},
},
];
describe(FilesystemProvider.name, () => {
let sut: FilesystemProvider;
beforeEach(() => {
sut = new FilesystemProvider();
});
afterEach(() => {
mockfs.restore();
});
describe('crawl', () => {
for (const { test, options, files } of tests) {
it(test, async () => {
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
const actual = await sut.crawl(options);
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => file);
expect(actual.sort()).toEqual(expected.sort());
});
}
});
});

View File

@@ -0,0 +1,190 @@
import archiver from 'archiver';
import chokidar, { WatchOptions } from 'chokidar';
import { glob, globStream } from 'fast-glob';
import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { mimeTypes } from 'src/domain/domain.constant';
import { CrawlOptionsDto } from 'src/domain/library/library.dto';
import { Instrumentation } from 'src/infra/instrumentation';
import { ImmichLogger } from 'src/infra/logger';
import {
DiskUsage,
IStorageRepository,
ImmichReadStream,
ImmichZipStream,
StorageEventType,
WatchEvents,
} from 'src/interfaces/storage.repository';
@Instrumentation()
export class FilesystemProvider implements IStorageRepository {
private logger = new ImmichLogger(FilesystemProvider.name);
readdir(folder: string): Promise<string[]> {
return fs.readdir(folder);
}
copyFile(source: string, target: string) {
return fs.copyFile(source, target);
}
stat(filepath: string) {
return fs.stat(filepath);
}
writeFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer);
}
rename(source: string, target: string) {
return fs.rename(source, target);
}
utimes(filepath: string, atime: Date, mtime: Date) {
return fs.utimes(filepath, atime, mtime);
}
createZipStream(): ImmichZipStream {
const archive = archiver('zip', { store: true });
const addFile = (input: string, filename: string) => {
archive.file(input, { name: filename });
};
const finalize = () => archive.finalize();
return { stream: archive, addFile, finalize };
}
async createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream> {
const { size } = await fs.stat(filepath);
await fs.access(filepath, constants.R_OK);
return {
stream: createReadStream(filepath),
length: size,
type: mimeType || undefined,
};
}
async readFile(filepath: string, options?: fs.FileReadOptions<Buffer>): Promise<Buffer> {
const file = await fs.open(filepath);
try {
const { buffer } = await file.read(options);
return buffer;
} finally {
await file.close();
}
}
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
try {
await fs.access(filepath, mode);
return true;
} catch {
return false;
}
}
async unlink(file: string) {
try {
await fs.unlink(file);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
this.logger.warn(`File ${file} does not exist.`);
} else {
throw error;
}
}
}
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
await fs.rm(folder, options);
}
async removeEmptyDirs(directory: string, self: boolean = false) {
// 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 });
}
}
async checkDiskUsage(folder: string): Promise<DiskUsage> {
const stats = await fs.statfs(folder);
return {
available: stats.bavail * stats.bsize,
free: stats.bfree * stats.bsize,
total: stats.blocks * stats.bsize,
};
}
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
if (pathsToCrawl.length === 0) {
return Promise.resolve([]);
}
return glob(this.asGlob(pathsToCrawl), {
absolute: true,
caseSensitiveMatch: false,
onlyFiles: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
}
async *walk(crawlOptions: CrawlOptionsDto): AsyncGenerator<string> {
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
if (pathsToCrawl.length === 0) {
async function* emptyGenerator() {}
return emptyGenerator();
}
const stream = globStream(this.asGlob(pathsToCrawl), {
absolute: true,
caseSensitiveMatch: false,
onlyFiles: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
for await (const value of stream) {
yield value as string;
}
}
watch(paths: string[], options: WatchOptions, events: Partial<WatchEvents>) {
const watcher = chokidar.watch(paths, options);
watcher.on(StorageEventType.READY, () => events.onReady?.());
watcher.on(StorageEventType.ADD, (path) => events.onAdd?.(path));
watcher.on(StorageEventType.CHANGE, (path) => events.onChange?.(path));
watcher.on(StorageEventType.UNLINK, (path) => events.onUnlink?.(path));
watcher.on(StorageEventType.ERROR, (error) => events.onError?.(error));
return () => watcher.close();
}
private asGlob(pathsToCrawl: string[]): string {
const base = pathsToCrawl.length === 1 ? pathsToCrawl[0] : `{${pathsToCrawl.join(',')}}`;
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
return `${base}/**/${extensions}`;
}
}

View File

@@ -0,0 +1,183 @@
import { getQueueToken } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule';
import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
import { CronJob, CronTime } from 'cron';
import { setTimeout } from 'node:timers/promises';
import { bullConfig } from 'src/config';
import { JOBS_TO_QUEUE, JobName, QueueName } from 'src/domain/job/job.constants';
import { Instrumentation } from 'src/infra/instrumentation';
import { ImmichLogger } from 'src/infra/logger';
import { IJobRepository, JobCounts, JobItem, QueueCleanType, QueueStatus } from 'src/interfaces/job.repository';
@Instrumentation()
@Injectable()
export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private logger = new ImmichLogger(JobRepository.name);
constructor(
private moduleReference: ModuleRef,
private schedulerReqistry: SchedulerRegistry,
) {}
addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise<void>) {
const workerHandler: Processor = async (job: Job) => handler(job as JobItem);
const workerOptions: WorkerOptions = { ...bullConfig, concurrency };
this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions);
}
addCronJob(name: string, expression: string, onTick: () => void, start = true): void {
const job = new CronJob<null, null>(
expression,
onTick,
// function to run onComplete
undefined,
// whether it should start directly
start,
// timezone
undefined,
// context
undefined,
// runOnInit
undefined,
// utcOffset
undefined,
// prevents memory leaking by automatically stopping when the node process finishes
true,
);
this.schedulerReqistry.addCronJob(name, job);
}
updateCronJob(name: string, expression?: string, start?: boolean): void {
const job = this.schedulerReqistry.getCronJob(name);
if (expression) {
job.setTime(new CronTime(expression));
}
if (start !== undefined) {
start ? job.start() : job.stop();
}
}
deleteCronJob(name: string): void {
this.schedulerReqistry.deleteCronJob(name);
}
setConcurrency(queueName: QueueName, concurrency: number) {
const worker = this.workers[queueName];
if (!worker) {
this.logger.warn(`Unable to set queue concurrency, worker not found: '${queueName}'`);
return;
}
worker.concurrency = concurrency;
}
async getQueueStatus(name: QueueName): Promise<QueueStatus> {
const queue = this.getQueue(name);
return {
isActive: !!(await queue.getActiveCount()),
isPaused: await queue.isPaused(),
};
}
pause(name: QueueName) {
return this.getQueue(name).pause();
}
resume(name: QueueName) {
return this.getQueue(name).resume();
}
empty(name: QueueName) {
return this.getQueue(name).drain();
}
clear(name: QueueName, type: QueueCleanType) {
return this.getQueue(name).clean(0, 1000, type);
}
getJobCounts(name: QueueName): Promise<JobCounts> {
return this.getQueue(name).getJobCounts(
'active',
'completed',
'failed',
'delayed',
'waiting',
'paused',
) as unknown as Promise<JobCounts>;
}
async queueAll(items: JobItem[]): Promise<void> {
if (items.length === 0) {
return;
}
const promises = [];
const itemsByQueue = {} as Record<string, (JobItem & { data: any; options: JobsOptions | undefined })[]>;
for (const item of items) {
const queueName = JOBS_TO_QUEUE[item.name];
const job = {
name: item.name,
data: item.data || {},
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId) {
// need to use add() instead of addBulk() for jobId deduplication
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else {
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
itemsByQueue[queueName].push(job);
}
}
for (const [queueName, jobs] of Object.entries(itemsByQueue)) {
const queue = this.getQueue(queueName as QueueName);
promises.push(queue.addBulk(jobs));
}
await Promise.all(promises);
}
async queue(item: JobItem): Promise<void> {
return this.queueAll([item]);
}
async waitForQueueCompletion(...queues: QueueName[]): Promise<void> {
let activeQueue: QueueStatus | undefined;
do {
const statuses = await Promise.all(queues.map((name) => this.getQueueStatus(name)));
activeQueue = statuses.find((status) => status.isActive);
} while (activeQueue);
{
this.logger.verbose(`Waiting for ${activeQueue} queue to stop...`);
await setTimeout(1000);
}
}
private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) {
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
return { jobId: item.data.id };
}
case JobName.GENERATE_PERSON_THUMBNAIL: {
return { priority: 1 };
}
case JobName.QUEUE_FACIAL_RECOGNITION: {
return { jobId: JobName.QUEUE_FACIAL_RECOGNITION };
}
default: {
return null;
}
}
}
private getQueue(queue: QueueName): Queue {
return this.moduleReference.get<Queue>(getQueueToken(queue), { strict: false });
}
}

View File

@@ -0,0 +1,198 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { LibraryStatsResponseDto } from 'src/domain/library/library.dto';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { ILibraryRepository } from 'src/interfaces/library.repository';
import { IsNull, Not } from 'typeorm';
import { Repository } from 'typeorm/repository/Repository.js';
@Instrumentation()
@Injectable()
export class LibraryRepository implements ILibraryRepository {
constructor(@InjectRepository(LibraryEntity) private repository: Repository<LibraryEntity>) {}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string, withDeleted = false): Promise<LibraryEntity | null> {
return this.repository.findOneOrFail({
where: {
id,
},
relations: { owner: true },
withDeleted,
});
}
@GenerateSql({ params: [DummyValue.STRING] })
existsByName(name: string, withDeleted = false): Promise<boolean> {
return this.repository.exist({
where: {
name,
},
withDeleted,
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getCountForUser(ownerId: string): Promise<number> {
return this.repository.countBy({ ownerId });
}
@GenerateSql({ params: [DummyValue.UUID] })
getDefaultUploadLibrary(ownerId: string): Promise<LibraryEntity | null> {
return this.repository.findOne({
where: {
ownerId: ownerId,
type: LibraryType.UPLOAD,
},
order: {
createdAt: 'ASC',
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getUploadLibraryCount(ownerId: string): Promise<number> {
return this.repository.count({
where: {
ownerId: ownerId,
type: LibraryType.UPLOAD,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getAllByUserId(ownerId: string, type?: LibraryType): Promise<LibraryEntity[]> {
return this.repository.find({
where: {
ownerId,
isVisible: true,
type,
},
relations: {
owner: true,
},
order: {
createdAt: 'ASC',
},
});
}
@GenerateSql({ params: [] })
getAll(withDeleted = false, type?: LibraryType): Promise<LibraryEntity[]> {
return this.repository.find({
where: { type },
relations: {
owner: true,
},
order: {
createdAt: 'ASC',
},
withDeleted,
});
}
@GenerateSql()
getAllDeleted(): Promise<LibraryEntity[]> {
return this.repository.find({
where: {
isVisible: true,
deletedAt: Not(IsNull()),
},
relations: {
owner: true,
},
order: {
createdAt: 'ASC',
},
withDeleted: true,
});
}
create(library: Omit<LibraryEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId'>): Promise<LibraryEntity> {
return this.repository.save(library);
}
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
async softDelete(id: string): Promise<void> {
await this.repository.softDelete({ id });
}
async update(library: Partial<LibraryEntity>): Promise<LibraryEntity> {
return this.save(library);
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
const stats = await this.repository
.createQueryBuilder('libraries')
.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('libraries.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('libraries.id')
.where('libraries.id = :id', { id })
.getRawOne();
return {
photos: Number(stats.photos),
videos: Number(stats.videos),
usage: Number(stats.usage),
total: Number(stats.photos) + Number(stats.videos),
};
}
@GenerateSql({ params: [DummyValue.UUID] })
async getOnlineAssetPaths(libraryId: string): Promise<string[]> {
// Return all non-offline asset paths for a given library
const rawResults = await this.repository
.createQueryBuilder('library')
.innerJoinAndSelect('library.assets', 'assets')
.where('library.id = :id', { id: libraryId })
.andWhere('assets.isOffline = false')
.select('assets.originalPath')
.getRawMany();
const results: string[] = [];
for (const rawPath of rawResults) {
results.push(rawPath.assets_originalPath);
}
return results;
}
@GenerateSql({ params: [DummyValue.UUID] })
async getAssetIds(libraryId: string, withDeleted = false): Promise<string[]> {
let query = this.repository
.createQueryBuilder('library')
.innerJoinAndSelect('library.assets', 'assets')
.where('library.id = :id', { id: libraryId })
.select('assets.id');
if (withDeleted) {
query = query.withDeleted();
}
// Return all asset paths for a given library
const rawResults = await query.getRawMany();
const results: string[] = [];
for (const rawPath of rawResults) {
results.push(rawPath.assets_id);
}
return results;
}
private async save(library: Partial<LibraryEntity>) {
const { id } = await this.repository.save(library);
return this.repository.findOneByOrFail({ id });
}
}

View File

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
import { CLIPConfig, ModelConfig, RecognitionConfig } from 'src/domain/smart-info/dto/model-config.dto';
import { Instrumentation } from 'src/infra/instrumentation';
import {
CLIPMode,
DetectFaceResult,
IMachineLearningRepository,
ModelType,
TextModelInput,
VisionModelInput,
} from 'src/interfaces/machine-learning.repository';
const errorPrefix = 'Machine learning request';
@Instrumentation()
@Injectable()
export class MachineLearningRepository implements IMachineLearningRepository {
private async predict<T>(url: string, input: TextModelInput | VisionModelInput, config: ModelConfig): Promise<T> {
const formData = await this.getFormData(input, config);
const res = await fetch(`${url}/predict`, { method: 'POST', body: formData }).catch((error: Error | any) => {
throw new Error(`${errorPrefix} to "${url}" failed with ${error?.cause || error}`);
});
if (res.status >= 400) {
const modelType = config.modelType ? ` for ${config.modelType.replace('-', ' ')}` : '';
throw new Error(`${errorPrefix}${modelType} failed with status ${res.status}: ${res.statusText}`);
}
return res.json();
}
detectFaces(url: string, input: VisionModelInput, config: RecognitionConfig): Promise<DetectFaceResult[]> {
return this.predict<DetectFaceResult[]>(url, input, { ...config, modelType: ModelType.FACIAL_RECOGNITION });
}
encodeImage(url: string, input: VisionModelInput, config: CLIPConfig): Promise<number[]> {
return this.predict<number[]>(url, input, {
...config,
modelType: ModelType.CLIP,
mode: CLIPMode.VISION,
} as CLIPConfig);
}
encodeText(url: string, input: TextModelInput, config: CLIPConfig): Promise<number[]> {
return this.predict<number[]>(url, input, {
...config,
modelType: ModelType.CLIP,
mode: CLIPMode.TEXT,
} as CLIPConfig);
}
private async getFormData(input: TextModelInput | VisionModelInput, config: ModelConfig): Promise<FormData> {
const formData = new FormData();
const { enabled, modelName, modelType, ...options } = config;
if (!enabled) {
throw new Error(`${modelType} is not enabled`);
}
formData.append('modelName', modelName);
if (modelType) {
formData.append('modelType', modelType);
}
if (options) {
formData.append('options', JSON.stringify(options));
}
if ('imagePath' in input) {
formData.append('image', new Blob([await readFile(input.imagePath)]));
} else if ('text' in input) {
formData.append('text', input.text);
} else {
throw new Error('Invalid input');
}
return formData;
}
}

View File

@@ -0,0 +1,145 @@
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { Colorspace } from 'src/entities/system-config.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { ImmichLogger } from 'src/infra/logger';
import {
CropOptions,
IMediaRepository,
ResizeOptions,
TranscodeOptions,
VideoInfo,
} from 'src/interfaces/media.repository';
import { handlePromiseError } from 'src/utils';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
sharp.concurrency(0);
sharp.cache({ files: 0 });
@Instrumentation()
export class MediaRepository implements IMediaRepository {
private logger = new ImmichLogger(MediaRepository.name);
crop(input: string | Buffer, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOn: 'none' })
.pipelineColorspace('rgb16')
.extract({
left: options.left,
top: options.top,
width: options.width,
height: options.height,
})
.toBuffer();
}
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
await sharp(input, { failOn: 'none' })
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.rotate()
.withIccProfile(options.colorspace)
.toFormat(options.format, {
quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
})
.toFile(output);
}
async probe(input: string): Promise<VideoInfo> {
const results = await probe(input);
return {
format: {
formatName: results.format.format_name,
formatLongName: results.format.format_long_name,
duration: results.format.duration || 0,
bitrate: results.format.bit_rate ?? 0,
},
videoStreams: results.streams
.filter((stream) => stream.codec_type === 'video')
.map((stream) => ({
index: stream.index,
height: stream.height || 0,
width: stream.width || 0,
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type,
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
rotation: Number.parseInt(`${stream.rotation ?? 0}`),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: Number.parseInt(stream.bit_rate ?? '0'),
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')
.map((stream) => ({
index: stream.index,
codecType: stream.codec_type,
codecName: stream.codec_name,
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
})),
};
}
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
});
}
if (typeof output !== 'string') {
throw new TypeError('Two-pass transcoding does not support writing to a stream');
}
// two-pass allows for precise control of bitrate at the cost of running twice
// recommended for vp9 for better quality and compression
return new Promise((resolve, reject) => {
// first pass output is not saved as only the .log file is needed
this.configureFfmpegCall(input, '/dev/null', options)
.addOptions('-pass', '1')
.addOptions('-passlogfile', output)
.addOptions('-f null')
.on('error', reject)
.on('end', () => {
// second pass
this.configureFfmpegCall(input, output, options)
.addOptions('-pass', '2')
.addOptions('-passlogfile', output)
.on('error', reject)
.on('end', () => handlePromiseError(fs.unlink(`${output}-0.log`), this.logger))
.on('end', () => handlePromiseError(fs.rm(`${output}-0.log.mbtree`, { force: true }), this.logger))
.on('end', resolve)
.run();
})
.run();
});
}
async generateThumbhash(imagePath: string): Promise<Buffer> {
const maxSize = 100;
const { data, info } = await sharp(imagePath)
.resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const thumbhash = await import('thumbhash');
return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data));
}
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) {
return ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)
.output(output)
.on('error', (error, stdout, stderr) => this.logger.error(stderr || error));
}
private chainPath(existing: string, path: string) {
const separator = existing.endsWith(':') ? '' : ':';
return `${existing}${separator}${path}`;
}
}

View File

@@ -0,0 +1,314 @@
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, Tags, exiftool } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { getName } from 'i18n-iso-countries';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import readLine from 'node:readline';
import { DummyValue, GenerateSql } from 'src/decorators';
import {
citiesFile,
geodataAdmin1Path,
geodataAdmin2Path,
geodataCities500Path,
geodataDatePath,
} from 'src/domain/domain.constant';
import { ExifEntity } from 'src/entities/exif.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { ImmichLogger } from 'src/infra/logger';
import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from 'src/interfaces/metadata.repository';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.repository';
import { DataSource, QueryRunner, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
@Instrumentation()
export class MetadataRepository implements IMetadataRepository {
constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@Inject(ISystemMetadataRepository)
private readonly systemMetadataRepository: ISystemMetadataRepository,
@InjectDataSource() private dataSource: DataSource,
) {}
private logger = new ImmichLogger(MetadataRepository.name);
async init(): Promise<void> {
this.logger.log('Initializing metadata repository');
const geodataDate = await readFile(geodataDatePath, 'utf8');
const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
if (geocodingMetadata?.lastUpdate === geodataDate) {
return;
}
await this.importGeodata();
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
lastUpdate: geodataDate,
lastImportFileName: citiesFile,
});
this.logger.log('Geodata import completed');
}
private async importGeodata() {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
const admin1 = await this.loadAdmin(geodataAdmin1Path);
const admin2 = await this.loadAdmin(geodataAdmin2Path);
try {
await queryRunner.startTransaction();
await queryRunner.manager.clear(GeodataPlacesEntity);
await this.loadCities500(queryRunner, admin1, admin2);
await queryRunner.commitTransaction();
} catch (error) {
this.logger.fatal('Error importing geodata', error);
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
private async loadGeodataToTableFromFile(
queryRunner: QueryRunner,
lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity,
filePath: string,
) {
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`);
}
const input = createReadStream(filePath);
let bufferGeodata: QueryDeepPartialEntity<GeodataPlacesEntity>[] = [];
const lineReader = readLine.createInterface({ input });
for await (const line of lineReader) {
const lineSplit = line.split('\t');
const geoData = lineToEntityMapper(lineSplit);
bufferGeodata.push(geoData);
if (bufferGeodata.length > 1000) {
await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
bufferGeodata = [];
}
}
await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
}
private async loadCities500(
queryRunner: QueryRunner,
admin1Map: Map<string, string>,
admin2Map: Map<string, string>,
) {
await this.loadGeodataToTableFromFile(
queryRunner,
(lineSplit: string[]) =>
this.geodataPlacesRepository.create({
id: Number.parseInt(lineSplit[0]),
name: lineSplit[1],
alternateNames: lineSplit[3],
latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8],
admin1Code: lineSplit[10],
admin2Code: lineSplit[11],
modificationDate: lineSplit[18],
admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
}),
geodataCities500Path,
);
}
private async loadAdmin(filePath: string) {
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`);
}
const input = createReadStream(filePath);
const lineReader = readLine.createInterface({ input: input });
const adminMap = new Map<string, string>();
for await (const line of lineReader) {
const lineSplit = line.split('\t');
adminMap.set(lineSplit[0], lineSplit[1]);
}
return adminMap;
}
async teardown() {
await exiftool.end();
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
const response = await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
.limit(1)
.getOne();
if (!response) {
this.logger.warn(
`Response from database for reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
);
return null;
}
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
const { countryCode, name: city, admin1Name, admin2Name } = response;
const country = getName(countryCode, 'en') ?? null;
const stateParts = [admin2Name, admin1Name].filter((name) => !!name);
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
return { country, state, city };
}
readTags(path: string): Promise<ImmichTags | null> {
return exiftool
.read(path, undefined, {
...DefaultReadTaskOptions,
defaultVideosToUTC: true,
backfillTimezones: true,
inferTimezoneFromDatestamps: true,
useMWG: true,
numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'],
/* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
})
.catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
return null;
}) as Promise<ImmichTags | null>;
}
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
return exiftool.extractBinaryTagToBuffer(tagName, path);
}
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
try {
await exiftool.write(path, tags, ['-overwrite_original']);
} catch (error) {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}
}
@GenerateSql({ params: [DummyValue.UUID] })
async getCountries(userId: string): Promise<string[]> {
const entity = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.country IS NOT NULL')
.select('exif.country')
.distinctOn(['exif.country'])
.getMany();
return entity.map((e) => e.country ?? '').filter((c) => c !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getStates(userId: string, country: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.state IS NOT NULL')
.select('exif.state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
result = await query.getMany();
return result.map((entity) => entity.state ?? '').filter((s) => s !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.city IS NOT NULL')
.select('exif.city')
.distinctOn(['exif.city']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
if (state) {
query.andWhere('exif.state = :state', { state });
}
result = await query.getMany();
return result.map((entity) => entity.city ?? '').filter((c) => c !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.make IS NOT NULL')
.select('exif.make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
result = await query.getMany();
return result.map((entity) => entity.make ?? '').filter((m) => m !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.model IS NOT NULL')
.select('exif.model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
result = await query.getMany();
return result.map((entity) => entity.model ?? '').filter((m) => m !== '');
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { MoveEntity, PathType } from 'src/entities/move.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { IMoveRepository, MoveCreate } from 'src/interfaces/move.repository';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class MoveRepository implements IMoveRepository {
constructor(@InjectRepository(MoveEntity) private repository: Repository<MoveEntity>) {}
create(entity: MoveCreate): Promise<MoveEntity> {
return this.repository.save(entity);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByEntity(entityId: string, pathType: PathType): Promise<MoveEntity | null> {
return this.repository.findOne({ where: { entityId, pathType } });
}
update(entity: Partial<MoveEntity>): Promise<MoveEntity> {
return this.repository.save(entity);
}
delete(move: MoveEntity): Promise<MoveEntity> {
return this.repository.remove(move);
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { PartnerEntity } from 'src/entities/partner.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { IPartnerRepository, PartnerIds } from 'src/interfaces/partner.repository';
import { DeepPartial, Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class PartnerRepository implements IPartnerRepository {
constructor(@InjectRepository(PartnerEntity) private readonly repository: Repository<PartnerEntity>) {}
getAll(userId: string): Promise<PartnerEntity[]> {
return this.repository.find({ where: [{ sharedWithId: userId }, { sharedById: userId }] });
}
get({ sharedWithId, sharedById }: PartnerIds): Promise<PartnerEntity | null> {
return this.repository.findOne({ where: { sharedById, sharedWithId } });
}
create({ sharedById, sharedWithId }: PartnerIds): Promise<PartnerEntity> {
return this.save({ sharedBy: { id: sharedById }, sharedWith: { id: sharedWithId } });
}
async remove(entity: PartnerEntity): Promise<void> {
await this.repository.remove(entity);
}
update(entity: Partial<PartnerEntity>): Promise<PartnerEntity> {
return this.save(entity);
}
private async save(entity: DeepPartial<PartnerEntity>): Promise<PartnerEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({
where: { sharedById: entity.sharedById, sharedWithId: entity.sharedWithId },
});
}
}

View File

@@ -0,0 +1,267 @@
import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { asVector, paginate } from 'src/infra/infra.utils';
import { Instrumentation } from 'src/infra/instrumentation';
import {
AssetFaceId,
IPersonRepository,
PeopleStatistics,
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
UpdateFacesData,
} from 'src/interfaces/person.repository';
import { Paginated, PaginationOptions } from 'src/utils';
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
@Instrumentation()
export class PersonRepository implements IPersonRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined))
.execute();
return result.affected ?? 0;
}
async delete(entities: PersonEntity[]): Promise<void> {
await this.personRepository.remove(entities);
}
async deleteAll(): Promise<void> {
await this.personRepository.clear();
}
async deleteAllFaces(): Promise<void> {
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
}
getAllFaces(
pagination: PaginationOptions,
options: FindManyOptions<AssetFaceEntity> = {},
): Paginated<AssetFaceEntity> {
return paginate(this.assetFaceRepository, pagination, options);
}
getAll(pagination: PaginationOptions, options: FindManyOptions<PersonEntity> = {}): Paginated<PersonEntity> {
return paginate(this.personRepository, pagination, options);
}
@GenerateSql({ params: [DummyValue.UUID] })
getAllForUser(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> {
const queryBuilder = this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
.innerJoin('face.asset', 'asset')
.andWhere('asset.isArchived = false')
.orderBy('person.isHidden', 'ASC')
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
.addOrderBy('COUNT(face.assetId)', 'DESC')
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
.andWhere("person.thumbnailPath != ''")
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id')
.limit(500);
if (!options?.withHidden) {
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
}
@GenerateSql()
getAllWithoutFaces(): Promise<PersonEntity[]> {
return this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.having('COUNT(face.assetId) = 0')
.groupBy('person.id')
.withDeleted()
.getMany();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({
where: { assetId },
relations: {
person: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> {
return this.assetFaceRepository.findOneOrFail({
where: { id },
relations: {
person: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets(
id: string,
relations: FindOptionsRelations<AssetFaceEntity>,
select: FindOptionsSelect<AssetFaceEntity>,
): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOne(
_.omitBy(
{
where: { id },
relations: {
...relations,
person: true,
asset: true,
},
select,
},
_.isUndefined,
),
);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.where({ id: assetFaceId })
.execute();
return result.affected ?? 0;
}
getById(personId: string): Promise<PersonEntity | null> {
return this.personRepository.findOne({ where: { id: personId } });
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
const queryBuilder = this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where(
'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)',
{ userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` },
)
.groupBy('person.id')
.orderBy('COUNT(face.assetId)', 'DESC')
.limit(20);
if (!withHidden) {
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> {
const items = await this.assetFaceRepository
.createQueryBuilder('face')
.leftJoin('face.asset', 'asset')
.where('face.personId = :personId', { personId })
.andWhere('asset.isArchived = false')
.andWhere('asset.deletedAt IS NULL')
.andWhere('asset.livePhotoVideoId IS NULL')
.select('COUNT(DISTINCT(asset.id))', 'count')
.getRawOne();
return {
assets: items.count ?? 0,
};
}
@GenerateSql({ params: [DummyValue.UUID] })
getAssets(personId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
faces: {
personId,
},
isVisible: true,
isArchived: false,
},
relations: {
faces: {
person: true,
},
exifInfo: true,
},
order: {
fileCreatedAt: 'desc',
},
// TODO: remove after either (1) pagination or (2) time bucket is implemented for this query
take: 1000,
});
}
@GenerateSql({ params: [DummyValue.UUID] })
async getNumberOfPeople(userId: string): Promise<PeopleStatistics> {
const items = await this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
.innerJoin('face.asset', 'asset')
.andWhere('asset.isArchived = false')
.andWhere("person.thumbnailPath != ''")
.select('COUNT(DISTINCT(person.id))', 'total')
.addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden')
.having('COUNT(face.assetId) != 0')
.getRawOne();
if (items == undefined) {
return { total: 0, hidden: 0 };
}
const result: PeopleStatistics = {
total: items.total ?? 0,
hidden: items.hidden ?? 0,
};
return result;
}
create(entity: Partial<PersonEntity>): Promise<PersonEntity> {
return this.personRepository.save(entity);
}
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
const res = await this.assetFaceRepository.insert(
entities.map((entity) => ({ ...entity, embedding: () => asVector(entity.embedding, true) })),
);
return res.identifiers.map((row) => row.id);
}
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
const { id } = await this.personRepository.save(entity);
return this.personRepository.findOneByOrFail({ id });
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@ChunkedArray()
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
}
@GenerateSql({ params: [DummyValue.UUID] })
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ personId });
}
}

View File

@@ -0,0 +1,346 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { getCLIPModelInfo } from 'src/domain/smart-info/smart-info.constant';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { vectorExt } from 'src/infra/database.config';
import { asVector, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils';
import { Instrumentation } from 'src/infra/instrumentation';
import { ImmichLogger } from 'src/infra/logger';
import { DatabaseExtension } from 'src/interfaces/database.repository';
import {
AssetSearchOptions,
Embedding,
FaceEmbeddingSearch,
FaceSearchResult,
ISearchRepository,
SearchPaginationOptions,
SmartSearchOptions,
} from 'src/interfaces/search.repository';
import { Paginated, PaginationMode, PaginationResult } from 'src/utils';
import { isValidInteger } from 'src/validation';
import { Repository, SelectQueryBuilder } from 'typeorm';
@Instrumentation()
@Injectable()
export class SearchRepository implements ISearchRepository {
private logger = new ImmichLogger(SearchRepository.name);
private faceColumns: string[];
private assetsByCityQuery: string;
constructor(
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
) {
this.faceColumns = this.assetFaceRepository.manager.connection
.getMetadata(AssetFaceEntity)
.ownColumns.map((column) => column.propertyName)
.filter((propertyName) => propertyName !== 'embedding');
this.assetsByCityQuery =
assetsByCityCte +
this.assetRepository
.createQueryBuilder('asset')
.innerJoinAndSelect('asset.exifInfo', 'exif')
.withDeleted()
.getQuery() +
' INNER JOIN cte ON asset.id = cte."assetId"';
}
async init(modelName: string): Promise<void> {
const { dimSize } = getCLIPModelInfo(modelName);
const curDimSize = await this.getDimSize();
this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`);
if (dimSize != curDimSize) {
this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`);
await this.updateDimSize(dimSize);
}
}
@GenerateSql({
params: [
{ page: 1, size: 100 },
{
takenAfter: DummyValue.DATE,
lensModel: DummyValue.STRING,
ownerId: DummyValue.UUID,
withStacked: true,
isFavorite: true,
ownerIds: [DummyValue.UUID],
},
],
})
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
let builder = this.assetRepository.createQueryBuilder('asset');
builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.SKIP_TAKE,
skip: (pagination.page - 1) * pagination.size,
take: pagination.size,
});
}
private createPersonFilter(builder: SelectQueryBuilder<AssetFaceEntity>, personIds: string[]) {
return builder
.select(`${builder.alias}."assetId"`)
.where(`${builder.alias}."personId" IN (:...personIds)`, { personIds })
.groupBy(`${builder.alias}."assetId"`)
.having(`COUNT(DISTINCT ${builder.alias}."personId") = :personCount`, { personCount: personIds.length });
}
@GenerateSql({
params: [
{ page: 1, size: 100 },
{
takenAfter: DummyValue.DATE,
embedding: Array.from({ length: 512 }, Math.random),
lensModel: DummyValue.STRING,
withStacked: true,
isFavorite: true,
userIds: [DummyValue.UUID],
},
],
})
async searchSmart(
pagination: SearchPaginationOptions,
{ embedding, userIds, personIds, ...options }: SmartSearchOptions,
): Paginated<AssetEntity> {
let results: PaginationResult<AssetEntity> = { items: [], hasNextPage: false };
await this.assetRepository.manager.transaction(async (manager) => {
let builder = manager.createQueryBuilder(AssetEntity, 'asset');
if (personIds?.length) {
const assetFaceBuilder = manager.createQueryBuilder(AssetFaceEntity, 'asset_face');
const cte = this.createPersonFilter(assetFaceBuilder, personIds);
builder
.addCommonTableExpression(cte, 'asset_face_ids')
.innerJoin('asset_face_ids', 'a', 'a."assetId" = asset.id');
}
builder = searchAssetBuilder(builder, options);
builder
.innerJoin('asset.smartSearch', 'search')
.andWhere('asset.ownerId IN (:...userIds )')
.orderBy('search.embedding <=> :embedding')
.setParameters({ userIds, embedding: asVector(embedding) });
await manager.query(this.getRuntimeConfig(pagination.size));
results = await paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.LIMIT_OFFSET,
skip: (pagination.page - 1) * pagination.size,
take: pagination.size,
});
});
return results;
}
@GenerateSql({
params: [
{
userIds: [DummyValue.UUID],
embedding: Array.from({ length: 512 }, Math.random),
numResults: 100,
maxDistance: 0.6,
},
],
})
async searchFaces({
userIds,
embedding,
numResults,
maxDistance,
hasPerson,
}: FaceEmbeddingSearch): Promise<FaceSearchResult[]> {
if (!isValidInteger(numResults, { min: 1 })) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
// setting this too low messes with prefilter recall
numResults = Math.max(numResults, 64);
let results: Array<AssetFaceEntity & { distance: number }> = [];
await this.assetRepository.manager.transaction(async (manager) => {
const cte = manager
.createQueryBuilder(AssetFaceEntity, 'faces')
.select('faces.embedding <=> :embedding', 'distance')
.innerJoin('faces.asset', 'asset')
.where('asset.ownerId IN (:...userIds )')
.orderBy('faces.embedding <=> :embedding')
.setParameters({ userIds, embedding: asVector(embedding) });
cte.limit(numResults);
if (hasPerson) {
cte.andWhere('faces."personId" IS NOT NULL');
}
for (const col of this.faceColumns) {
cte.addSelect(`faces.${col}`, col);
}
await manager.query(this.getRuntimeConfig(numResults));
results = await manager
.createQueryBuilder()
.select('res.*')
.addCommonTableExpression(cte, 'cte')
.from('cte', 'res')
.where('res.distance <= :maxDistance', { maxDistance })
.orderBy('res.distance')
.getRawMany();
});
return results.map((row) => ({
face: this.assetFaceRepository.create(row),
distance: row.distance,
}));
}
@GenerateSql({ params: [DummyValue.STRING] })
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
return await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
.where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
.orderBy(
`
COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
`,
)
.setParameters({ placeName })
.limit(20)
.getMany();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
const parameters = [userIds.join(', '), true, false, AssetType.IMAGE];
const rawRes = await this.repository.query(this.assetsByCityQuery, parameters);
const items: AssetEntity[] = [];
for (const res of rawRes) {
const item = { exifInfo: {} as Record<string, any> } as Record<string, any>;
for (const [key, value] of Object.entries(res)) {
if (key.startsWith('exif_')) {
item.exifInfo[key.replace('exif_', '')] = value;
} else {
item[key.replace('asset_', '')] = value;
}
}
items.push(item as AssetEntity);
}
return items;
}
async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
if (!smartInfo.assetId || !embedding) {
return;
}
await this.upsertEmbedding(smartInfo.assetId, embedding);
}
private async upsertEmbedding(assetId: string, embedding: number[]): Promise<void> {
await this.smartSearchRepository.upsert(
{ assetId, embedding: () => asVector(embedding, true) },
{ conflictPaths: ['assetId'] },
);
}
private async updateDimSize(dimSize: number): Promise<void> {
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
}
const curDimSize = await this.getDimSize();
if (curDimSize === dimSize) {
return;
}
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
await this.smartSearchRepository.manager.transaction(async (manager) => {
await manager.clear(SmartSearchEntity);
await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
});
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
}
deleteAllSearchEmbeddings(): Promise<void> {
return this.smartSearchRepository.clear();
}
private async getDimSize(): Promise<number> {
const res = await this.smartSearchRepository.manager.query(`
SELECT atttypmod as dimsize
FROM pg_attribute f
JOIN pg_class c ON c.oid = f.attrelid
WHERE c.relkind = 'r'::char
AND f.attnum > 0
AND c.relname = 'smart_search'
AND f.attname = 'embedding'`);
const dimSize = res[0]['dimsize'];
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
throw new Error(`Could not retrieve CLIP dimension size`);
}
return dimSize;
}
private getRuntimeConfig(numResults?: number): string {
if (vectorExt === DatabaseExtension.VECTOR) {
return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall
}
let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;';
if (numResults) {
runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`;
}
return runtimeConfig;
}
}
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
const assetsByCityCte = `
WITH RECURSIVE cte AS (
(
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
ORDER BY city
LIMIT 1
)
UNION ALL
SELECT l.city, l."assetId"
FROM cte c
, LATERAL (
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE city > c.city AND "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
ORDER BY city
LIMIT 1
) l
)
`;

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Instrumentation } from 'src/infra/instrumentation';
import { GitHubRelease, IServerInfoRepository } from 'src/interfaces/server-info.repository';
@Instrumentation()
@Injectable()
export class ServerInfoRepository implements IServerInfoRepository {
async getGitHubRelease(): Promise<GitHubRelease> {
try {
const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest');
if (!response.ok) {
throw new Error(`GitHub API request failed with status ${response.status}: ${await response.text()}`);
}
return response.json();
} catch (error) {
throw new Error(`Failed to fetch GitHub release: ${error}`);
}
}
}

View File

@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.repository';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class SharedLinkRepository implements ISharedLinkRepository {
constructor(@InjectRepository(SharedLinkEntity) private repository: Repository<SharedLinkEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
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',
},
},
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
assets: true,
album: {
owner: true,
},
},
order: {
createdAt: 'DESC',
},
});
}
@GenerateSql({ params: [DummyValue.BUFFER] })
async getByKey(key: Buffer): Promise<SharedLinkEntity | null> {
return await this.repository.findOne({
where: {
key,
},
relations: {
user: true,
},
});
}
create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
}
update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
}
async remove(entity: SharedLinkEntity): Promise<void> {
await this.repository.remove(entity);
}
private async save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
}
}

View File

@@ -0,0 +1,48 @@
import { InjectRepository } from '@nestjs/typeorm';
import { readFile } from 'node:fs/promises';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { SystemConfigEntity } from 'src/entities/system-config.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { ISystemConfigRepository } from 'src/interfaces/system-config.repository';
import { In, Repository } from 'typeorm';
@Instrumentation()
export class SystemConfigRepository implements ISystemConfigRepository {
constructor(
@InjectRepository(SystemConfigEntity)
private repository: Repository<SystemConfigEntity>,
) {}
async fetchStyle(url: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`);
}
return response.json();
} catch (error) {
throw new Error(`Failed to fetch data from ${url}: ${error}`);
}
}
@GenerateSql()
load(): Promise<SystemConfigEntity[]> {
return this.repository.find();
}
readFile(filename: string): Promise<string> {
return readFile(filename, { encoding: 'utf8' });
}
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);
}
@GenerateSql({ params: [DummyValue.STRING] })
@Chunked()
async deleteKeys(keys: string[]): Promise<void> {
await this.repository.delete({ key: In(keys) });
}
}

View File

@@ -0,0 +1,25 @@
import { InjectRepository } from '@nestjs/typeorm';
import { SystemMetadata, SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.repository';
import { Repository } from 'typeorm';
@Instrumentation()
export class SystemMetadataRepository implements ISystemMetadataRepository {
constructor(
@InjectRepository(SystemMetadataEntity)
private repository: Repository<SystemMetadataEntity>,
) {}
async get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null> {
const metadata = await this.repository.findOne({ where: { key } });
if (!metadata) {
return null;
}
return metadata.value as SystemMetadata[T];
}
async set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void> {
await this.repository.upsert({ key, value }, { conflictPaths: { key: true } });
}
}

View File

@@ -0,0 +1,126 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AssetEntity } from 'src/entities/asset.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { ITagRepository } from 'src/interfaces/tag.repository';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class TagRepository implements ITagRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
) {}
getById(userId: string, id: string): Promise<TagEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
user: true,
},
});
}
getAll(userId: string): Promise<TagEntity[]> {
return this.repository.find({ where: { userId } });
}
create(tag: Partial<TagEntity>): Promise<TagEntity> {
return this.save(tag);
}
update(tag: Partial<TagEntity>): Promise<TagEntity> {
return this.save(tag);
}
async remove(tag: TagEntity): Promise<void> {
await this.repository.remove(tag);
}
async getAssets(userId: string, tagId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
tags: {
userId,
id: tagId,
},
},
relations: {
exifInfo: true,
tags: true,
faces: {
person: true,
},
},
order: {
createdAt: 'ASC',
},
});
}
async addAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
for (const assetId of assetIds) {
const asset = await this.assetRepository.findOneOrFail({
where: {
ownerId: userId,
id: assetId,
},
relations: {
tags: true,
},
});
asset.tags.push({ id } as TagEntity);
await this.assetRepository.save(asset);
}
}
async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
for (const assetId of assetIds) {
const asset = await this.assetRepository.findOneOrFail({
where: {
ownerId: userId,
id: assetId,
},
relations: {
tags: true,
},
});
asset.tags = asset.tags.filter((tag) => tag.id !== id);
await this.assetRepository.save(asset);
}
}
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
return this.repository.exist({
where: {
id: tagId,
userId,
assets: {
id: assetId,
},
},
relations: {
assets: true,
},
});
}
hasName(userId: string, name: string): Promise<boolean> {
return this.repository.exist({
where: {
name,
userId,
},
});
}
private async save(tag: Partial<TagEntity>): Promise<TagEntity> {
const { id } = await this.repository.save(tag);
return this.repository.findOneOrFail({ where: { id }, relations: { user: true } });
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import { IUserTokenRepository } from 'src/interfaces/user-token.repository';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class UserTokenRepository implements IUserTokenRepository {
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
@GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string): Promise<UserTokenEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } });
}
getAll(userId: string): Promise<UserTokenEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
user: true,
},
order: {
updatedAt: 'desc',
createdAt: 'desc',
},
});
}
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
}
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
}
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
}

View File

@@ -0,0 +1,144 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { Instrumentation } from 'src/infra/instrumentation';
import {
IUserRepository,
UserFindOptions,
UserListFilter,
UserStatsQueryResponse,
} from 'src/interfaces/user.repository';
import { IsNull, Not, Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>,
) {}
async get(userId: string, options: UserFindOptions): Promise<UserEntity | null> {
options = options || {};
return this.userRepository.findOne({
where: { id: userId },
withDeleted: options.withDeleted,
});
}
@GenerateSql()
async getAdmin(): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { isAdmin: true } });
}
@GenerateSql()
async hasAdmin(): Promise<boolean> {
return this.userRepository.exist({ where: { isAdmin: true } });
}
@GenerateSql({ params: [DummyValue.EMAIL] })
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();
}
@GenerateSql({ params: [DummyValue.STRING] })
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { storageLabel } });
}
@GenerateSql({ params: [DummyValue.STRING] })
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({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
return this.userRepository.find({
withDeleted,
order: {
createdAt: 'DESC',
},
});
}
create(user: Partial<UserEntity>): Promise<UserEntity> {
return this.save(user);
}
// TODO change to (user: Partial<UserEntity>)
update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
return this.save({ ...user, id });
}
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user);
}
@GenerateSql()
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.name', 'userName')
.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')
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
.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);
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
}
return stats;
}
async updateUsage(id: string, delta: number): Promise<void> {
await this.userRepository.increment({ id }, 'quotaUsageInBytes', delta);
}
@GenerateSql({ params: [DummyValue.UUID] })
async syncUsage(id?: string) {
const subQuery = this.assetRepository
.createQueryBuilder('assets')
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)')
.leftJoin('assets.exifInfo', 'exif')
.where('assets.ownerId = users.id AND NOT assets.isExternal')
.withDeleted();
const query = this.userRepository
.createQueryBuilder('users')
.leftJoin('users.assets', 'assets')
.update()
.set({ quotaUsageInBytes: () => `(${subQuery.getQuery()})` });
if (id) {
query.where('users.id = :id', { id });
}
await query.execute();
}
private async save(user: Partial<UserEntity>) {
const { id } = await this.userRepository.save(user);
return this.userRepository.findOneOrFail({ where: { id }, withDeleted: true });
}
}