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:
Ben Beckford
2026-05-11 14:35:10 -07:00
committed by GitHub
parent 38438c8d9a
commit e142e3aca7
66 changed files with 14174 additions and 56 deletions
+2
View File
@@ -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',
+2
View File
@@ -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,
+7 -1
View File
@@ -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())
+7
View File
@@ -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',
+18
View File
@@ -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
+6
View File
@@ -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",
+28 -4
View File
@@ -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>,
+2
View File
@@ -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,
+9 -1
View File
@@ -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);
+19 -3
View File
@@ -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> {
+3 -3
View File
@@ -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) {