From 166f36e5bfa1e51059adf9153795dcf7bbe370e2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 Apr 2026 03:33:32 +0000 Subject: [PATCH] feat(server,web): favorite albums per user --- open-api/immich-openapi-specs.json | 26 ++++++++++++-- open-api/typescript-sdk/src/fetch-client.ts | 12 +++++-- server/src/database.ts | 8 ++++- server/src/dtos/album.dto.ts | 15 ++++++-- server/src/dtos/sync.dto.ts | 1 + server/src/repositories/album.repository.ts | 22 ++++++++++++ .../1776913840000-AddIsFavoriteToAlbumUser.ts | 9 +++++ server/src/schema/tables/album-user.table.ts | 3 ++ server/src/services/album.service.ts | 35 ++++++++++++++----- server/test/factories/album-user.factory.ts | 1 + .../components/album-page/AlbumCard.svelte | 11 +++--- .../components/album-page/AlbumsList.svelte | 9 +++++ web/src/lib/services/album.service.ts | 18 ++++++++++ web/src/lib/stores/preferences.store.ts | 1 + .../(user)/albums/AlbumsControls.svelte | 1 + .../[[assetId=id]]/+page.svelte | 17 +++++++++ web/src/test-data/factories/album-factory.ts | 1 + 17 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 server/src/schema/migrations/1776913840000-AddIsFavoriteToAlbumUser.ts diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ac1de35252..a44a139b80 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1648,6 +1648,15 @@ "type": "string" } }, + { + "name": "favorite", + "required": false, + "in": "query", + "description": "Filter to only albums favorited by the authenticated user", + "schema": { + "type": "boolean" + } + }, { "name": "shared", "required": false, @@ -15324,6 +15333,10 @@ "description": "Activity feed enabled", "type": "boolean" }, + "isFavorite": { + "description": "Whether the authenticated user has favorited this album", + "type": "boolean" + }, "lastModifiedAssetTimestamp": { "description": "Last modified asset timestamp", "format": "date-time", @@ -15357,6 +15370,7 @@ "hasSharedLink", "id", "isActivityEnabled", + "isFavorite", "shared", "updatedAt" ], @@ -22452,6 +22466,10 @@ "description": "Album ID", "type": "string" }, + "isFavorite": { + "description": "Favorite flag", + "type": "boolean" + }, "role": { "$ref": "#/components/schemas/AlbumUserRole" }, @@ -22462,6 +22480,7 @@ }, "required": [ "albumId", + "isFavorite", "role", "userId" ], @@ -25202,13 +25221,14 @@ }, "UpdateAlbumUserDto": { "properties": { + "isFavorite": { + "description": "Mark album as favorite for the user (only the user themselves can update)", + "type": "boolean" + }, "role": { "$ref": "#/components/schemas/AlbumUserRole" } }, - "required": [ - "role" - ], "type": "object" }, "UpdateAssetDto": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 40c4bec235..d7272b4301 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -474,6 +474,8 @@ export type AlbumResponseDto = { id: string; /** Activity feed enabled */ isActivityEnabled: boolean; + /** Whether the authenticated user has favorited this album */ + isFavorite: boolean; /** Last modified asset timestamp */ lastModifiedAssetTimestamp?: string; order?: AssetOrder; @@ -556,7 +558,9 @@ export type MapMarkerResponseDto = { state: string | null; }; export type UpdateAlbumUserDto = { - role: AlbumUserRole; + /** Mark album as favorite for the user (only the user themselves can update) */ + isFavorite?: boolean; + role?: AlbumUserRole; }; export type AlbumUserAddDto = { /** Album user role */ @@ -2856,6 +2860,8 @@ export type SyncAlbumUserDeleteV1 = { export type SyncAlbumUserV1 = { /** Album ID */ albumId: string; + /** Favorite flag */ + isFavorite: boolean; role: AlbumUserRole; /** User ID */ userId: string; @@ -3619,8 +3625,9 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility } /** * List all albums */ -export function getAllAlbums({ assetId, shared }: { +export function getAllAlbums({ assetId, favorite, shared }: { assetId?: string; + favorite?: boolean; shared?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3628,6 +3635,7 @@ export function getAllAlbums({ assetId, shared }: { data: AlbumResponseDto[]; }>(`/albums${QS.query(QS.explode({ assetId, + favorite, shared }))}`, { ...opts diff --git a/server/src/database.ts b/server/src/database.ts index c001388e79..9af995eb0b 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -35,6 +35,7 @@ export type AuthUser = { export type AlbumUser = { user: ShallowDehydrateObject; role: AlbumUserRole; + isFavorite: boolean; }; export type AssetFile = { @@ -395,7 +396,12 @@ export const columns = { 'asset.height', 'asset.isEdited', ], - syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], + syncAlbumUser: [ + 'album_user.albumId as albumId', + 'album_user.userId as userId', + 'album_user.role', + 'album_user.isFavorite', + ], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 33870cd6fc..9b3501d851 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -69,6 +69,7 @@ const GetAlbumsSchema = z .optional() .describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'), assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'), + favorite: stringToBool.optional().describe('Filter to only albums favorited by the authenticated user'), }) .meta({ id: 'GetAlbumsDto' }); @@ -82,7 +83,11 @@ const AlbumStatisticsResponseSchema = z const UpdateAlbumUserSchema = z .object({ - role: AlbumUserRoleSchema, + role: AlbumUserRoleSchema.optional(), + isFavorite: z + .boolean() + .optional() + .describe('Mark album as favorite for the user (only the user themselves can update)'), }) .meta({ id: 'UpdateAlbumUserDto' }); @@ -118,6 +123,7 @@ export const AlbumResponseSchema = z 'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.', ), hasSharedLink: z.boolean().describe('Has shared link'), + isFavorite: z.boolean().describe('Whether the authenticated user has favorited this album'), assetCount: z.int().min(0).describe('Number of assets'), // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. lastModifiedAssetTimestamp: z @@ -161,8 +167,9 @@ export type MapAlbumDto = { order: AssetOrder; }; -export const mapAlbum = (entity: MaybeDehydrated): AlbumResponseDto => { +export const mapAlbum = (entity: MaybeDehydrated, authUserId?: string): AlbumResponseDto => { const albumUsers: AlbumUserResponseDto[] = []; + let isFavorite = false; if (entity.albumUsers) { for (const albumUser of entity.albumUsers) { @@ -171,6 +178,9 @@ export const mapAlbum = (entity: MaybeDehydrated): AlbumResponseDto user, role: albumUser.role, }); + if (authUserId && user.id === authUserId) { + isFavorite = albumUser.isFavorite ?? false; + } } } @@ -196,6 +206,7 @@ export const mapAlbum = (entity: MaybeDehydrated): AlbumResponseDto albumUsers, shared: hasSharedUser || hasSharedLink, hasSharedLink, + isFavorite, startDate: asDateString(startDate), endDate: asDateString(endDate), assetCount: entity.assets?.length || 0, diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 0df617813d..e866375bb1 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -195,6 +195,7 @@ const SyncAlbumUserV1Schema = z albumId: z.string().describe('Album ID'), userId: z.string().describe('User ID'), role: AlbumUserRoleSchema, + isFavorite: z.boolean().describe('Favorite flag'), }) .meta({ id: 'SyncAlbumUserV1' }); diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index a910673c62..a73c3317af 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -39,6 +39,7 @@ const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user')) .orderBy('album_user.role') .$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc')) @@ -244,6 +245,27 @@ export class AlbumRepository { .execute(); } + /** + * Get albums the user has favorited (owned or shared). + */ + @GenerateSql({ params: [DummyValue.UUID] }) + getFavorites(userId: string) { + return this.db + .selectFrom('album') + .selectAll('album') + .innerJoin('album_user', (join) => + join + .onRef('album_user.albumId', '=', 'album.id') + .on('album_user.userId', '=', userId) + .on('album_user.isFavorite', '=', sql.lit(true)), + ) + .where('album.deletedAt', 'is', null) + .select(withAlbumUsers(userId)) + .select(withSharedLink) + .orderBy('album.createdAt', 'desc') + .execute(); + } + /** * Get albums of owner that are _not_ shared */ diff --git a/server/src/schema/migrations/1776913840000-AddIsFavoriteToAlbumUser.ts b/server/src/schema/migrations/1776913840000-AddIsFavoriteToAlbumUser.ts new file mode 100644 index 0000000000..1661324a7f --- /dev/null +++ b/server/src/schema/migrations/1776913840000-AddIsFavoriteToAlbumUser.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "album_user" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "album_user" DROP COLUMN "isFavorite";`.execute(db); +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 677d6ca2f2..a0abe2c501 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -58,6 +58,9 @@ export class AlbumUserTable { @Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor }) role!: Generated; + @Column({ type: 'boolean', default: false }) + isFavorite!: Generated; + @CreateIdColumn({ index: true }) createId!: Generated; diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index ef8a31dcb5..ea51fc2531 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -38,12 +38,17 @@ export class AlbumService extends BaseService { }; } - async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise { + async getAll( + { user: { id: ownerId } }: AuthDto, + { assetId, shared, favorite }: GetAlbumsDto, + ): Promise { await this.albumRepository.updateThumbnails(); let albums: MapAlbumDto[]; if (assetId) { albums = await this.albumRepository.getByAssetId(ownerId, assetId); + } else if (favorite === true) { + albums = await this.albumRepository.getFavorites(ownerId); } else if (shared === true) { albums = await this.albumRepository.getShared(ownerId); } else if (shared === false) { @@ -61,7 +66,7 @@ export class AlbumService extends BaseService { } return albums.map((album) => ({ - ...mapAlbum(album), + ...mapAlbum(album, ownerId), sharedLinks: undefined, startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined), endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined), @@ -82,7 +87,7 @@ export class AlbumService extends BaseService { const isShared = hasSharedUsers || hasSharedLink; return { - ...mapAlbum(album), + ...mapAlbum(album, auth.user.id), startDate: asDateString(albumMetadataForIds?.startDate ?? undefined), endDate: asDateString(albumMetadataForIds?.endDate ?? undefined), assetCount: albumMetadataForIds?.assetCount ?? 0, @@ -141,7 +146,7 @@ export class AlbumService extends BaseService { await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name }); } - return mapAlbum(album); + return mapAlbum(album, auth.user.id); } async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise { @@ -168,7 +173,7 @@ export class AlbumService extends BaseService { auth.user.id, ); - return mapAlbum({ ...updatedAlbum, assets: album.assets }); + return mapAlbum({ ...updatedAlbum, assets: album.assets }, auth.user.id); } async delete(auth: AuthDto, id: string): Promise { @@ -310,7 +315,7 @@ export class AlbumService extends BaseService { await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); } - return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum); + return this.findOrFail(id, auth.user.id, { withAssets: true }).then((album) => mapAlbum(album, auth.user.id)); } async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise { @@ -341,8 +346,22 @@ export class AlbumService extends BaseService { } async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise { - await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); - await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); + if (dto.role === undefined && dto.isFavorite === undefined) { + throw new BadRequestException('No updates provided'); + } + + if (dto.role !== undefined) { + await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); + } + + if (dto.isFavorite !== undefined) { + if (userId !== auth.user.id) { + throw new BadRequestException('Cannot favorite an album on behalf of another user'); + } + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] }); + } + + await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role, isFavorite: dto.isFavorite }); } private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) { diff --git a/server/test/factories/album-user.factory.ts b/server/test/factories/album-user.factory.ts index 6e2f8cb832..39c62a35d0 100644 --- a/server/test/factories/album-user.factory.ts +++ b/server/test/factories/album-user.factory.ts @@ -24,6 +24,7 @@ export class AlbumUserFactory { albumId: newUuid(), userId: newUuid(), role: AlbumUserRole.Editor, + isFavorite: false, createId: newUuidV7(), createdAt: newDate(), updateId: newUuidV7(), diff --git a/web/src/lib/components/album-page/AlbumCard.svelte b/web/src/lib/components/album-page/AlbumCard.svelte index 621de983eb..61b1777fbb 100644 --- a/web/src/lib/components/album-page/AlbumCard.svelte +++ b/web/src/lib/components/album-page/AlbumCard.svelte @@ -4,8 +4,8 @@ import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu'; import { getShortDateRange } from '$lib/utils/date-time'; import { type AlbumResponseDto } from '@immich/sdk'; - import { IconButton } from '@immich/ui'; - import { mdiDotsVertical } from '@mdi/js'; + import { Icon, IconButton } from '@immich/ui'; + import { mdiDotsVertical, mdiHeart } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { @@ -60,11 +60,14 @@

- {album.albumName} + {#if album.isFavorite} + + {/if} + {album.albumName}

{#if showDateRange && album.startDate && album.endDate} diff --git a/web/src/lib/components/album-page/AlbumsList.svelte b/web/src/lib/components/album-page/AlbumsList.svelte index 846e2b97a7..16e81eeb18 100644 --- a/web/src/lib/components/album-page/AlbumsList.svelte +++ b/web/src/lib/components/album-page/AlbumsList.svelte @@ -131,6 +131,15 @@ case AlbumFilter.Shared: { return sharedAlbums; } + case AlbumFilter.Favorites: { + const nonOwnedFavorites = sharedAlbums.filter( + (album) => + album.isFavorite && + album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role !== AlbumUserRole.Owner, + ); + const ownedFavorites = ownedAlbums.filter((album) => album.isFavorite); + return nonOwnedFavorites.length > 0 ? ownedFavorites.concat(nonOwnedFavorites) : ownedFavorites; + } default: { const nonOwnedAlbums = sharedAlbums.filter( (album) => diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index 8cd46bd13d..fb7445ab8c 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -173,6 +173,24 @@ export const handleUpdateUserAlbumRole = async ({ } }; +export const toggleAlbumFavorite = async (album: AlbumResponseDto): Promise => { + const $t = await getFormatter(); + const next = !album.isFavorite; + + try { + await updateAlbumUser({ + id: album.id, + userId: authManager.user.id, + updateAlbumUserDto: { isFavorite: next }, + }); + const updated = { ...album, isFavorite: next }; + eventManager.emit('AlbumUpdate', updated); + return updated; + } catch (error) { + handleError(error, $t('errors.unable_to_update_album_info')); + } +}; + export const handleAddUsersToAlbum = async (album: AlbumResponseDto, users: UserResponseDto[]) => { const $t = await getFormatter(); diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 52925d6055..f126c10c91 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -94,6 +94,7 @@ export enum AlbumFilter { All = 'All', Owned = 'Owned', Shared = 'Shared', + Favorites = 'Favorites', } export enum AlbumGroupBy { diff --git a/web/src/routes/(user)/albums/AlbumsControls.svelte b/web/src/routes/(user)/albums/AlbumsControls.svelte index 13677bb1f9..4c46bee25b 100644 --- a/web/src/routes/(user)/albums/AlbumsControls.svelte +++ b/web/src/routes/(user)/albums/AlbumsControls.svelte @@ -89,6 +89,7 @@ [AlbumFilter.All]: $t('all'), [AlbumFilter.Owned]: $t('owned'), [AlbumFilter.Shared]: $t('shared'), + [AlbumFilter.Favorites]: $t('favorites'), }); let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9a72417b74..2922c50c3b 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -44,6 +44,7 @@ getAlbumAssetsActions, handleDeleteAlbum, handleDownloadAlbum, + toggleAlbumFavorite, } from '$lib/services/album.service'; import { getGlobalActions } from '$lib/services/app.service'; import { getAssetBulkActions } from '$lib/services/asset.service'; @@ -68,6 +69,8 @@ mdiDeleteOutline, mdiDotsVertical, mdiDownload, + mdiHeart, + mdiHeartOutline, mdiImageOutline, mdiImagePlusOutline, mdiLink, @@ -500,6 +503,20 @@ {#snippet trailing()} + { + const updated = await toggleAlbumFavorite(album); + if (updated) { + album = updated; + } + }} + /> + {#if isEditor} ({ albumUsers: [], hasSharedLink: false, isActivityEnabled: true, + isFavorite: false, order: AssetOrder.Desc, });