mirror of
https://github.com/immich-app/immich.git
synced 2025-06-03 05:36:44 -04:00
feat(web): deduplication UI (#9540)
This commit is contained in:
parent
832d728940
commit
57d94bce68
13
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
13
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
@ -14,6 +14,7 @@ class AssetBulkUpdateDto {
|
|||||||
/// Returns a new [AssetBulkUpdateDto] instance.
|
/// Returns a new [AssetBulkUpdateDto] instance.
|
||||||
AssetBulkUpdateDto({
|
AssetBulkUpdateDto({
|
||||||
this.dateTimeOriginal,
|
this.dateTimeOriginal,
|
||||||
|
this.duplicateId,
|
||||||
this.ids = const [],
|
this.ids = const [],
|
||||||
this.isArchived,
|
this.isArchived,
|
||||||
this.isFavorite,
|
this.isFavorite,
|
||||||
@ -31,6 +32,8 @@ class AssetBulkUpdateDto {
|
|||||||
///
|
///
|
||||||
String? dateTimeOriginal;
|
String? dateTimeOriginal;
|
||||||
|
|
||||||
|
String? duplicateId;
|
||||||
|
|
||||||
List<String> ids;
|
List<String> ids;
|
||||||
|
|
||||||
///
|
///
|
||||||
@ -84,6 +87,7 @@ class AssetBulkUpdateDto {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
|
||||||
other.dateTimeOriginal == dateTimeOriginal &&
|
other.dateTimeOriginal == dateTimeOriginal &&
|
||||||
|
other.duplicateId == duplicateId &&
|
||||||
_deepEquality.equals(other.ids, ids) &&
|
_deepEquality.equals(other.ids, ids) &&
|
||||||
other.isArchived == isArchived &&
|
other.isArchived == isArchived &&
|
||||||
other.isFavorite == isFavorite &&
|
other.isFavorite == isFavorite &&
|
||||||
@ -96,6 +100,7 @@ class AssetBulkUpdateDto {
|
|||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
|
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
|
||||||
|
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
||||||
(ids.hashCode) +
|
(ids.hashCode) +
|
||||||
(isArchived == null ? 0 : isArchived!.hashCode) +
|
(isArchived == null ? 0 : isArchived!.hashCode) +
|
||||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||||
@ -105,7 +110,7 @@ class AssetBulkUpdateDto {
|
|||||||
(stackParentId == null ? 0 : stackParentId!.hashCode);
|
(stackParentId == null ? 0 : stackParentId!.hashCode);
|
||||||
|
|
||||||
@override
|
@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() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -113,6 +118,11 @@ class AssetBulkUpdateDto {
|
|||||||
json[r'dateTimeOriginal'] = this.dateTimeOriginal;
|
json[r'dateTimeOriginal'] = this.dateTimeOriginal;
|
||||||
} else {
|
} else {
|
||||||
// json[r'dateTimeOriginal'] = null;
|
// json[r'dateTimeOriginal'] = null;
|
||||||
|
}
|
||||||
|
if (this.duplicateId != null) {
|
||||||
|
json[r'duplicateId'] = this.duplicateId;
|
||||||
|
} else {
|
||||||
|
// json[r'duplicateId'] = null;
|
||||||
}
|
}
|
||||||
json[r'ids'] = this.ids;
|
json[r'ids'] = this.ids;
|
||||||
if (this.isArchived != null) {
|
if (this.isArchived != null) {
|
||||||
@ -157,6 +167,7 @@ class AssetBulkUpdateDto {
|
|||||||
|
|
||||||
return AssetBulkUpdateDto(
|
return AssetBulkUpdateDto(
|
||||||
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
||||||
|
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||||
ids: json[r'ids'] is Iterable
|
ids: json[r'ids'] is Iterable
|
||||||
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
|
@ -6955,6 +6955,10 @@
|
|||||||
"dateTimeOriginal": {
|
"dateTimeOriginal": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"duplicateId": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"ids": {
|
"ids": {
|
||||||
"items": {
|
"items": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
|
@ -238,6 +238,7 @@ export type AssetBulkDeleteDto = {
|
|||||||
};
|
};
|
||||||
export type AssetBulkUpdateDto = {
|
export type AssetBulkUpdateDto = {
|
||||||
dateTimeOriginal?: string;
|
dateTimeOriginal?: string;
|
||||||
|
duplicateId?: string | null;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
@ -57,6 +57,9 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
|
|||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({ optional: true })
|
||||||
removeParent?: boolean;
|
removeParent?: boolean;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
duplicateId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateAssetDto extends UpdateAssetBase {
|
export class UpdateAssetDto extends UpdateAssetBase {
|
||||||
|
@ -1,11 +1,19 @@
|
|||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
|
import { ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class DuplicateResponseDto {
|
export class DuplicateResponseDto {
|
||||||
duplicateId!: string;
|
duplicateId!: string;
|
||||||
assets!: AssetResponseDto[];
|
assets!: AssetResponseDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ResolveDuplicatesDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@ValidateUUID({ each: true })
|
||||||
|
assetIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] {
|
export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] {
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { JobCommand, JobName, sendJobCommand, type AllJobStatusResponseDto, type JobCommandDto } from '@immich/sdk';
|
import { JobCommand, JobName, sendJobCommand, type AllJobStatusResponseDto, type JobCommandDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
|
mdiContentDuplicate,
|
||||||
mdiFaceRecognition,
|
mdiFaceRecognition,
|
||||||
mdiFileJpgBox,
|
mdiFileJpgBox,
|
||||||
mdiFileXmlBox,
|
mdiFileXmlBox,
|
||||||
@ -88,6 +89,12 @@
|
|||||||
subtitle: 'Run machine learning on assets to support smart search',
|
subtitle: 'Run machine learning on assets to support smart search',
|
||||||
disabled: !$featureFlags.smartSearch,
|
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]: {
|
[JobName.FaceDetection]: {
|
||||||
icon: mdiFaceRecognition,
|
icon: mdiFaceRecognition,
|
||||||
title: getJobName(JobName.FaceDetection),
|
title: getJobName(JobName.FaceDetection),
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.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 savedConfig: SystemConfigDto;
|
||||||
export let defaultConfig: SystemConfigDto;
|
export let defaultConfig: SystemConfigDto;
|
||||||
@ -77,6 +78,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</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
|
<SettingAccordion
|
||||||
key="facial-recognition"
|
key="facial-recognition"
|
||||||
title="Facial Recognition"
|
title="Facial Recognition"
|
||||||
|
@ -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',
|
'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',
|
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
|
||||||
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
|
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',
|
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':
|
'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',
|
'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',
|
||||||
|
@ -19,6 +19,8 @@
|
|||||||
mdiMapOutline,
|
mdiMapOutline,
|
||||||
mdiTrashCan,
|
mdiTrashCan,
|
||||||
mdiTrashCanOutline,
|
mdiTrashCanOutline,
|
||||||
|
mdiToolbox,
|
||||||
|
mdiToolboxOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import LoadingSpinner from '../loading-spinner.svelte';
|
import LoadingSpinner from '../loading-spinner.svelte';
|
||||||
import StatusBox from '../status-box.svelte';
|
import StatusBox from '../status-box.svelte';
|
||||||
@ -42,6 +44,7 @@
|
|||||||
let isPhotosSelected: boolean;
|
let isPhotosSelected: boolean;
|
||||||
let isSharingSelected: boolean;
|
let isSharingSelected: boolean;
|
||||||
let isTrashSelected: boolean;
|
let isTrashSelected: boolean;
|
||||||
|
let isUtilitiesSelected: boolean;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SideBarSection>
|
<SideBarSection>
|
||||||
@ -136,6 +139,13 @@
|
|||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</SideBarLink>
|
</SideBarLink>
|
||||||
|
|
||||||
|
<SideBarLink
|
||||||
|
title="Utilities"
|
||||||
|
routeId="/(user)/utilities"
|
||||||
|
bind:isSelected={isUtilitiesSelected}
|
||||||
|
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
|
||||||
|
></SideBarLink>
|
||||||
|
|
||||||
<SideBarLink
|
<SideBarLink
|
||||||
title="Archive"
|
title="Archive"
|
||||||
routeId="/(user)/archive"
|
routeId="/(user)/archive"
|
||||||
|
@ -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>
|
18
web/src/lib/components/utilities-page/utilities-menu.svelte
Normal file
18
web/src/lib/components/utilities-page/utilities-menu.svelte
Normal 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>
|
@ -39,6 +39,9 @@ export enum AppRoute {
|
|||||||
AUTH_REGISTER = '/auth/register',
|
AUTH_REGISTER = '/auth/register',
|
||||||
AUTH_CHANGE_PASSWORD = '/auth/change-password',
|
AUTH_CHANGE_PASSWORD = '/auth/change-password',
|
||||||
AUTH_ONBOARDING = '/auth/onboarding',
|
AUTH_ONBOARDING = '/auth/onboarding',
|
||||||
|
|
||||||
|
UTILITIES = '/utilities',
|
||||||
|
DUPLICATES = '/utilities/duplicates',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProjectionType {
|
export enum ProjectionType {
|
||||||
|
@ -7,6 +7,7 @@ import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stor
|
|||||||
import { downloadManager } from '$lib/stores/download';
|
import { downloadManager } from '$lib/stores/download';
|
||||||
import { downloadRequest, getKey, s } from '$lib/utils';
|
import { downloadRequest, getKey, s } from '$lib/utils';
|
||||||
import { createAlbum } from '$lib/utils/album-utils';
|
import { createAlbum } from '$lib/utils/album-utils';
|
||||||
|
import { asByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
|
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
|
||||||
import {
|
import {
|
||||||
addAssetsToAlbum as addAssets,
|
addAssetsToAlbum as addAssets,
|
||||||
@ -223,6 +224,21 @@ export function isFlipped(orientation?: string | null) {
|
|||||||
return value && (isRotated270CW(value) || isRotated90CW(value));
|
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
|
* Returns aspect ratio for the asset
|
||||||
*/
|
*/
|
||||||
|
15
web/src/routes/(user)/utilities/+page.svelte
Normal file
15
web/src/routes/(user)/utilities/+page.svelte
Normal 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>
|
15
web/src/routes/(user)/utilities/+page.ts
Normal file
15
web/src/routes/(user)/utilities/+page.ts
Normal 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;
|
@ -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>
|
@ -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;
|
Loading…
x
Reference in New Issue
Block a user