feat(server,web): favorite albums per user

This commit is contained in:
Alex
2026-04-23 03:33:32 +00:00
parent f0835d06f8
commit 166f36e5bf
17 changed files with 170 additions and 20 deletions
+23 -3
View File
@@ -1648,6 +1648,15 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "favorite",
"required": false,
"in": "query",
"description": "Filter to only albums favorited by the authenticated user",
"schema": {
"type": "boolean"
}
},
{ {
"name": "shared", "name": "shared",
"required": false, "required": false,
@@ -15324,6 +15333,10 @@
"description": "Activity feed enabled", "description": "Activity feed enabled",
"type": "boolean" "type": "boolean"
}, },
"isFavorite": {
"description": "Whether the authenticated user has favorited this album",
"type": "boolean"
},
"lastModifiedAssetTimestamp": { "lastModifiedAssetTimestamp": {
"description": "Last modified asset timestamp", "description": "Last modified asset timestamp",
"format": "date-time", "format": "date-time",
@@ -15357,6 +15370,7 @@
"hasSharedLink", "hasSharedLink",
"id", "id",
"isActivityEnabled", "isActivityEnabled",
"isFavorite",
"shared", "shared",
"updatedAt" "updatedAt"
], ],
@@ -22452,6 +22466,10 @@
"description": "Album ID", "description": "Album ID",
"type": "string" "type": "string"
}, },
"isFavorite": {
"description": "Favorite flag",
"type": "boolean"
},
"role": { "role": {
"$ref": "#/components/schemas/AlbumUserRole" "$ref": "#/components/schemas/AlbumUserRole"
}, },
@@ -22462,6 +22480,7 @@
}, },
"required": [ "required": [
"albumId", "albumId",
"isFavorite",
"role", "role",
"userId" "userId"
], ],
@@ -25202,13 +25221,14 @@
}, },
"UpdateAlbumUserDto": { "UpdateAlbumUserDto": {
"properties": { "properties": {
"isFavorite": {
"description": "Mark album as favorite for the user (only the user themselves can update)",
"type": "boolean"
},
"role": { "role": {
"$ref": "#/components/schemas/AlbumUserRole" "$ref": "#/components/schemas/AlbumUserRole"
} }
}, },
"required": [
"role"
],
"type": "object" "type": "object"
}, },
"UpdateAssetDto": { "UpdateAssetDto": {
+10 -2
View File
@@ -474,6 +474,8 @@ export type AlbumResponseDto = {
id: string; id: string;
/** Activity feed enabled */ /** Activity feed enabled */
isActivityEnabled: boolean; isActivityEnabled: boolean;
/** Whether the authenticated user has favorited this album */
isFavorite: boolean;
/** Last modified asset timestamp */ /** Last modified asset timestamp */
lastModifiedAssetTimestamp?: string; lastModifiedAssetTimestamp?: string;
order?: AssetOrder; order?: AssetOrder;
@@ -556,7 +558,9 @@ export type MapMarkerResponseDto = {
state: string | null; state: string | null;
}; };
export type UpdateAlbumUserDto = { 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 = { export type AlbumUserAddDto = {
/** Album user role */ /** Album user role */
@@ -2856,6 +2860,8 @@ export type SyncAlbumUserDeleteV1 = {
export type SyncAlbumUserV1 = { export type SyncAlbumUserV1 = {
/** Album ID */ /** Album ID */
albumId: string; albumId: string;
/** Favorite flag */
isFavorite: boolean;
role: AlbumUserRole; role: AlbumUserRole;
/** User ID */ /** User ID */
userId: string; userId: string;
@@ -3619,8 +3625,9 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/** /**
* List all albums * List all albums
*/ */
export function getAllAlbums({ assetId, shared }: { export function getAllAlbums({ assetId, favorite, shared }: {
assetId?: string; assetId?: string;
favorite?: boolean;
shared?: boolean; shared?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
@@ -3628,6 +3635,7 @@ export function getAllAlbums({ assetId, shared }: {
data: AlbumResponseDto[]; data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({ }>(`/albums${QS.query(QS.explode({
assetId, assetId,
favorite,
shared shared
}))}`, { }))}`, {
...opts ...opts
+7 -1
View File
@@ -35,6 +35,7 @@ export type AuthUser = {
export type AlbumUser = { export type AlbumUser = {
user: ShallowDehydrateObject<User>; user: ShallowDehydrateObject<User>;
role: AlbumUserRole; role: AlbumUserRole;
isFavorite: boolean;
}; };
export type AssetFile = { export type AssetFile = {
@@ -395,7 +396,12 @@ export const columns = {
'asset.height', 'asset.height',
'asset.isEdited', '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'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
+13 -2
View File
@@ -69,6 +69,7 @@ const GetAlbumsSchema = z
.optional() .optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'), .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)'), 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' }); .meta({ id: 'GetAlbumsDto' });
@@ -82,7 +83,11 @@ const AlbumStatisticsResponseSchema = z
const UpdateAlbumUserSchema = z const UpdateAlbumUserSchema = z
.object({ .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' }); .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.', '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'), 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'), assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z lastModifiedAssetTimestamp: z
@@ -161,8 +167,9 @@ export type MapAlbumDto = {
order: AssetOrder; order: AssetOrder;
}; };
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => { export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, authUserId?: string): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = [];
let isFavorite = false;
if (entity.albumUsers) { if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) { for (const albumUser of entity.albumUsers) {
@@ -171,6 +178,9 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
user, user,
role: albumUser.role, role: albumUser.role,
}); });
if (authUserId && user.id === authUserId) {
isFavorite = albumUser.isFavorite ?? false;
}
} }
} }
@@ -196,6 +206,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
albumUsers, albumUsers,
shared: hasSharedUser || hasSharedLink, shared: hasSharedUser || hasSharedLink,
hasSharedLink, hasSharedLink,
isFavorite,
startDate: asDateString(startDate), startDate: asDateString(startDate),
endDate: asDateString(endDate), endDate: asDateString(endDate),
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
+1
View File
@@ -195,6 +195,7 @@ const SyncAlbumUserV1Schema = z
albumId: z.string().describe('Album ID'), albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'), userId: z.string().describe('User ID'),
role: AlbumUserRoleSchema, role: AlbumUserRoleSchema,
isFavorite: z.boolean().describe('Favorite flag'),
}) })
.meta({ id: 'SyncAlbumUserV1' }); .meta({ id: 'SyncAlbumUserV1' });
@@ -39,6 +39,7 @@ const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'albu
.innerJoin('user', 'user.id', 'album_user.userId') .innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id') .whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role') .select('album_user.role')
.select('album_user.isFavorite')
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user')) .select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role') .orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc')) .$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
@@ -244,6 +245,27 @@ export class AlbumRepository {
.execute(); .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 * Get albums of owner that are _not_ shared
*/ */
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" DROP COLUMN "isFavorite";`.execute(db);
}
@@ -58,6 +58,9 @@ export class AlbumUserTable {
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor }) @Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>; role!: Generated<AlbumUserRole>;
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@CreateIdColumn({ index: true }) @CreateIdColumn({ index: true })
createId!: Generated<string>; createId!: Generated<string>;
+27 -8
View File
@@ -38,12 +38,17 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, shared, favorite }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[]; let albums: MapAlbumDto[];
if (assetId) { if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId); albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (favorite === true) {
albums = await this.albumRepository.getFavorites(ownerId);
} else if (shared === true) { } else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId); albums = await this.albumRepository.getShared(ownerId);
} else if (shared === false) { } else if (shared === false) {
@@ -61,7 +66,7 @@ export class AlbumService extends BaseService {
} }
return albums.map((album) => ({ return albums.map((album) => ({
...mapAlbum(album), ...mapAlbum(album, ownerId),
sharedLinks: undefined, sharedLinks: undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined), startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined), endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
@@ -82,7 +87,7 @@ export class AlbumService extends BaseService {
const isShared = hasSharedUsers || hasSharedLink; const isShared = hasSharedUsers || hasSharedLink;
return { return {
...mapAlbum(album), ...mapAlbum(album, auth.user.id),
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined), startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined), endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0, 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 }); 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<AlbumResponseDto> { async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
@@ -168,7 +173,7 @@ export class AlbumService extends BaseService {
auth.user.id, 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<void> { async delete(auth: AuthDto, id: string): Promise<void> {
@@ -310,7 +315,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); 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<void> { async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -341,8 +346,22 @@ export class AlbumService extends BaseService {
} }
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> { async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); if (dto.role === undefined && dto.isFavorite === undefined) {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); 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) { private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
@@ -24,6 +24,7 @@ export class AlbumUserFactory {
albumId: newUuid(), albumId: newUuid(),
userId: newUuid(), userId: newUuid(),
role: AlbumUserRole.Editor, role: AlbumUserRole.Editor,
isFavorite: false,
createId: newUuidV7(), createId: newUuidV7(),
createdAt: newDate(), createdAt: newDate(),
updateId: newUuidV7(), updateId: newUuidV7(),
@@ -4,8 +4,8 @@
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu'; import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time'; import { getShortDateRange } from '$lib/utils/date-time';
import { type AlbumResponseDto } from '@immich/sdk'; import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { Icon, IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js'; import { mdiDotsVertical, mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -60,11 +60,14 @@
<div class="mt-4"> <div class="mt-4">
<p <p
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary" class="flex items-center gap-1 w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
data-testid="album-name" data-testid="album-name"
title={album.albumName} title={album.albumName}
> >
{album.albumName} {#if album.isFavorite}
<Icon icon={mdiHeart} size="1em" aria-label={$t('favorite')} class="text-primary shrink-0" />
{/if}
<span class="line-clamp-2">{album.albumName}</span>
</p> </p>
{#if showDateRange && album.startDate && album.endDate} {#if showDateRange && album.startDate && album.endDate}
@@ -131,6 +131,15 @@
case AlbumFilter.Shared: { case AlbumFilter.Shared: {
return sharedAlbums; 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: { default: {
const nonOwnedAlbums = sharedAlbums.filter( const nonOwnedAlbums = sharedAlbums.filter(
(album) => (album) =>
+18
View File
@@ -173,6 +173,24 @@ export const handleUpdateUserAlbumRole = async ({
} }
}; };
export const toggleAlbumFavorite = async (album: AlbumResponseDto): Promise<AlbumResponseDto | undefined> => {
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[]) => { export const handleAddUsersToAlbum = async (album: AlbumResponseDto, users: UserResponseDto[]) => {
const $t = await getFormatter(); const $t = await getFormatter();
+1
View File
@@ -94,6 +94,7 @@ export enum AlbumFilter {
All = 'All', All = 'All',
Owned = 'Owned', Owned = 'Owned',
Shared = 'Shared', Shared = 'Shared',
Favorites = 'Favorites',
} }
export enum AlbumGroupBy { export enum AlbumGroupBy {
@@ -89,6 +89,7 @@
[AlbumFilter.All]: $t('all'), [AlbumFilter.All]: $t('all'),
[AlbumFilter.Owned]: $t('owned'), [AlbumFilter.Owned]: $t('owned'),
[AlbumFilter.Shared]: $t('shared'), [AlbumFilter.Shared]: $t('shared'),
[AlbumFilter.Favorites]: $t('favorites'),
}); });
let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]); let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]);
@@ -44,6 +44,7 @@
getAlbumAssetsActions, getAlbumAssetsActions,
handleDeleteAlbum, handleDeleteAlbum,
handleDownloadAlbum, handleDownloadAlbum,
toggleAlbumFavorite,
} from '$lib/services/album.service'; } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
import { getAssetBulkActions } from '$lib/services/asset.service'; import { getAssetBulkActions } from '$lib/services/asset.service';
@@ -68,6 +69,8 @@
mdiDeleteOutline, mdiDeleteOutline,
mdiDotsVertical, mdiDotsVertical,
mdiDownload, mdiDownload,
mdiHeart,
mdiHeartOutline,
mdiImageOutline, mdiImageOutline,
mdiImagePlusOutline, mdiImagePlusOutline,
mdiLink, mdiLink,
@@ -500,6 +503,20 @@
{#snippet trailing()} {#snippet trailing()}
<ActionButton action={Cast} /> <ActionButton action={Cast} />
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={album.isFavorite ? $t('remove_from_favorites') : $t('to_favorite')}
icon={album.isFavorite ? mdiHeart : mdiHeartOutline}
onclick={async () => {
const updated = await toggleAlbumFavorite(album);
if (updated) {
album = updated;
}
}}
/>
{#if isEditor} {#if isEditor}
<IconButton <IconButton
variant="ghost" variant="ghost"
@@ -14,5 +14,6 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
albumUsers: [], albumUsers: [],
hasSharedLink: false, hasSharedLink: false,
isActivityEnabled: true, isActivityEnabled: true,
isFavorite: false,
order: AssetOrder.Desc, order: AssetOrder.Desc,
}); });