refactor(server,web): time buckets for main timeline, archived, and favorites (1) (#3537)

* refactor: time buckets

* feat(web): use new time bucket api

* feat(web): use asset grid in archive/favorites

* chore: open api

* chore: clean up uuid validation

* refactor(web): move memory lane to photos page

* Update web/src/routes/(user)/archive/+page.svelte

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* fix: hide archived photos on main timeline

* fix: select exif info

---------

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-08-04 17:07:15 -04:00 committed by GitHub
parent e5bdf671b5
commit c6abef186c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1516 additions and 1862 deletions

View file

@ -47,6 +47,23 @@ export enum WithProperty {
SIDECAR = 'sidecar',
}
export enum TimeBucketSize {
DAY = 'DAY',
MONTH = 'MONTH',
}
export interface TimeBucketOptions {
size: TimeBucketSize;
isArchived?: boolean;
isFavorite?: boolean;
albumId?: string;
}
export interface TimeBucketItem {
timeBucket: string;
count: number;
}
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
@ -64,4 +81,6 @@ export interface IAssetRepository {
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
getTimeBuckets(userId: string, options: TimeBucketOptions): Promise<TimeBucketItem[]>;
getByTimeBucket(userId: string, timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
}

View file

@ -10,11 +10,20 @@ import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util';
import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository';
import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto';
import {
AssetIdsDto,
DownloadArchiveInfo,
DownloadDto,
DownloadResponseDto,
MemoryLaneDto,
TimeBucketAssetDto,
TimeBucketDto,
} from './dto';
import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto';
import { MapMarkerDto } from './dto/map-marker.dto';
import { mapAsset, MapMarkerResponseDto } from './response-dto';
import { AssetResponseDto, mapAsset, MapMarkerResponseDto } from './response-dto';
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
import { TimeBucketResponseDto } from './response-dto/time-bucket-response.dto';
export enum UploadFieldName {
ASSET_DATA = 'assetData',
@ -135,6 +144,21 @@ export class AssetService {
return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0));
}
async getTimeBuckets(authUser: AuthUserDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
const { userId, ...options } = dto;
const targetId = userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [targetId]);
return this.assetRepository.getTimeBuckets(targetId, options);
}
async getByTimeBucket(authUser: AuthUserDto, dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
const { userId, timeBucket, ...options } = dto;
const targetId = userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [targetId]);
const assets = await this.assetRepository.getByTimeBucket(targetId, timeBucket, options);
return assets.map(mapAsset);
}
async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, id);

View file

@ -3,3 +3,4 @@ export * from './asset-statistics.dto';
export * from './download.dto';
export * from './map-marker.dto';
export * from './memory-lane.dto';
export * from './time-bucket.dto';

View file

@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toBoolean, ValidateUUID } from '../../domain.util';
import { TimeBucketSize } from '../asset.repository';
export class TimeBucketDto {
@IsNotEmpty()
@IsEnum(TimeBucketSize)
@ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' })
size!: TimeBucketSize;
@ValidateUUID({ optional: true })
userId?: string;
@ValidateUUID({ optional: true })
albumId?: string;
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
isArchived?: boolean;
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
isFavorite?: boolean;
}
export class TimeBucketAssetDto extends TimeBucketDto {
@IsString()
@IsNotEmpty()
timeBucket!: string;
}

View file

@ -3,3 +3,4 @@ export * from './asset-response.dto';
export * from './exif-response.dto';
export * from './map-marker-response.dto';
export * from './smart-info-response.dto';
export * from './time-bucket-response.dto';

View file

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class TimeBucketResponseDto {
@ApiProperty({ type: 'string' })
timeBucket!: string;
@ApiProperty({ type: 'integer' })
count!: number;
}

View file

