feat(web): deduplication UI (#9540)

This commit is contained in:
Alex 2024-05-23 12:57:25 -05:00 committed by GitHub
parent 832d728940
commit 57d94bce68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 362 additions and 2 deletions

View File

@ -14,6 +14,7 @@ class AssetBulkUpdateDto {
/// Returns a new [AssetBulkUpdateDto] instance.
AssetBulkUpdateDto({
this.dateTimeOriginal,
this.duplicateId,
this.ids = const [],
this.isArchived,
this.isFavorite,
@ -31,6 +32,8 @@ class AssetBulkUpdateDto {
///
String? dateTimeOriginal;
String? duplicateId;
List<String> ids;
///
@ -84,6 +87,7 @@ class AssetBulkUpdateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
other.dateTimeOriginal == dateTimeOriginal &&
other.duplicateId == duplicateId &&
_deepEquality.equals(other.ids, ids) &&
other.isArchived == isArchived &&
other.isFavorite == isFavorite &&
@ -96,6 +100,7 @@ class AssetBulkUpdateDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
(duplicateId == null ? 0 : duplicateId!.hashCode) +
(ids.hashCode) +
(isArchived == null ? 0 : isArchived!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
@ -105,7 +110,7 @@ class AssetBulkUpdateDto {
(stackParentId == null ? 0 : stackParentId!.hashCode);
@override
String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, removeParent=$removeParent, stackParentId=$stackParentId]';
String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, removeParent=$removeParent, stackParentId=$stackParentId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -113,6 +118,11 @@ class AssetBulkUpdateDto {
json[r'dateTimeOriginal'] = this.dateTimeOriginal;
} else {
// json[r'dateTimeOriginal'] = null;
}
if (this.duplicateId != null) {
json[r'duplicateId'] = this.duplicateId;
} else {
// json[r'duplicateId'] = null;
}
json[r'ids'] = this.ids;
if (this.isArchived != null) {
@ -157,6 +167,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],

View File

@ -6955,6 +6955,10 @@
"dateTimeOriginal": {
"type": "string"
},
"duplicateId": {
"nullable": true,
"type": "string"
},
"ids": {
"items": {
"format": "uuid",

View File

@ -238,6 +238,7 @@ export type AssetBulkDeleteDto = {
};
export type AssetBulkUpdateDto = {
dateTimeOriginal?: string;
duplicateId?: string | null;
ids: string[];
isArchived?: boolean;
isFavorite?: boolean;

View File

@ -57,6 +57,9 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
@ValidateBoolean({ optional: true })
removeParent?: boolean;
@Optional()
duplicateId?: string | null;
}
export class UpdateAssetDto extends UpdateAssetBase {

View File

@ -1,11 +1,19 @@
import { IsNotEmpty } from 'class-validator';
import { groupBy } from 'lodash';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ValidateUUID } from 'src/validation';
export class DuplicateResponseDto {
duplicateId!: string;
assets!: AssetResponseDto[];
}
export class ResolveDuplicatesDto {
@IsNotEmpty()
@ValidateUUID({ each: true })
assetIds!: string[];
}
export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] {
const result = [];

View File

@ -8,6 +8,7 @@
import { handleError } from '$lib/utils/handle-error';
import { JobCommand, JobName, sendJobCommand, type AllJobStatusResponseDto, type JobCommandDto } from '@immich/sdk';
import {
mdiContentDuplicate,
mdiFaceRecognition,
mdiFileJpgBox,
mdiFileXmlBox,
@ -88,6 +89,12 @@
subtitle: 'Run machine learning on assets to support smart search',
disabled: !$featureFlags.smartSearch,
},
[JobName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: getJobName(JobName.DuplicateDetection),
subtitle: 'Run machine learning on assets to detect similar images. Relies on Smart Search',
disabled: !$featureFlags.duplicateDetection,
},
[JobName.FaceDetection]: {
icon: mdiFaceRecognition,
title: getJobName(JobName.FaceDetection),

View File

@ -11,6 +11,7 @@
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@ -77,6 +78,37 @@
</div>
</SettingAccordion>
<SettingAccordion
key="duplicate-detection"
title="Duplicate Detection"
subtitle="Use CLIP embeddings to find likely duplicates"
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-duplicate-detection"
title="ENABLED"
subtitle="If disabled, exactly identical assets will still be de-duplicated."
bind:checked={config.machineLearning.duplicateDetection.enabled}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX DETECTION DISTANCE"
bind:value={config.machineLearning.duplicateDetection.maxDistance}
step="0.01"
min={0.001}
max={0.1}
desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
disabled={disabled || $featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="facial-recognition"
title="Facial Recognition"

View File

@ -43,7 +43,7 @@
'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90',
green: 'bg-green-400 text-gray-800 enabled:hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray':
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',

View File

@ -19,6 +19,8 @@
mdiMapOutline,
mdiTrashCan,
mdiTrashCanOutline,
mdiToolbox,
mdiToolboxOutline,
} from '@mdi/js';
import LoadingSpinner from '../loading-spinner.svelte';
import StatusBox from '../status-box.svelte';
@ -42,6 +44,7 @@
let isPhotosSelected: boolean;
let isSharingSelected: boolean;
let isTrashSelected: boolean;
let isUtilitiesSelected: boolean;
</script>
<SideBarSection>
@ -136,6 +139,13 @@
</svelte:fragment>
</SideBarLink>
<SideBarLink
title="Utilities"
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<SideBarLink
title="Archive"
routeId="/(user)/archive"

View File

@ -0,0 +1,133 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { s } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { sortBy } from 'lodash-es';
export let duplicate: DuplicateResponseDto;
export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
let selectedAssetIds = new Set<string>();
$: trashCount = duplicate.assets.length - selectedAssetIds.size;
onMount(() => {
const suggestedAsset = sortBy(duplicate.assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
if (!suggestedAsset) {
selectedAssetIds = new Set(duplicate.assets[0].id);
return;
}
selectedAssetIds.add(suggestedAsset.id);
selectedAssetIds = selectedAssetIds;
});
const onSelectAsset = (asset: AssetResponseDto) => {
if (selectedAssetIds.has(asset.id)) {
selectedAssetIds.delete(asset.id);
} else {
selectedAssetIds.add(asset.id);
}
selectedAssetIds = selectedAssetIds;
};
const handleResolve = () => {
const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
const duplicateAssetIds = duplicate.assets.map((asset) => asset.id);
onResolve(duplicateAssetIds, trashIds);
};
</script>
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-[900px] m-auto mb-16">
<div class="flex flex-wrap gap-1 place-items-center place-content-center px-4 pt-4">
{#each duplicate.assets as asset, index (index)}
{@const isSelected = selectedAssetIds.has(asset.id)}
{@const isFromExternalLibrary = !!asset.libraryId}
{@const assetData = JSON.stringify(asset, null, 2)}
<div class="relative">
<button on:click={() => onSelectAsset(asset)} class="block relative">
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
alt={asset.id}
title={`${assetData}`}
class={`w-[250px] h-[250px] object-cover rounded-t-xl border-t-[4px] border-l-[4px] border-r-[4px] border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}
draggable="false"
/>
<!-- OVERLAY CHIP -->
<div
class={`absolute bottom-2 right-3 ${isSelected ? 'bg-green-400/90' : 'bg-red-300/90'} px-4 py-1 rounded-xl text-xs font-semibold`}
>
{isSelected ? 'Keep' : 'Trash'}
</div>
<!-- EXTERNAL LIBRARY CHIP-->
{#if isFromExternalLibrary}
<div
class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs font-semibold text-white"
>
External
</div>
{/if}
</button>
<!-- ASSET INFO-->
<table
class={`text-xs w-full rounded-b-xl font-semibold ${isSelected ? 'bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black' : 'bg-gray-200 dark:bg-gray-800 dark:text-white'} mt-0 transition-all`}
>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>{asset.originalFileName}</td>
</tr>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center`}
>
<td>{getAssetResolution(asset)} - {getFileSize(asset)}</td>
</tr>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>
{#await getAllAlbums({ assetId: asset.id })}
Scanning for album...
{:then albums}
{#if albums.length === 0}
Not in any album
{:else}
In {albums.length} album{s(albums.length)}
{/if}
{/await}
</td>
</tr>
</table>
</div>
{/each}
</div>
<!-- CONFIRM BUTTONS -->
<div class="flex gap-4 my-4 border-transparent w-full justify-end p-4 h-[85px]">
{#if trashCount === 0}
<Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiCheck} size="20" />Keep All
</Button>
{:else}
<Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiTrashCanOutline} size="20" />{trashCount === duplicate.assets.length
? 'Trash All'
: `Trash ${trashCount}`}
</Button>
{/if}
</div>
</div>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import { mdiContentDuplicate } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
</script>
<a href={AppRoute.DUPLICATES}>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="text-xs font-medium p-4">ORGANIZE YOUR LIBRARY</p>
<button class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
<span
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
Review duplicates
</button>
</div>
</a>

View File

@ -39,6 +39,9 @@ export enum AppRoute {
AUTH_REGISTER = '/auth/register',
AUTH_CHANGE_PASSWORD = '/auth/change-password',
AUTH_ONBOARDING = '/auth/onboarding',
UTILITIES = '/utilities',
DUPLICATES = '/utilities/duplicates',
}
export enum ProjectionType {

View File

@ -7,6 +7,7 @@ import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stor
import { downloadManager } from '$lib/stores/download';
import { downloadRequest, getKey, s } from '$lib/utils';
import { createAlbum } from '$lib/utils/album-utils';
import { asByteUnitString } from '$lib/utils/byte-units';
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
import {
addAssetsToAlbum as addAssets,
@ -223,6 +224,21 @@ export function isFlipped(orientation?: string | null) {
return value && (isRotated270CW(value) || isRotated90CW(value));
}
export function getFileSize(asset: AssetResponseDto): string {
const size = asset.exifInfo?.fileSizeInByte || 0;
return size > 0 ? asByteUnitString(size, undefined, 4) : 'Invalid Data';
}
export function getAssetResolution(asset: AssetResponseDto): string {
const { width, height } = getAssetRatio(asset);
if (width === 235 && height === 235) {
return 'Invalid Data';
}
return `${width} x ${height}`;
}
/**
* Returns aspect ratio for the asset
*/

View File

@ -0,0 +1,15 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import type { PageData } from './$types';
import UtilitiesMenu from '$lib/components/utilities-page/utilities-menu.svelte';
export let data: PageData;
</script>
<UserPageLayout title={data.meta.title}>
<div class="w-full max-w-xl m-auto">
<div class="mt-5">
<UtilitiesMenu />
</div>
</div>
</UserPageLayout>

View File

@ -0,0 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
await authenticate();
const asset = await getAssetInfoFromParam(params);
return {
asset,
meta: {
title: 'Utilities',
},
};
}) satisfies PageLoad;

View File

@ -0,0 +1,66 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import type { PageData } from './$types';
import { handleError } from '$lib/utils/handle-error';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { s } from '$lib/utils';
import { deleteAssets, getConfig, updateAssets } from '@immich/sdk';
export let data: PageData;
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
try {
const { trash } = await getConfig();
// TODO - Create showConfirmDialog controller to show native confirm.
if (
!trash.enabled &&
trashIds.length > 0 &&
!confirm('Are you sure you want to permanently delete these duplicates?')
) {
return;
}
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !trash.enabled } });
data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
if (trashIds.length === 0) {
return;
}
notificationController.show({
message: `Moved ${trashIds.length} asset${s(trashIds.length)} to trash`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to resolve duplicate');
}
};
</script>
<UserPageLayout title={data.meta.title + ` (${data.duplicates.length})`} scrollbar={true}>
<div class="mt-4">
{#if data.duplicates && data.duplicates.length > 0}
<div class="mb-4 text-sm dark:text-white">
<p>Resolve each group by indicating which, if any, are duplicates.</p>
</div>
{#key data.duplicates[0].duplicateId}
<DuplicatesCompareControl
duplicate={data.duplicates[0]}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
/>
{/key}
{:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
No duplicates were found.
</p>
{/if}
</div>
</UserPageLayout>

View File

@ -0,0 +1,18 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAssetDuplicates } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
await authenticate();
const asset = await getAssetInfoFromParam(params);
const duplicates = await getAssetDuplicates();
return {
asset,
duplicates,
meta: {
title: 'Duplicates',
},
};
}) satisfies PageLoad;