mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: recently added assets page (#28272)
* feat(server): add ordering date option to time buckets * feat(web): add recently added page * feat(server): recently created assets in explore data * feat(web): recently added in explore tab * fix: recently added assets ordering * fix(server): failing bucket test * feat(web): improve recently added preview * chore: update e2e explore/timeline tests * chore: rename and refactor timeline ordering dates * fix(web): invalid timeline option * feat(mobile): recently added page * fix(server): sync tests * fix(mobile): resync assets to get uploadedAt column * chore: rename assetorderby enum * chore(mobile): formatting * minor fixes * stylings --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -382,6 +382,7 @@ export const columns = {
|
||||
'asset.checksum',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.createdAt',
|
||||
'asset.localDateTime',
|
||||
'asset.type',
|
||||
'asset.deletedAt',
|
||||
@@ -404,6 +405,7 @@ export const columns = {
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.localDateTime',
|
||||
'asset.createdAt',
|
||||
'asset.type',
|
||||
'asset.deletedAt',
|
||||
'asset.visibility',
|
||||
|
||||
@@ -75,6 +75,7 @@ const SyncAssetV1Schema = z
|
||||
checksum: z.string().describe('Checksum'),
|
||||
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
|
||||
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
|
||||
createdAt: isoDatetimeToDate.nullable().describe('Uploaded to Immich at'),
|
||||
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
|
||||
duration: z.string().nullable().describe('Duration'),
|
||||
type: AssetTypeSchema,
|
||||
@@ -99,6 +100,7 @@ const SyncAssetV2Schema = z
|
||||
checksum: z.string().describe('Checksum'),
|
||||
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
|
||||
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
|
||||
createdAt: isoDatetimeToDate.nullable().describe('Uploaded to Immich at'),
|
||||
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
|
||||
duration: z.int32().min(0).nullable().describe('Duration'),
|
||||
type: AssetTypeSchema,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { BBoxSchema } from 'src/dtos/bbox.dto';
|
||||
import { AssetOrderSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import { AssetOrderBySchema, AssetOrderSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import { stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -23,6 +23,9 @@ const TimeBucketQueryBaseSchema = z
|
||||
order: AssetOrderSchema.optional().describe(
|
||||
'Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)',
|
||||
),
|
||||
orderBy: AssetOrderBySchema.optional().describe(
|
||||
'Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)',
|
||||
),
|
||||
visibility: AssetVisibilitySchema.optional().describe(
|
||||
'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
|
||||
),
|
||||
@@ -82,6 +85,9 @@ const TimeBucketAssetResponseSchema = z
|
||||
thumbhash: z
|
||||
.array(z.string().nullable())
|
||||
.describe('Array of BlurHash strings for generating asset previews (base64 encoded)'),
|
||||
createdAt: z
|
||||
.array(z.string())
|
||||
.describe('Array of UTC timestamps when each asset was originally uploaded to Immich'),
|
||||
fileCreatedAt: z.array(z.string()).describe('Array of file creation timestamps in UTC'),
|
||||
localOffsetHours: z
|
||||
.array(z.number())
|
||||
|
||||
@@ -74,6 +74,13 @@ export enum AssetOrder {
|
||||
|
||||
export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' });
|
||||
|
||||
export enum AssetOrderBy {
|
||||
TakenAt = 'takenAt',
|
||||
CreatedAt = 'createdAt',
|
||||
}
|
||||
|
||||
export const AssetOrderBySchema = z.enum(AssetOrderBy).describe('Asset sorting property').meta({ id: 'AssetOrderBy' });
|
||||
|
||||
export enum MemoryType {
|
||||
/** pictures taken on this day X years ago */
|
||||
OnThisDay = 'on_this_day',
|
||||
|
||||
@@ -382,6 +382,7 @@ with
|
||||
"asset"."ownerId",
|
||||
"asset"."status",
|
||||
asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
|
||||
asset."createdAt" at time zone 'utc' as "createdAt",
|
||||
encode("asset"."thumbhash", 'base64') as "thumbhash",
|
||||
"asset_exif"."city",
|
||||
"asset_exif"."country",
|
||||
@@ -442,6 +443,7 @@ with
|
||||
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
|
||||
coalesce(array_agg("fileCreatedAt"), '{}') as "fileCreatedAt",
|
||||
coalesce(array_agg("localOffsetHours"), '{}') as "localOffsetHours",
|
||||
coalesce(array_agg("createdAt"), '{}') as "createdAt",
|
||||
coalesce(array_agg("ownerId"), '{}') as "ownerId",
|
||||
coalesce(array_agg("projectionType"), '{}') as "projectionType",
|
||||
coalesce(array_agg("ratio"), '{}') as "ratio",
|
||||
@@ -485,6 +487,22 @@ where
|
||||
limit
|
||||
$5
|
||||
|
||||
-- AssetRepository.getRecentlyCreatedAssetIds
|
||||
select
|
||||
"id" as "data",
|
||||
"createdAt" as "value"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"ownerId" = $1::uuid
|
||||
and "asset"."visibility" = $2
|
||||
and "type" = $3
|
||||
and "deletedAt" is null
|
||||
order by
|
||||
"value" desc
|
||||
limit
|
||||
$4
|
||||
|
||||
-- AssetRepository.detectOfflineExternalAssets
|
||||
update "asset"
|
||||
set
|
||||
|
||||
@@ -65,6 +65,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."createdAt",
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
@@ -98,6 +99,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."createdAt",
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
@@ -133,6 +135,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."createdAt",
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
@@ -407,6 +410,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."createdAt",
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
@@ -737,6 +741,7 @@ select
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."localDateTime",
|
||||
"asset"."createdAt",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
"asset"."visibility",
|
||||
@@ -789,6 +794,7 @@ select
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."localDateTime",
|
||||
"asset"."createdAt",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
"asset"."visibility",
|
||||
|
||||
@@ -17,7 +17,7 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { LockableProperty, Stack } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
@@ -89,6 +89,7 @@ interface AssetBuilderOptions {
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
order?: AssetOrder;
|
||||
orderBy?: AssetOrderBy;
|
||||
}
|
||||
|
||||
export interface TimeBucketItem {
|
||||
@@ -711,7 +712,7 @@ export class AssetRepository {
|
||||
.with('asset', (qb) =>
|
||||
qb
|
||||
.selectFrom('asset')
|
||||
.select(truncatedDate<Date>().as('timeBucket'))
|
||||
.select(truncatedDate<Date>(options.orderBy).as('timeBucket'))
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(!!options.bbox, (qb) => {
|
||||
@@ -783,6 +784,7 @@ export class AssetRepository {
|
||||
'asset.ownerId',
|
||||
'asset.status',
|
||||
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
|
||||
sql`asset."createdAt" at time zone 'utc'`.as('createdAt'),
|
||||
eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
|
||||
'asset_exif.city',
|
||||
'asset_exif.country',
|
||||
@@ -815,7 +817,7 @@ export class AssetRepository {
|
||||
|
||||
return withBoundingBox(withBoundingCircle, bbox);
|
||||
})
|
||||
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.where(truncatedDate(options.orderBy), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.$if(!!options.albumId, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.exists(
|
||||
@@ -861,7 +863,12 @@ export class AssetRepository {
|
||||
)
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||
.orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order)
|
||||
.orderBy(
|
||||
options.orderBy == AssetOrderBy.CreatedAt
|
||||
? sql`"createdAt"`
|
||||
: sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`,
|
||||
order,
|
||||
)
|
||||
.orderBy('asset.fileCreatedAt', order),
|
||||
)
|
||||
.with('agg', (qb) =>
|
||||
@@ -880,6 +887,7 @@ export class AssetRepository {
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['fileCreatedAt']), sql.lit('{}')).as('fileCreatedAt'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['localOffsetHours']), sql.lit('{}')).as('localOffsetHours'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['createdAt']), sql.lit('{}')).as('createdAt'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
|
||||
@@ -929,6 +937,22 @@ export class AssetRepository {
|
||||
return { fieldName: 'exifInfo.city', items };
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, 12] })
|
||||
async getRecentlyCreatedAssetIds(ownerId: string, maxAssets: number) {
|
||||
const items = await this.db
|
||||
.selectFrom('asset')
|
||||
.select(['id as data', 'createdAt as value'])
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('asset.visibility', '=', AssetVisibility.Timeline)
|
||||
.where('type', '=', AssetType.Image)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('value', 'desc')
|
||||
.limit(maxAssets)
|
||||
.execute();
|
||||
|
||||
return { fieldName: 'createdAt', items };
|
||||
}
|
||||
|
||||
async upsertFile(
|
||||
file: Pick<
|
||||
Insertable<AssetFileTable>,
|
||||
|
||||
@@ -110,6 +110,7 @@ export class JobService extends BaseService {
|
||||
checksum: hexOrBufferToBase64(asset.checksum),
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
createdAt: asset.createdAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
duration: asset.duration,
|
||||
type: asset.type,
|
||||
@@ -166,6 +167,7 @@ export class JobService extends BaseService {
|
||||
checksum: hexOrBufferToBase64(asset.checksum),
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
createdAt: asset.createdAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
duration: asset.duration,
|
||||
type: asset.type,
|
||||
|
||||
@@ -65,7 +65,7 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
describe('getExploreData', () => {
|
||||
it('should get assets by city and tag', async () => {
|
||||
it('should get recent assets and assets by city and tag', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||
@@ -74,9 +74,17 @@ describe(SearchService.name, () => {
|
||||
fieldName: 'exifInfo.city',
|
||||
items: [{ value: 'city', data: asset.id }],
|
||||
});
|
||||
mocks.asset.getRecentlyCreatedAssetIds.mockResolvedValue({
|
||||
fieldName: 'createdAt',
|
||||
items: [{ value: asset.createdAt, data: asset.id }],
|
||||
});
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
|
||||
const expectedResponse = [
|
||||
{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] },
|
||||
{
|
||||
fieldName: 'createdAt',
|
||||
items: [{ value: asset.createdAt.toISOString(), data: mapAsset(getForAsset(asset)) }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await sut.getExploreData(auth);
|
||||
|
||||
@@ -40,10 +40,26 @@ export class SearchService extends BaseService {
|
||||
|
||||
async getExploreData(auth: AuthDto) {
|
||||
const options = { maxFields: 12, minAssetsPerField: 5 };
|
||||
|
||||
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
||||
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));
|
||||
const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
|
||||
return [{ fieldName: cities.fieldName, items }];
|
||||
const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
||||
cities.items.map(({ data }) => data),
|
||||
);
|
||||
const cityItems = cityAssets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
|
||||
|
||||
const recents = await this.assetRepository.getRecentlyCreatedAssetIds(auth.user.id, options.maxFields);
|
||||
const recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
|
||||
recents.items.map((item) => item.data),
|
||||
);
|
||||
const recentItems = recentAssets.map((asset) => ({
|
||||
value: asset.createdAt.toISOString(),
|
||||
data: mapAsset(asset, { auth }),
|
||||
}));
|
||||
|
||||
return [
|
||||
{ fieldName: cities.fieldName, items: cityItems },
|
||||
{ fieldName: recents.fieldName, items: recentItems },
|
||||
];
|
||||
}
|
||||
|
||||
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Notice, PostgresError } from 'postgres';
|
||||
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
|
||||
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
@@ -298,8 +298,8 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
|
||||
).as('tags');
|
||||
}
|
||||
|
||||
export function truncatedDate<O>() {
|
||||
return sql<O>`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
|
||||
export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
|
||||
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
|
||||
}
|
||||
|
||||
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
|
||||
|
||||
Reference in New Issue
Block a user