Skip to content

feat: Selectable metadata in duplicates utility with diffing#26328

Open
ollioddi wants to merge 13 commits intoimmich-app:mainfrom
ollioddi:feat/duplicate-metadata2
Open

feat: Selectable metadata in duplicates utility with diffing#26328
ollioddi wants to merge 13 commits intoimmich-app:mainfrom
ollioddi:feat/duplicate-metadata2

Conversation

@ollioddi
Copy link

Description

This PR introduces an enhancement to the duplicates utility. It gives the user full control of what metadata to show in the UI for a duplicate group. It also features diffing to only show relevant differences.

Superseedes #21342, as my branch had gotten 2300 commits behind. I re-created it again to respect changes made since.

Motivation

Deleting photos are destructive, and we want to preserve our best versions of our photos.

Keeping the wrong photo can result in a timeline which is out of order, or you simply lose metadata which you can't simply restore, such as GPS data or camera metadata.

How it works

You have full control over what metadata for a photo you want to see. Currently, a set of defaults are configured, but with most attributes hidden by default.

The controls are exposed via a Modal.

As a user, you have two options:

  • Show me ALL the metadata i have selected in in the modal
  • Show me ANY of these attributes, IF they are different across assets

By allowing to only show different metadata, the UI is kept clean and comparison made easier. I don't want to bloat the UI, but these options gives the user full control over their preference.

Furthermore, the current UI relies entirely on icons for the metadata fields. An option has been added to show the labels, at the expense of a more dense layout (Good for 2 duplicates, a little tight for 4).

Changes made

  • Added modal for controlling preference
  • Updated metadata rows to also include the text label (The previous implementation relied on icons only, which I think increases mental load of deciphering)
  • Updated icons to be semantically meaningful
  • Removed unused translations, and added relevant new.
  • Added utilities for diffing
  • Added store for preferences

How Has This Been Tested?

I've used it on many duplicates. Works like a charm.

Screenshots (if appropriate)

image
The new menu entry which opens the modal




image


The preferences modal. Choose display mode, and then individual properties you want to be shown.



image
A duplicate group with only differences selected to be shown



image


A duplicate group with ALL metadata set to be shown




image

Labels hidden, relying on icons

Checklist:

  • I have carefully read CONTRIBUTING.md
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation if applicable
  • I have no unrelated changes in the PR.
  • I have confirmed that any new dependencies are strictly necessary.
  • I have written tests for new code (if applicable)
  • I have followed naming conventions/patterns in the surrounding code
  • All code in src/services/ uses repositories implementations for database calls, filesystem operations, etc.
  • All code in src/repositories/ is pretty basic/simple and does not have any immich specific logic (that belongs in src/services/)

Please describe to which degree, if any, an LLM was used in creating this pull request.

...
Just a little help coming from React, understanding Svelte concepts. Code is written by myself. Feedback on the Svelte part is welcome :-)

I made sure to use Immich/UI and tried to match conventions of existing code.

@ollioddi ollioddi force-pushed the feat/duplicate-metadata2 branch from 1c79b05 to 3a7bf6e Compare February 18, 2026 19:06
@ollioddi
Copy link
Author

I'd like guidance on what i'm doing wrong with the translations :-)

@danieldietzler
Copy link
Member

We've reworked the details shown in the duplicates viewer by now. This PR feels to me like too fine granular customization, I personally don't really see a big use case for this and I doubt many people would use this. @alextran1502 do you agree with me here or do you want that PR?

@ollioddi
Copy link
Author

Hi @danieldietzler, thanks for taking a look!

I understand your concern about adding too much granularity for the average user, but my main motivation here is that deleting duplicates is a destructive action. When users are permanently deleting files, they need absolute confidence that they aren't losing valuable data, and the current UI doesn't always provide enough context for that.

There are a few key reasons I believe showing more information, and adding labels is necessary:

1. Ambiguity and Cognitive Load
Currently, the implementation relies almost entirely on icons without text labels, which is quite constrained and not very user-friendly. For example, the UI shows a generic "date" without clarifying if it's the creation date or the modified date. I frequently have assets where the creation dates are identical, but the modified dates differ. Without clear labels, users have to guess what they are looking at.

2. Flexibility for Power Users (Nielsen's Heuristics)
Regarding the customization feeling too granular: a well-established UX principle Nielsen's heuristic on flexibility and efficiency of use is that a system should cater to both novice and expert users. Tucking these settings away in a modal achieves exactly this. It keeps the default UI clean and simple for the average user, while providing an "accelerator" for power users who need to manage complex, messy libraries.

3. Preventing Hidden Metadata Loss
Because different cameras and apps handle EXIF data differently, two visually identical photos might have very different metadata under the hood (e.g., missing GPS coordinates or camera profiles). If we don't allow users the option to see this data, they can't make an informed choice and might unknowingly delete the "better" file.

The "diffing" feature in this PR specifically addresses your concern about UI bloat: it allows users to only see the fields that actually differ, keeping the interface as clean as possible while still surfacing critical discrepancies.

To me, the current albeit revamped implementation is still lacking, which is why I decided to pick this up again.

I'd love to hear what @alextran1502 thinks. I strongly believe that offering optional labels and the ability to expose hidden metadata differences makes the duplicates tool much safer and more robust for everyone.

@danieldietzler
Copy link
Member

Hey again! We've discussed this and don't want this level of customization. We do however like the diffing, so what we'd prefer is

  • keep the diffing
  • show all fields that differ
  • cut the table at a reasonable length, with an "show more" button to extend it to show all diffing fields.
    Are you able to do that? :)