@ -6,11 +6,8 @@ import { In } from 'typeorm/find-options/operator/In';
import { Repository } from 'typeorm/repository/Repository';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto, TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
@ -36,8 +33,6 @@ export interface IAssetRepository {
getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetCountByTimeBucket(userId: string, dto: GetAssetCountByTimeBucketDto): Promise<AssetCountByTimeBucket[]>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
@ -52,57 +47,6 @@ export class AssetRepository implements IAssetRepository {
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
async getAssetByTimeBucket(userId: string, dto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
// Get asset entity from a list of time buckets
let builder = this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.where('asset.ownerId = :userId', { userId: userId })
.andWhere(`date_trunc('month', "fileCreatedAt") IN (:...buckets)`, {
buckets: [...dto.timeBucket],
})
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.orderBy('asset.fileCreatedAt', 'DESC');
if (!dto.withoutThumbs) {
builder = builder.andWhere('asset.resizePath is not NULL');
}
return builder.getMany();
}
async getAssetCountByTimeBucket(
userId: string,
dto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucket[]> {
const builder = this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)::int`, 'count')
.where('"ownerId" = :userId', { userId: userId })
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false');
// Using a parameter for this doesn't work https://github.com/typeorm/typeorm/issues/7308
if (dto.timeGroup === TimeGroupEnum.Month) {
builder
.addSelect(`date_trunc('month', "fileCreatedAt")`, 'timeBucket')
.groupBy(`date_trunc('month', "fileCreatedAt")`)
.orderBy(`date_trunc('month', "fileCreatedAt")`, 'DESC');
} else if (dto.timeGroup === TimeGroupEnum.Day) {
builder
.addSelect(`date_trunc('day', "fileCreatedAt")`, 'timeBucket')
.groupBy(`date_trunc('day', "fileCreatedAt")`)
.orderBy(`date_trunc('day', "fileCreatedAt")`, 'DESC');
}
if (!dto.withoutThumbs) {
builder.andWhere('asset.resizePath is not NULL');
}
return builder.getRawMany();
}
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
return this.assetRepository
.createQueryBuilder('asset')

View file

@ -30,14 +30,11 @@ import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DeviceIdDto } from './dto/device-id.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto';
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
@ -163,15 +160,6 @@ export class AssetController {
return this.assetService.searchAsset(authUser, dto);
}
@Post('/count-by-time-bucket')
@HttpCode(HttpStatus.OK)
getAssetCountByTimeBucket(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucketResponseDto> {
return this.assetService.getAssetCountByTimeBucket(authUser, dto);
}
/**
* Get all AssetEntity belong to the user
*/
@ -189,15 +177,6 @@ export class AssetController {
return this.assetService.getAllAssets(authUser, dto);
}
@Post('/time-bucket')
@HttpCode(HttpStatus.OK)
getAssetByTimeBucket(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: GetAssetByTimeBucketDto,
): Promise<AssetResponseDto[]> {
return this.assetService.getAssetByTimeBucket(authUser, dto);
}
/**
* Get all asset of a device that are in the database, ID only.
*/

View file

@ -16,9 +16,7 @@ import { QueryFailedError, Repository } from 'typeorm';
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
@ -83,18 +81,6 @@ const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
const result1 = new AssetCountByTimeBucket();
result1.count = 2;
result1.timeBucket = '2022-06-01T00:00:00.000Z';
const result2 = new AssetCountByTimeBucket();
result1.count = 5;
result1.timeBucket = '2022-07-01T00:00:00.000Z';
return [result1, result2];
};
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
@ -113,12 +99,10 @@ describe('AssetService', () => {
update: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetsByChecksums: jest.fn(),
getExistingAssets: jest.fn(),
getByOriginalPath: jest.fn(),
@ -221,21 +205,6 @@ describe('AssetService', () => {
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
it('get assets count by time bucket', async () => {
const assetCountByTimeBucket = _getAssetCountByTimeBucket();
assetRepositoryMock.getAssetCountByTimeBucket.mockImplementation(() =>
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
);
const result = await sut.getAssetCountByTimeBucket(authStub.user1, {
timeGroup: TimeGroupEnum.Month,
});
expect(result.totalCount).toEqual(assetCountByTimeBucket.reduce((a, b) => a + b.count, 0));
expect(result.buckets.length).toEqual(2);
});
describe('deleteAll', () => {
it('should return failed status when an asset is missing', async () => {
assetRepositoryMock.get.mockResolvedValue(null);

View file

@ -37,8 +37,6 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto';
@ -49,10 +47,6 @@ import {
AssetRejectReason,
AssetUploadAction,
} from './response-dto/asset-check-response.dto';
import {
AssetCountByTimeBucketResponseDto,
mapAssetCountByTimeBucket,
} from './response-dto/asset-count-by-time-group-response.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
@ -195,13 +189,6 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset));
}
public async getAssetByTimeBucket(authUser: AuthUserDto, dto: GetAssetByTimeBucketDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
const assets = await this._assetRepository.getAssetByTimeBucket(userId, dto);
return assets.map((asset) => mapAsset(asset));
}
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
@ -457,16 +444,6 @@ export class AssetService {
};
}
async getAssetCountByTimeBucket(
authUser: AuthUserDto,
dto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucketResponseDto> {
const userId = dto.userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
const result = await this._assetRepository.getAssetCountByTimeBucket(userId, dto);
return mapAssetCountByTimeBucket(result);
}
getExifPermission(authUser: AuthUserDto) {
return !authUser.isPublicUser || authUser.isShowExif;
}

View file

@ -1,28 +0,0 @@
import { toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
export class GetAssetByTimeBucketDto {
@IsNotEmpty()
@ApiProperty({
isArray: true,
type: String,
title: 'Array of date time buckets',
example: ['2015-06-01T00:00:00.000Z', '2016-02-01T00:00:00.000Z', '2016-03-01T00:00:00.000Z'],
})
timeBucket!: string[];
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
/**
* Include assets without thumbnails
*/
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
withoutThumbs?: boolean;
}

View file

@ -1,32 +0,0 @@
import { toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
export enum TimeGroupEnum {
Day = 'day',
Month = 'month',
}
export class GetAssetCountByTimeBucketDto {
@IsNotEmpty()
@ApiProperty({
type: String,
enum: TimeGroupEnum,
enumName: 'TimeGroupEnum',
})
timeGroup!: TimeGroupEnum;
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
/**
* Include assets without thumbnails
*/
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
withoutThumbs?: boolean;
}

View file

@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class AssetCountByTimeBucket {
@ApiProperty({ type: 'string' })
timeBucket!: string;
@ApiProperty({ type: 'integer' })
count!: number;
}
export class AssetCountByTimeBucketResponseDto {
buckets!: AssetCountByTimeBucket[];
@ApiProperty({ type: 'integer' })
totalCount!: number;
}
export function mapAssetCountByTimeBucket(result: AssetCountByTimeBucket[]): AssetCountByTimeBucketResponseDto {
return {
buckets: result,
totalCount: result.map((group) => group.count).reduce((a, b) => a + b, 0),
};
}

View file

@ -1,5 +1,6 @@
import {
AssetIdsDto,
AssetResponseDto,
AssetService,
AssetStatsDto,
AssetStatsResponseDto,
@ -8,6 +9,9 @@ import {
DownloadResponseDto,
MapMarkerResponseDto,
MemoryLaneDto,
TimeBucketAssetDto,
TimeBucketDto,
TimeBucketResponseDto,
} from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
@ -60,4 +64,16 @@ export class AssetController {
getAssetStats(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
return this.service.getStatistics(authUser, dto);
}
@Authenticated({ isShared: true })
@Get('time-buckets')
getTimeBuckets(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
return this.service.getTimeBuckets(authUser, dto);
}
@Authenticated({ isShared: true })
@Get('time-bucket')
getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getByTimeBucket(authUser, dto);
}
}

View file

@ -8,6 +8,9 @@ import {
MapMarkerSearchOptions,
Paginated,
PaginationOptions,
TimeBucketItem,
TimeBucketOptions,
TimeBucketSize,
WithoutProperty,
WithProperty,
} from '@app/domain';
@ -19,6 +22,11 @@ import { AssetEntity, AssetType } from '../entities';
import OptionalBetween from '../utils/optional-between.util';
import { paginate } from '../utils/pagination.util';
const truncateMap: Record<TimeBucketSize, string> = {
[TimeBucketSize.DAY]: 'day',
[TimeBucketSize.MONTH]: 'month',
};
@Injectable()
export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>) {}
@ -357,4 +365,47 @@ export class AssetRepository implements IAssetRepository {
return result;
}
getTimeBuckets(userId: string, options: TimeBucketOptions): Promise<TimeBucketItem[]> {
const truncateValue = truncateMap[options.size];
return this.getBuilder(userId, options)
.select(`COUNT(asset.id)::int`, 'count')
.addSelect(`date_trunc('${truncateValue}', "fileCreatedAt")`, 'timeBucket')
.groupBy(`date_trunc('${truncateValue}', "fileCreatedAt")`)
.orderBy(`date_trunc('${truncateValue}', "fileCreatedAt")`, 'DESC')
.getRawMany();
}
getByTimeBucket(userId: string, timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> {
const truncateValue = truncateMap[options.size];
return this.getBuilder(userId, options)
.andWhere(`date_trunc('${truncateValue}', "fileCreatedAt") = :timeBucket`, { timeBucket })
.orderBy('asset.fileCreatedAt', 'DESC')
.getMany();
}
private getBuilder(userId: string, options: TimeBucketOptions) {
const { isArchived, isFavorite, albumId } = options;
let builder = this.repository
.createQueryBuilder('asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('asset.isVisible = true')
.leftJoinAndSelect('asset.exifInfo', 'exifInfo');
if (albumId) {
builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId });
}
if (isArchived != undefined) {
builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived });
}
if (isFavorite !== undefined) {
builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite });
}
return builder;
}
}