mirror of
https://github.com/immich-app/immich.git
synced 2026-03-07 10:37:22 +03:00
chore: migrate database files (#8126)
This commit is contained in:
820
server/src/repositories/asset.repository.ts
Normal file
820
server/src/repositories/asset.repository.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user