diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index baeabc35e5aa9..dcab64e1f380f 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -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 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 toJson() { final json = {}; @@ -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(json, r'dateTimeOriginal'), + duplicateId: mapValueOfType(json, r'duplicateId'), ids: json[r'ids'] is Iterable ? (json[r'ids'] as Iterable).cast().toList(growable: false) : const [], diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 929338b7347a1..9ad8d86fa69d7 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6957,6 +6957,10 @@ "dateTimeOriginal": { "type": "string" }, + "duplicateId": { + "nullable": true, + "type": "string" + }, "ids": { "items": { "format": "uuid", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 02ff002f01293..6c84706ca9da7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -238,6 +238,7 @@ export type AssetBulkDeleteDto = { }; export type AssetBulkUpdateDto = { dateTimeOriginal?: string; + duplicateId?: string | null; ids: string[]; isArchived?: boolean; isFavorite?: boolean; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 72f1b24c1bca6..e60c323a6e1c7 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -57,6 +57,9 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { @ValidateBoolean({ optional: true }) removeParent?: boolean; + + @Optional() + duplicateId?: string | null; } export class UpdateAssetDto extends UpdateAssetBase { diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index cdfeed056f260..73863fa95da51 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -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 = []; diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 0d01e2c3e49ca..c7b177b05a459 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -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), diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index f032fa8c80901..cb6c2b480b1c2 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -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 @@ + +
+ + +
+ + +
+
+ @@ -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 0000000000000..81759fd8306a4 --- /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 ec393a57b90d2..a9b7b8929dabb 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 dc29375ddd024..f94c8b4375893 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 0000000000000..bf18b99436aa9 --- /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 0000000000000..1a62d6ec3f22b --- /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 0000000000000..fbb7ebca31f9d --- /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 0000000000000..67c33b85fd8be --- /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;