From 57d94bce6865d222ed56078224076a682017869e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 May 2024 12:57:25 -0500 Subject: [PATCH] feat(web): deduplication UI (#9540) --- .../lib/model/asset_bulk_update_dto.dart | Bin 7948 -> 8322 bytes open-api/immich-openapi-specs.json | 4 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/asset.dto.ts | 3 + server/src/dtos/duplicate.dto.ts | 8 ++ .../admin-page/jobs/jobs-panel.svelte | 7 + .../machine-learning-settings.svelte | 32 +++++ .../components/elements/buttons/button.svelte | 2 +- .../side-bar/side-bar.svelte | 10 ++ .../duplicates-compare-control.svelte | 133 ++++++++++++++++++ .../utilities-page/utilities-menu.svelte | 18 +++ web/src/lib/constants.ts | 3 + web/src/lib/utils/asset-utils.ts | 16 +++ web/src/routes/(user)/utilities/+page.svelte | 15 ++ web/src/routes/(user)/utilities/+page.ts | 15 ++ .../[[assetId=id]]/+page.svelte | 66 +++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 18 +++ 17 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte create mode 100644 web/src/lib/components/utilities-page/utilities-menu.svelte create mode 100644 web/src/routes/(user)/utilities/+page.svelte create mode 100644 web/src/routes/(user)/utilities/+page.ts create mode 100644 web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index baeabc35e5aa9d74c0ffd7bcbe15c2c88aa4cb6d..dcab64e1f380ff0021e389fd0ed0af23be40a092 100644 GIT binary patch delta 270 zcmeCNYjWJMijg~|v>+!lIk6 + +
+ + +
+ + +
+
+ @@ -136,6 +139,13 @@ + + + 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(); + + $: 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); + }; + + +
+
+ {#each duplicate.assets as asset, index (index)} + {@const isSelected = selectedAssetIds.has(asset.id)} + {@const isFromExternalLibrary = !!asset.libraryId} + {@const assetData = JSON.stringify(asset, null, 2)} + +
+ + + + + + + + + + + + + + + +
{asset.originalFileName}
{getAssetResolution(asset)} - {getFileSize(asset)}
+ {#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} +
+
+ {/each} +
+ + +
+ {#if trashCount === 0} + + {:else} + + {/if} +
+
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte new file mode 100644 index 0000000000..81759fd830 --- /dev/null +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -0,0 +1,18 @@ + + + +
+

ORGANIZE YOUR LIBRARY

+ + +
+
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index ec393a57b9..a9b7b8929d 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -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 { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index dc29375ddd..f94c8b4375 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -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 */ diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte new file mode 100644 index 0000000000..bf18b99436 --- /dev/null +++ b/web/src/routes/(user)/utilities/+page.svelte @@ -0,0 +1,15 @@ + + + +
+
+ +
+
+
diff --git a/web/src/routes/(user)/utilities/+page.ts b/web/src/routes/(user)/utilities/+page.ts new file mode 100644 index 0000000000..1a62d6ec3f --- /dev/null +++ b/web/src/routes/(user)/utilities/+page.ts @@ -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; diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..fbb7ebca31 --- /dev/null +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,66 @@ + + + +
+ {#if data.duplicates && data.duplicates.length > 0} +
+

Resolve each group by indicating which, if any, are duplicates.

+
+ {#key data.duplicates[0].duplicateId} + + handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)} + /> + {/key} + {:else} +

+ No duplicates were found. +

+ {/if} +
+
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..67c33b85fd --- /dev/null +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -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;