diff --git a/i18n/en.json b/i18n/en.json index 5903d7850e..552b8b1381 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -962,6 +962,7 @@ "cannot_navigate_previous_asset": "Cannot navigate to previous asset", "cant_apply_changes": "Can't apply changes", "cant_change_activity": "Can't {enabled, select, true {disable} other {enable}} activity", + "cant_change_timeline_visibility": "Unable to change timeline visibility", "cant_change_asset_favorite": "Can't change favorite for asset", "cant_change_metadata_assets_count": "Can't change metadata of {count, plural, one {# asset} other {# assets}}", "cant_get_faces": "Can't get faces", @@ -1177,6 +1178,8 @@ "hide_person": "Hide person", "hide_text_recognition": "Hide text recognition", "hide_unnamed_people": "Hide unnamed people", + "hide_from_timeline": "Hide from timeline", + "hide_from_timeline_description": "Photos in this album will not appear in your main timeline", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_success": "Added {added} assets to album {album}.", @@ -2081,6 +2084,7 @@ "time_based_memories": "Time-based memories", "time_based_memories_duration": "Number of seconds to display each image.", "timeline": "Timeline", + "timeline_visibility_changed": "Album is now {hidden, select, true {hidden from} other {visible in}} timeline", "timezone": "Timezone", "to_archive": "Archive", "to_change_password": "Change password", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c052e41a49..67e7533bec 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14671,6 +14671,9 @@ "hasSharedLink": { "type": "boolean" }, + "hideFromTimeline": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -14715,6 +14718,7 @@ "createdAt", "description", "hasSharedLink", + "hideFromTimeline", "id", "isActivityEnabled", "owner", @@ -16143,6 +16147,9 @@ }, "description": { "type": "string" + }, + "hideFromTimeline": { + "type": "boolean" } }, "required": [ @@ -22528,6 +22535,9 @@ "description": { "type": "string" }, + "hideFromTimeline": { + "type": "boolean" + }, "isActivityEnabled": { "type": "boolean" }, diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 2f3f22099a..1f409dec4d 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -53,6 +53,9 @@ export class CreateAlbumDto { @ValidateUUID({ optional: true, each: true }) assetIds?: string[]; + + @ValidateBoolean({ optional: true }) + hideFromTimeline?: boolean; } export class AlbumsAddAssetsDto { @@ -86,6 +89,9 @@ export class UpdateAlbumDto { @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true }) order?: AssetOrder; + + @ValidateBoolean({ optional: true }) + hideFromTimeline?: boolean; } export class GetAlbumsDto { @@ -157,6 +163,7 @@ export class AlbumResponseDto { isActivityEnabled!: boolean; @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true }) order?: AssetOrder; + hideFromTimeline!: boolean; // Optional per-user contribution counts for shared albums @Type(() => ContributorCountResponseDto) @@ -178,6 +185,7 @@ export type MapAlbumDto = { owner: User; isActivityEnabled: boolean; order: AssetOrder; + hideFromTimeline: boolean; }; export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { @@ -225,6 +233,7 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt assetCount: entity.assets?.length || 0, isActivityEnabled: entity.isActivityEnabled, order: entity.order, + hideFromTimeline: entity.hideFromTimeline, }; }; diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 100ab908c0..22cba69c5a 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; +import { isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { columns, Exif } from 'src/database'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; @@ -294,9 +295,10 @@ export class AlbumRepository { } update(id: string, album: Updateable) { + const value = omitBy(album, isUndefined); return this.db .updateTable('album') - .set(album) + .set(value) .where('id', '=', id) .returningAll('album') .returning(withOwner) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7db3a76f12..c9a0dedf35 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -328,6 +328,18 @@ export class AssetRepository { .where('asset_file.type', '=', AssetFileType.Preview), ), ) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('album_asset') + .innerJoin('album', 'album.id', 'album_asset.albumId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where('album.hideFromTimeline', '=', true) + .where('album.deletedAt', 'is', null), + ), + ), + ) .where('asset.deletedAt', 'is', null) .orderBy(sql`(asset."localDateTime" at time zone 'UTC')::date`, 'desc') .limit(20) @@ -624,7 +636,21 @@ export class AssetRepository { .$if(options.isDuplicate !== undefined, (qb) => qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null), ) - .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), + .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) + .$if(!options.albumId, (qb) => + qb.where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('album_asset') + .innerJoin('album', 'album.id', 'album_asset.albumId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where('album.hideFromTimeline', '=', true) + .where('album.deletedAt', 'is', null), + ), + ), + ), + ), ) .selectFrom('asset') .select(sql`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket')) @@ -725,6 +751,20 @@ export class AssetRepository { ) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) + .$if(!options.albumId, (qb) => + qb.where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('album_asset') + .innerJoin('album', 'album.id', 'album_asset.albumId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where('album.hideFromTimeline', '=', true) + .where('album.deletedAt', 'is', null), + ), + ), + ), + ) .orderBy('asset.fileCreatedAt', options.order ?? 'desc'), ) .with('agg', (qb) => diff --git a/server/src/schema/migrations/1765454965807-AddHideFromTimelineToAlbum.ts b/server/src/schema/migrations/1765454965807-AddHideFromTimelineToAlbum.ts new file mode 100644 index 0000000000..ef1dc036a2 --- /dev/null +++ b/server/src/schema/migrations/1765454965807-AddHideFromTimelineToAlbum.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "album" ADD "hideFromTimeline" boolean NOT NULL DEFAULT false;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "album" DROP COLUMN "hideFromTimeline";`.execute(db); +} diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index 5628db3d03..2f5479a7d7 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -60,6 +60,9 @@ export class AlbumTable { @Column({ default: AssetOrder.Desc }) order!: Generated; + @Column({ type: 'boolean', default: false }) + hideFromTimeline!: Generated; + @UpdateIdColumn({ index: true }) updateId!: Generated; } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 18747dbc3a..f0cc27ee56 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -123,6 +123,7 @@ export class AlbumService extends BaseService { description: dto.description, albumThumbnailAssetId: assetIds[0] || null, order: getPreferences(userMetadata).albums.defaultAssetOrder, + hideFromTimeline: dto.hideFromTimeline, }, assetIds, albumUsers, @@ -153,6 +154,7 @@ export class AlbumService extends BaseService { albumThumbnailAssetId: dto.albumThumbnailAssetId, isActivityEnabled: dto.isActivityEnabled, order: dto.order, + hideFromTimeline: dto.hideFromTimeline, }); return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets }); diff --git a/server/test/medium/specs/services/timeline.service.spec.ts b/server/test/medium/specs/services/timeline.service.spec.ts index eaca4dcc14..6feb25195c 100644 --- a/server/test/medium/specs/services/timeline.service.spec.ts +++ b/server/test/medium/specs/services/timeline.service.spec.ts @@ -44,6 +44,34 @@ describe(TimelineService.name, () => { ]); }); + it('should exclude assets from albums with hideFromTimeline=true', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + // Create two assets + const { asset: asset1 } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: new Date('1970-01-01'), + }); + const { asset: asset2 } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: new Date('1970-01-01'), + }); + + // Create an album with hideFromTimeline=true and add asset1 + const { album } = await ctx.newAlbum({ + ownerId: user.id, + hideFromTimeline: true, + }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset1.id }); + + // Get time buckets - should only include asset2, not asset1 + const response = await sut.getTimeBuckets(auth, {}); + + expect(response).toEqual([{ count: 1, timeBucket: '1970-01-01' }]); + }); + it('should return error if time bucket is requested with partners asset and archived', async () => { const { sut } = setup(); const auth = factory.auth(); diff --git a/web/src/lib/modals/AlbumOptionsModal.svelte b/web/src/lib/modals/AlbumOptionsModal.svelte index 1234fd8b76..48f6b0762e 100644 --- a/web/src/lib/modals/AlbumOptionsModal.svelte +++ b/web/src/lib/modals/AlbumOptionsModal.svelte @@ -73,6 +73,21 @@ } }; + const handleToggleHideFromTimeline = async () => { + try { + album = await updateAlbumInfo({ + id: album.id, + updateAlbumDto: { + hideFromTimeline: !album.hideFromTimeline, + }, + }); + + toastManager.success($t('timeline_visibility_changed', { values: { hidden: album.hideFromTimeline } })); + } catch (error) { + handleError(error, $t('errors.cant_change_timeline_visibility')); + } + }; + const handleRemoveUser = async (user: UserResponseDto): Promise => { const confirmed = await modalManager.showDialog({ title: $t('album_remove_user'), @@ -127,6 +142,12 @@ checked={album.isActivityEnabled} onToggle={handleToggleActivity} /> +