@ollioddi
Copy link
Author

ollioddi commented Feb 23, 2026

Thanks for continuing looking into it @danieldietzler.

I can absolutely do that. Just to make sure I understand you correctly:

  • Basically remove the modal entirely (The customisation)
  • Always show all fields which differ (No way to show them unless they are different)
  • If theres more than "x" rows different, hide them behind a button to show more (I'll make it a constant so it's easy to fine tune later if someone returns to it)

Did you also discuss the labels? They do take up quite a lot of space, but without them I also feel it's quite an increase in mental load deciphering what you are actually deciding on. Especially if theres many duplicates, which shows different fields on every page.

For most things, its alright i guess, only one resolution. But still want to hear your thoughts.

image

EDIT: Since there will be no customisation, are there any metadata fields you always want shown, regardless if they are different or not?

@alextran1502
Copy link
Member

Adding label is fine, we can find the right text size so that it doesn't take up as much space

@ollioddi
Copy link
Author

ollioddi commented Mar 1, 2026

@danieldietzler @alextran1502 i've implemented your feedback.

Changes:

  • Remove modal & store (The customization)
  • Show 5 dynamic metadatas by default (+1 for albums). Uses a "Show x more fields" to view it all
  • If less than 5 dynamic metadatas are different, the UI is not shown
  • Retained the label, but with a smaller font
  • Fixed an issue with truncating the path which would entirely hide the extension and file, now it truncates more predictably and still allows you to see the root location.
image

Tested by using it on a couple of groups. It even works nice with 28 photos in the same bucket...

Heres how it looks:
image

image

Let me know if you want further modifications.

@multi-suggester
Copy link

I think adding tags as another metadata info and adding an expand button for the long texts might be good addition(i.e albums or long photo names etc)

@ollioddi
Copy link
Author

ollioddi commented Mar 7, 2026

Excellent points @danieldietzler, that is much cleaner and duplicates less. I've also reverted the changes to translations. Sorry!

I can also do tags as suggested by @multi-suggester if you feel it belongs in the PR? I'd have to populate the tags in the duplicate-repository.ts, as they are not currently in the response.

@danieldietzler
Copy link
Member

Nope, please disregard that comment. PRs generally should be focused on one thing; adding more fields would be an extra feature and should go in a new PR :)

@ollioddi
Copy link
Author

Thanks for the feedback @danieldietzler. You had great points. I've pushed the changes, and tested locally. Still functions as expected.

Let me know if you need anything else from me.

location: isDifferent(
(a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown',
// Ordered list of keys that differ, sliced based on show-more state
let visibleKeySet = $derived(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let visibleKeySet = $derived(
let visibleKeys = $derived(

keys: MetadataFieldKey[];
};

let allMetadataItems = $derived([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let allMetadataItems = $derived([
let allMetadataItems = $derived<MetadataItem[]>([

IMO is cleaner than the satisfies.
Also, this can be const right?

import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';

const INITIAL_VISIBLE_COUNT = 5;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use UPPER_SNAKE_CASE for constants. Just do UpperCamelCase.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, as a personal preference I would put it on line 37 or something

Comment on lines +37 to +40
let differingMetadataFields: DifferingMetadataFields = $derived(computeDifferingMetadataFields(assets));

let differingCount = $derived(Object.values(differingMetadataFields).filter(Boolean).length);
let hasMore = $derived(differingCount > INITIAL_VISIBLE_COUNT);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these all be const?

Comment on lines +19 to +25
<Text size="tiny" class="text-immich-fg/40 dark:text-immich-dark-fg/40 self-center truncate px-1 pr-2 text-[10px]">
{title}
</Text>
{/if}

<div class="overflow-hidden justify-self-end text-end rounded px-1 transition-colors">
<Text size="tiny" class="break-all text-[10px]">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't be overwriting the font size. If you want a larger text, just use size="small" or something instead of size="tiny"

return duplicateAssets.pop();
};

export const MetadataFieldKeys = [
Copy link
Member

@danieldietzler danieldietzler Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just allMetadataItems.flatMap(({ keys }) => keys), right?
I think what I would like to see is

  • drop this array
  • move allMetadataItems here
    • export const getAllMetadataItems = (asset: AssetResponseDto, $t: MessageFormatter) => [...]
  • The type of MetadataFieldKey should then be something like ReturnType<typeof getAllMetadataItems>[number]['keys'] (if you even still need that type)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think getting those >100loc out of the svelte component helps a lot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants