1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-03 01:22:44 +01:00
This commit is contained in:
Arno 2025-01-28 21:54:35 +01:00 committed by GitHub
commit e42f5ddb20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 215 additions and 26 deletions

View file

@ -1,8 +0,0 @@
---
sidebar_position: 2
---
# Comparison
If you're new here and came from other asset self-hosting alternatives you might want to look at a comparison between Immich and your current solution.
Here you can see a [comparison between the various OpenSource Photo Libraries](https://meichthys.github.io/foss_photo_libraries/) including Immich.

View file

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -10280,6 +10280,9 @@
"description": "Person id.",
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10385,6 +10388,9 @@
"nullable": true,
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10406,6 +10412,10 @@
"id": {
"type": "string"
},
"isFavorite": {
"description": "This property was added in v1.124.0",
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},
@ -10453,6 +10463,9 @@
"description": "Asset is used to get the feature face thumbnail.",
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10480,6 +10493,10 @@
"id": {
"type": "string"
},
"isFavorite": {
"description": "This property was added in v1.124.0",
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},

View file

@ -215,6 +215,8 @@ export type PersonWithFacesResponseDto = {
birthDate: string | null;
faces: AssetFaceWithoutPersonResponseDto[];
id: string;
/** This property was added in v1.124.0 */
isFavorite?: boolean;
isHidden: boolean;
name: string;
thumbnailPath: string;
@ -492,6 +494,8 @@ export type DuplicateResponseDto = {
export type PersonResponseDto = {
birthDate: string | null;
id: string;
/** This property was added in v1.124.0 */
isFavorite?: boolean;
isHidden: boolean;
name: string;
thumbnailPath: string;
@ -689,6 +693,7 @@ export type PersonCreateDto = {
/** Person date of birth.
Note: the mobile app cannot currently set the birth date to null. */
birthDate?: string | null;
isFavorite?: boolean;
/** Person visibility */
isHidden?: boolean;
/** Person name. */
@ -702,6 +707,7 @@ export type PeopleUpdateItem = {
featureFaceAssetId?: string;
/** Person id. */
id: string;
isFavorite?: boolean;
/** Person visibility */
isHidden?: boolean;
/** Person name. */
@ -716,6 +722,7 @@ export type PersonUpdateDto = {
birthDate?: string | null;
/** Asset is used to get the feature face thumbnail. */
featureFaceAssetId?: string;
isFavorite?: boolean;
/** Person visibility */
isHidden?: boolean;
/** Person name. */

22
server/src/db.d.ts vendored
View file

@ -3,21 +3,16 @@
* Please do not edit it manually.
*/
import type { ColumnType } from "kysely";
import type { ColumnType } from 'kysely';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[]
? U[]
: ArrayTypeImpl<T>;
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S[], I[], U[]>
: T[];
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
export type AssetsStatusEnum = "active" | "deleted" | "trashed";
export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
@ -33,7 +28,7 @@ export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Sourcetype = "exif" | "machine-learning";
export type Sourcetype = 'exif' | 'machine-learning';
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
@ -279,6 +274,7 @@ export interface Person {
createdAt: Generated<Timestamp>;
faceAssetId: string | null;
id: Generated<string>;
isFavorite: Generated<boolean>;
isHidden: Generated<boolean>;
name: Generated<string>;
ownerId: string;
@ -438,6 +434,6 @@ export interface DB {
tags_closure: TagsClosure;
user_metadata: UserMetadata;
users: Users;
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat;
'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
version_history: VersionHistory;
}

View file

@ -32,6 +32,10 @@ export class PersonCreateDto {
*/
@ValidateBoolean({ optional: true })
isHidden?: boolean;
@ApiProperty()
@ValidateBoolean({ optional: true })
isFavorite?: boolean;
}
export class PersonUpdateDto extends PersonCreateDto {
@ -97,6 +101,9 @@ export class PersonResponseDto {
isHidden!: boolean;
@PropertyLifecycle({ addedAt: 'v1.107.0' })
updatedAt?: Date;
@ApiProperty()
@PropertyLifecycle({ addedAt: 'v1.124.0' })
isFavorite?: boolean;
}
export class PersonWithFacesResponseDto extends PersonResponseDto {
@ -170,6 +177,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
birthDate: person.birthDate,
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
updatedAt: person.updatedAt,
};
}

View file

@ -49,4 +49,7 @@ export class PersonEntity {
@Column({ default: false })
isHidden!: boolean;
@Column({ default: false })
isFavorite!: boolean;
}

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddIsFavoritePerson1734879118272 implements MigrationInterface {
name = 'AddIsFavoritePerson1734879118272'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`);
}
}

View file

@ -132,6 +132,7 @@ export class PersonRepository implements IPersonRepository {
)
.where('person.ownerId', '=', userId)
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
eb.or([
eb('person.name', '!=', ''),

View file

@ -30,6 +30,7 @@ const responseDto: PersonResponseDto = {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
updatedAt: expect.any(Date),
isFavorite: false,
};
const statistics = { assets: 3 };
@ -116,6 +117,7 @@ describe(PersonService.name, () => {
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
isFavorite: false,
updatedAt: expect.any(Date),
},
],
@ -125,6 +127,35 @@ describe(PersonService.name, () => {
withHidden: true,
});
});
it('should get all visible people and favorites should be first in the array', async () => {
personMock.getAllForUser.mockResolvedValue({
items: [personStub.isFavorite, personStub.withName],
hasNextPage: false,
});
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
hasNextPage: false,
total: 2,
hidden: 1,
people: [
{
id: 'person-4',
name: personStub.isFavorite.name,
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
isFavorite: true,
updatedAt: expect.any(Date),
},
responseDto,
],
});
expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
minimumFaceCount: 3,
withHidden: false,
});
});
});
describe('getById', () => {
@ -227,6 +258,7 @@ describe(PersonService.name, () => {
birthDate: '1976-06-30',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
isFavorite: false,
updatedAt: expect.any(Date),
});
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
@ -245,6 +277,16 @@ describe(PersonService.name, () => {
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it('should update a person favorite status', async () => {
personMock.update.mockResolvedValue(personStub.withName);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it("should update a person's thumbnailPath", async () => {
personMock.update.mockResolvedValue(personStub.withName);
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
@ -375,6 +417,7 @@ describe(PersonService.name, () => {
).resolves.toEqual({
birthDate: personStub.noName.birthDate,
isHidden: personStub.noName.isHidden,
isFavorite: personStub.noName.isFavorite,
id: personStub.noName.id,
name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath,

View file

@ -184,13 +184,14 @@ export class PersonService extends BaseService {
name: dto.name,
birthDate: dto.birthDate,
isHidden: dto.isHidden,
isFavorite: dto.isFavorite,
});
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
@ -203,7 +204,14 @@ export class PersonService extends BaseService {
faceId = face.id;
}
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
const person = await this.personRepository.update({
id,
faceAssetId: faceId,
name,
birthDate,
isHidden,
isFavorite,
});
if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
@ -221,6 +229,7 @@ export class PersonService extends BaseService {
name: person.name,
birthDate: person.birthDate,
featureFaceAssetId: person.featureFaceAssetId,
isFavorite: person.isFavorite,
});
results.push({ id: person.id, success: true });
} catch (error: Error | any) {

View file

@ -15,6 +15,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
hidden: Object.freeze<PersonEntity>({
id: 'person-1',
@ -29,6 +30,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: true,
isFavorite: false,
}),
withName: Object.freeze<PersonEntity>({
id: 'person-1',
@ -43,6 +45,7 @@ export const personStub = {
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
withBirthDate: Object.freeze<PersonEntity>({
id: 'person-1',
@ -57,6 +60,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
noThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@ -71,6 +75,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@ -85,6 +90,7 @@ export const personStub = {
faceAssetId: 'asset-id',
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
primaryPerson: Object.freeze<PersonEntity>({
id: 'person-1',
@ -99,6 +105,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
mergePerson: Object.freeze<PersonEntity>({
id: 'person-2',
@ -113,6 +120,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
randomPerson: Object.freeze<PersonEntity>({
id: 'person-3',
@ -127,5 +135,21 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
isFavorite: Object.freeze<PersonEntity>({
id: 'person-4',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
isFavorite: true,
}),
};

View file

@ -8,12 +8,15 @@
mdiCalendarEditOutline,
mdiDotsVertical,
mdiEyeOffOutline,
mdiHeart,
mdiHeartOutline,
} from '@mdi/js';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { t } from 'svelte-i18n';
import { focusOutside } from '$lib/actions/focus-outside';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import Icon from '$lib/components/elements/icon.svelte';
interface Props {
person: PersonResponseDto;
@ -22,9 +25,18 @@
onSetBirthDate: () => void;
onMergePeople: () => void;
onHidePerson: () => void;
onToggleFavorite: () => void;
}
let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props();
let {
person,
preload = false,
onChangeName,
onSetBirthDate,
onMergePeople,
onHidePerson,
onToggleFavorite,
}: Props = $props();
let showVerticalDots = $state(false);
</script>
@ -51,6 +63,11 @@
title={person.name}
widthStyle="100%"
/>
{#if person.isFavorite}
<div class="absolute bottom-2 left-2 z-10">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
</div>
{#if person.name}
<span
@ -76,6 +93,11 @@
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
<MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} />
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
<MenuOption
onClick={onToggleFavorite}
icon={person.isFavorite ? mdiHeart : mdiHeartOutline}
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
/>
</ButtonContextMenu>
</div>
{/if}

View file

@ -11,6 +11,8 @@
import { onMount } from 'svelte';
import { websocketEvents } from '$lib/stores/websocket';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiHeart } from '@mdi/js';
interface Props {
data: PageData;
@ -53,7 +55,7 @@
<SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
{#snippet children({ itemCount })}
{#each people.slice(0, itemCount) as person (person.id)}
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center">
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center relative">
<ImageThumbnail
circle
shadow
@ -61,6 +63,11 @@
altText={person.name}
widthStyle="100%"
/>
{#if person.isFavorite}
<div class="absolute bottom-2 left-2 z-10">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
</a>
{/each}

View file

@ -222,6 +222,25 @@
}
};
const handleToggleFavorite = async (detail: PersonResponseDto) => {
try {
const updatedPerson = await updatePerson({
id: detail.id,
personUpdateDto: { isFavorite: !detail.isFavorite },
});
const index = people.findIndex((person) => person.id === detail.id);
people[index] = updatedPerson;
notificationController.show({
message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } }));
}
};
const handleMergePeople = async (detail: PersonResponseDto) => {
await goto(
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
@ -413,6 +432,7 @@
onSetBirthDate={() => handleSetBirthDate(person)}
onMergePeople={() => handleMergePeople(person)}
onHidePerson={() => handleHidePerson(person)}
onToggleFavorite={() => handleToggleFavorite(person)}
/>
{/snippet}
</PeopleInfiniteScroll>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
@ -50,6 +50,8 @@
mdiDotsVertical,
mdiEyeOffOutline,
mdiEyeOutline,
mdiHeart,
mdiHeartOutline,
mdiPlus,
} from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
@ -181,6 +183,25 @@
}
};
const toggleFavoritePerson = async () => {
try {
const updatedPerson = await updatePerson({
id: person.id,
personUpdateDto: { isFavorite: !person.isFavorite },
});
// Invalidate to reload the page data and have the favorite status updated
await invalidateAll();
notificationController.show({
message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: person.isFavorite } }));
}
};
const handleMerge = async (person: PersonResponseDto) => {
await updateAssetCount();
await handleGoBack();
@ -445,6 +466,11 @@
icon={mdiAccountMultipleCheckOutline}
onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
/>
<MenuOption
icon={person.isFavorite ? mdiHeart : mdiHeartOutline}
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
onClick={() => toggleFavoritePerson()}
/>
</ButtonContextMenu>
{/snippet}
</ControlAppBar>