Repository: LycheeOrg/Lychee (language: php)

Issue:
[Enhancement] Remove Duplicates
A Deduplication or "Remove Duplicates" feature would be welcomed!



Discussion / hints:
Finding the duplicates is the easy part. Providing the interaction to know which one to remove is another piece of cake in the other hand...

That being said, I will not be working on this enhancement (other mights or project for volunteers).
Prompting the user to choose which duplicate to remove should be fine.
> Prompting the user to choose which duplicate to remove should be fine.

Like I said, easier said than done. :sweat_smile: 
Agreed. Just filed this issue for tracking here: https://github.com/meichthys/foss_photo_libraries#free-and-opensource-photo-libraries
I did not know about this repo, however this is a bit incorrect:
- [Photo Discovery](https://github.com/meichthys/foss_photo_libraries/blob/main/features.md#photo-discovery) is covered with the _On This Day_ smart album.
- [Photo Map](https://github.com/meichthys/foss_photo_libraries/blob/main/features.md#photo-map) is covered: we do have the map in the top right corner.
:)
Awesome. Thanks! I'll get that updated 👍
is there a function when *uploading* new images that catches duplicates?
> is there a function when _uploading_ new images that catches duplicates?

A bit late, but Yes.
And there is a settings which allows you to decide what happens in such cases.

Fix patch:
diff --git a/lang/cz/duplicate-finder.php b/lang/cz/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/cz/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/cz/maintenance.php b/lang/cz/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/cz/maintenance.php
+++ b/lang/cz/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/de/duplicate-finder.php b/lang/de/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/de/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php
index 6b9b0ec426a..67eb7720051 100644
--- a/lang/de/maintenance.php
+++ b/lang/de/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Lösche den gesamten Inhalt aus <span class="font-mono">%s</span>',
 		'button' => 'Säubern',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Job Historie reparieren',
 		'description' => 'Markiere Jobs mit dem Status <span class="text-ready-400">%s</span> oder <span class="text-primary-500">%s</span> als <span class="text-danger-700">%s</span>.',
diff --git a/lang/el/duplicate-finder.php b/lang/el/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/el/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/el/maintenance.php b/lang/el/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/el/maintenance.php
+++ b/lang/el/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/en/duplicate-finder.php b/lang/en/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/en/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php
index 44b2ed00e9a..ed972d7aaa0 100644
--- a/lang/en/maintenance.php
+++ b/lang/en/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/es/duplicate-finder.php b/lang/es/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/es/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/es/maintenance.php b/lang/es/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/es/maintenance.php
+++ b/lang/es/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/fr/duplicate-finder.php b/lang/fr/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/fr/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/fr/maintenance.php b/lang/fr/maintenance.php
index 35fa82b53fc..ed0306045d3 100644
--- a/lang/fr/maintenance.php
+++ b/lang/fr/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Supprimer le contenu de <span class="font-mono">%s</span>',
 		'button' => 'Nettoyer',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Réparer l’historique des Jobs',
 		'description' => 'Marquer les jobs avec status <span class="text-ready-400">%s</span> ou <span class="text-primary-500">%s</span> comme <span class="text-danger-700">%s</span>.',
diff --git a/lang/hu/duplicate-finder.php b/lang/hu/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/hu/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/hu/maintenance.php b/lang/hu/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/hu/maintenance.php
+++ b/lang/hu/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/it/duplicate-finder.php b/lang/it/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/it/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/it/maintenance.php b/lang/it/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/it/maintenance.php
+++ b/lang/it/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/ja/duplicate-finder.php b/lang/ja/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/ja/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/ja/maintenance.php b/lang/ja/maintenance.php
index 547f4ca1245..75df17ac35a 100644
--- a/lang/ja/maintenance.php
+++ b/lang/ja/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => '<span class="font-mono">%s</span> からすべてのコンテンツを削除します',
 		'button' => '削除',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'ジョブ履歴の修正',
 		'description' => 'ステータスが <span class="text-ready-400">%s</span> または <span class="text-primary-500">%s</span> のジョブを <span class="text-danger-700">%s</span> としてマークします。',
diff --git a/lang/nl/duplicate-finder.php b/lang/nl/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/nl/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/nl/maintenance.php b/lang/nl/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/nl/maintenance.php
+++ b/lang/nl/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/no/duplicate-finder.php b/lang/no/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/no/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/no/maintenance.php b/lang/no/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/no/maintenance.php
+++ b/lang/no/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/pl/duplicate-finder.php b/lang/pl/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/pl/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/pl/maintenance.php b/lang/pl/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/pl/maintenance.php
+++ b/lang/pl/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/pt/duplicate-finder.php b/lang/pt/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/pt/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/pt/maintenance.php b/lang/pt/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/pt/maintenance.php
+++ b/lang/pt/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/ru/duplicate-finder.php b/lang/ru/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/ru/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/ru/maintenance.php b/lang/ru/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/ru/maintenance.php
+++ b/lang/ru/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/sk/duplicate-finder.php b/lang/sk/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/sk/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/sk/maintenance.php b/lang/sk/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/sk/maintenance.php
+++ b/lang/sk/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/sv/duplicate-finder.php b/lang/sv/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/sv/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/sv/maintenance.php b/lang/sv/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/sv/maintenance.php
+++ b/lang/sv/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/vi/duplicate-finder.php b/lang/vi/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/vi/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/vi/maintenance.php b/lang/vi/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/vi/maintenance.php
+++ b/lang/vi/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/zh_CN/duplicate-finder.php b/lang/zh_CN/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/zh_CN/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/zh_CN/maintenance.php b/lang/zh_CN/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/zh_CN/maintenance.php
+++ b/lang/zh_CN/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/lang/zh_TW/duplicate-finder.php b/lang/zh_TW/duplicate-finder.php
new file mode 100644
index 00000000000..5a7282f7f2e
--- /dev/null
+++ b/lang/zh_TW/duplicate-finder.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) 2017-2018 Tobias Reich
+ * Copyright (c) 2018-2025 LycheeOrg.
+ */
+
+return [
+	/*
+	|--------------------------------------------------------------------------
+	| Duplicate Finder Page
+	|--------------------------------------------------------------------------
+	*/
+	'title' => 'Maintenance',
+	'intro' => 'On this page you will find the duplicate pictures found in your database.',
+	'found' => ' duplicates found!',
+	'invalid-search' => ' At least the checksum or title condition must be checked.',
+	'checksum-must-match' => 'Checksum must match.',
+	'title-must-match' => 'Title must match.',
+	'must-be-in-same-album' => 'Must be in the same album.',
+
+	'columns' => [
+		'album' => 'Album',
+		'photo' => 'Photo',
+		'checksum' => 'Checksum',
+	],
+
+	'warning' => [
+		'no-original-left' => 'No original left.',
+		'keep-one' => 'You selected all duplicates in this group. Please chose at least one duplicate to keep.',
+	],
+
+	'delete-selected' => 'Delete selected',
+];
\ No newline at end of file
diff --git a/lang/zh_TW/maintenance.php b/lang/zh_TW/maintenance.php
index 6a5f95681f0..787ddf6e2dd 100644
--- a/lang/zh_TW/maintenance.php
+++ b/lang/zh_TW/maintenance.php
@@ -20,6 +20,14 @@
 		'description' => 'Remove all contents from <span class="font-mono">%s</span>',
 		'button' => 'Clean',
 	],
+	'duplicate-finder' => [
+		'title' => 'Duplicates',
+		'description' => 'This module counts potential duplicates betwen pictures.',
+		'duplicates-all' => 'Duplicates over all albums',
+		'duplicates-title' => 'Title duplicates per album',
+		'duplicates-per-album' => 'Duplicates per album',
+		'show' => 'Show duplicates',
+	],
 	'fix-jobs' => [
 		'title' => 'Fixing Jobs History',
 		'description' => 'Mark jobs with status <span class="text-ready-400">%s</span> or <span class="text-primary-500">%s</span> as <span class="text-danger-700">%s</span>.',
diff --git a/resources/js/components/maintenance/DuplicateLine.vue b/resources/js/components/maintenance/DuplicateLine.vue
new file mode 100644
index 00000000000..2d87782f4da
--- /dev/null
+++ b/resources/js/components/maintenance/DuplicateLine.vue
@@ -0,0 +1,104 @@
+<template>
+	<div class="hover:bg-primary-emphasis/5">
+		<template v-for="duplicate in props.duplicates.data">
+			<div
+				:class="{
+					'flex justify-between items-center hover:text-color-emphasis': true,
+					'bg-red-700/10': selectedIds.includes(duplicate.photo_id),
+				}"
+				@mouseover="hover(duplicate.url ?? '', duplicate.photo_title)"
+			>
+				<div class="flex-shrink">
+					<i
+						class="pi pi-trash mr-2"
+						:class="{
+							'text-red-700': selectedIds.includes(duplicate.photo_id),
+							'text-transparent': !selectedIds.includes(duplicate.photo_id),
+						}"
+					/>
+				</div>
+				<div class="w-1/3 flex-none flex items-center gap-2 group">
+					<router-link :to="{ name: 'album', params: { albumid: duplicate.album_id } }" target="_blank" class="">
+						<i class="pi pi-link text-primary-emphasis hover:text-primary-emphasis-alt"></i>
+					</router-link>
+					<span
+						class="w-full inline-block whitespace-nowrap text-ellipsis overflow-hidden cursor-pointer"
+						@click="click(duplicate.photo_id)"
+					>
+						{{ duplicate.album_title }}
+					</span>
+				</div>
+				<div class="w-1/3 flex-none flex gap-2 group">
+					<router-link
+						:to="{ name: 'photo', params: { albumid: duplicate.album_id, photoid: duplicate.photo_id } }"
+						target="_blank"
+						class=""
+					>
+						<i class="pi pi-link text-primary-emphasis hover:text-primary-emphasis-alt"></i>
+					</router-link>
+					<span
+						class="w-full inline-block whitespace-nowrap text-ellipsis overflow-hidden cursor-pointer"
+						@click="click(duplicate.photo_id)"
+					>
+						{{ duplicate.photo_title }}
+					</span>
+				</div>
+				<div class="w-1/4 font-mono text-xs cursor-pointer" @click="click(duplicate.photo_id)">{{ duplicate.checksum.slice(0, 12) }}</div>
+			</div>
+		</template>
+	</div>
+</template>
+<script setup lang="ts">
+import { ref, watch } from "vue";
+import { type SplitData } from "@/composables/album/splitter";
+import { useToast } from "primevue/usetoast";
+import { trans } from "laravel-vue-i18n";
+
+const toast = useToast();
+const props = defineProps<{
+	duplicates: SplitData<App.Http.Resources.Models.Duplicates.Duplicate>;
+	selectedIds: string[];
+}>();
+
+const emits = defineEmits<{
+	hover: [src: string, title: string];
+	click: [id: string];
+}>();
+
+function hover(url: string, title: string) {
+	emits("hover", url, title);
+}
+
+function click(id: string) {
+	emits("click", id);
+}
+
+// Warn if all duplicates are selected
+// Because this mean that no original is left
+const warned = ref(false);
+
+watch(
+	() => props.selectedIds,
+	(newSelectedIds) => {
+		if (newSelectedIds.length === 0) {
+			return;
+		}
+
+		if (props.duplicates.data.filter((duplicate) => !newSelectedIds.includes(duplicate.photo_id)).length > 0) {
+			warned.value = false;
+			return;
+		}
+
+		if (warned.value === false) {
+			toast.add({
+				severity: "warn",
+				summary: trans("duplicate-finder.warning.no-original-left"),
+				detail: trans("duplicate-finder.warning.keep-one"),
+				life: 5000,
+			});
+			warned.value = true;
+		}
+	},
+	{ deep: true },
+);
+</script>
diff --git a/resources/js/components/maintenance/MaintenanceDuplicateChecker.vue b/resources/js/components/maintenance/MaintenanceDuplicateChecker.vue
new file mode 100644
index 00000000000..1c868b58f37
--- /dev/null
+++ b/resources/js/components/maintenance/MaintenanceDuplicateChecker.vue
@@ -0,0 +1,60 @@
+<template>
+	<Card
+		class="min-h-40 dark:bg-surface-800 shadow shadow-surface-950/30 rounded-lg relative"
+		pt:body:class="min-h-40 h-full"
+		pt:content:class="h-full flex justify-between flex-col"
+	>
+		<template #title>
+			<div class="text-center">
+				{{ $t("maintenance.duplicate-finder.title") }}
+			</div>
+		</template>
+		<template #content>
+			<div class="w-full h-40 text-sm text-muted-color">
+				<div class="w-full text-left">
+					<h2 class="mb-4">{{ $t("maintenance.duplicate-finder.description") }}</h2>
+					<p v-if="data !== undefined && data.pure_duplicates + data.duplicates_within_album + data.title_duplicates > 0">
+						{{ $t("maintenance.duplicate-finder.duplicates-all") }}: {{ data.pure_duplicates }}<br />
+						{{ $t("maintenance.duplicate-finder.duplicates-title") }}: {{ data.title_duplicates }}<br />
+						{{ $t("maintenance.duplicate-finder.duplicates-per-album") }}: {{ data.duplicates_within_album }}<br />
+					</p>
+					<ProgressSpinner v-if="data === undefined" class="w-full"></ProgressSpinner>
+				</div>
+			</div>
+			<div class="flex gap-4 mt-1">
+				<Button
+					as="router-link"
+					to="/duplicatesFinder"
+					v-if="data !== undefined && data.pure_duplicates"
+					severity="primary"
+					class="w-full border-none self-end"
+					>{{ $t("maintenance.duplicate-finder.show") }}</Button
+				>
+			</div>
+		</template>
+	</Card>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import Button from "primevue/button";
+import Card from "primevue/card";
+import ProgressSpinner from "primevue/progressspinner";
+import MaintenanceService from "@/services/maintenance-service";
+
+const data = ref<App.Http.Resources.Models.Duplicates.DuplicateCount | undefined>(undefined);
+
+function load() {
+	MaintenanceService.getDuplicatesCount().then((response) => {
+		data.value = response.data;
+	});
+}
+
+onMounted(load);
+</script>
+
+<style lang="css" scoped>
+.lychee-dark .p-card {
+	--p-card-background: var(--p-surface-800);
+}
+</style>
diff --git a/resources/js/lychee.d.ts b/resources/js/lychee.d.ts
index 6c7b110fcfb..e6983174a0f 100644
--- a/resources/js/lychee.d.ts
+++ b/resources/js/lychee.d.ts
@@ -481,6 +481,21 @@ declare namespace App.Http.Resources.Models {
 		created_at: string;
 	};
 }
+declare namespace App.Http.Resources.Models.Duplicates {
+	export type Duplicate = {
+		album_id: string;
+		album_title: string;
+		photo_id: string;
+		photo_title: string;
+		checksum: string;
+		url: string | null;
+	};
+	export type DuplicateCount = {
+		pure_duplicates: number;
+		title_duplicates: number;
+		duplicates_within_album: number;
+	};
+}
 declare namespace App.Http.Resources.Models.Utils {
 	export type AlbumProtectionPolicy = {
 		is_public: boolean;
diff --git a/resources/js/router/routes.ts b/resources/js/router/routes.ts
index 45382059464..5af80c3bbeb 100644
--- a/resources/js/router/routes.ts
+++ b/resources/js/router/routes.ts
@@ -16,6 +16,7 @@ const Diagnostics = () => import("@/views/Diagnostics.vue");
 const Statistics = () => import("@/views/Statistics.vue");
 const Jobs = () => import("@/views/Jobs.vue");
 const FixTree = () => import("@/views/FixTree.vue");
+const DuplicatesFinder = () => import("@/views/DuplicatesFinder.vue");
 
 const routes_ = [
 	{
@@ -104,6 +105,11 @@ const routes_ = [
 		path: "/fixTree",
 		component: FixTree,
 	},
+	{
+		name: "duplicates",
+		path: "/duplicatesFinder",
+		component: DuplicatesFinder,
+	},
 	{
 		name: "profile",
 		path: "/profile",
diff --git a/resources/js/services/maintenance-service.ts b/resources/js/services/maintenance-service.ts
index 0b174bba9a8..f334191ab55 100644
--- a/resources/js/services/maintenance-service.ts
+++ b/resources/js/services/maintenance-service.ts
@@ -69,6 +69,25 @@ const MaintenanceService = {
 	updateFullTree(albums: UpdateTreeData[]): Promise<AxiosResponse> {
 		return axios.post(`${Constants.getApiUrl()}Maintenance::fullTree`, { albums: albums });
 	},
+
+	getDuplicatesCount(): Promise<AxiosResponse<App.Http.Resources.Models.Duplicates.DuplicateCount>> {
+		return axios.get(`${Constants.getApiUrl()}Maintenance::countDuplicates`, { data: {} });
+	},
+
+	getDuplicates(
+		withAlbumConstraint: boolean,
+		withChecksumConstraint: boolean,
+		withTitleConstraint: boolean,
+	): Promise<AxiosResponse<App.Http.Resources.Models.Duplicates.Duplicate[]>> {
+		return axios.get(`${Constants.getApiUrl()}Maintenance::searchDuplicates`, {
+			data: {},
+			params: {
+				with_album_constraint: withAlbumConstraint,
+				with_checksum_constraint: withChecksumConstraint,
+				with_title_constraint: withTitleConstraint,
+			},
+		});
+	},
 };
 
 export default MaintenanceService;
diff --git a/resources/js/views/DuplicatesFinder.vue b/resources/js/views/DuplicatesFinder.vue
new file mode 100644
index 00000000000..5c473fc5cdb
--- /dev/null
+++ b/resources/js/views/DuplicatesFinder.vue
@@ -0,0 +1,201 @@
+<template>
+	<ProgressBar v-if="groupedDuplicates === undefined" mode="indeterminate" class="rounded-none absolute w-full" :pt:value:class="'rounded-none'" />
+	<Toolbar class="w-full border-0 h-14">
+		<template #start>
+			<OpenLeftMenu />
+		</template>
+
+		<template #center>
+			{{ $t("duplicate-finder.title") }}
+		</template>
+
+		<template #end> </template>
+	</Toolbar>
+	<div class="text-muted-color text-center mt-2 p-2">
+		<p class="mb-4">
+			{{ $t("duplicate-finder.intro") }}
+		</p>
+		<p v-if="isValid && groupedDuplicates !== undefined">
+			<span class="text-muted-color-emphasis">{{ duplicates?.length }}</span> {{ $t("duplicate-finder.found") }}
+		</p>
+		<p class="text-muted-color-emphasis" v-if="!isValid">
+			<i class="text-warning-700 pi pi-exclamation-triangle mr-2" /> {{ $t("duplicate-finder.invalid-search") }}
+		</p>
+	</div>
+	<div class="text-muted-color">
+		<div class="md:max-w-md mt-2 mb-16 mx-auto">
+			<ul class="mb-4" v-if="is_se_enabled || is_se_preview_enabled">
+				<li class="ml-2 pt-1 flex items-center gap-x-4">
+					<Checkbox
+						v-model="withChecksumConstraint"
+						binary
+						inputId="withChecksumConstraint"
+						:disabled="is_se_preview_enabled"
+						@update:modelValue="fetch"
+					/>
+					<label for="withChecksumConstraint" :class="{ 'text-muted-color-emphasis': isValid, 'text-warning-600': !isValid }">
+						{{ $t("duplicate-finder.checksum-must-match") }}
+					</label>
+				</li>
+				<li class="ml-2 pt-1 flex items-center gap-x-4">
+					<Checkbox
+						v-model="withTitleConstraint"
+						binary
+						inputId="withTitleConstraint"
+						:disabled="is_se_preview_enabled"
+						@update:modelValue="fetch"
+					/>
+					<label for="withTitleConstraint" :class="{ 'text-muted-color-emphasis': isValid, 'text-warning-600': !isValid }">
+						{{ $t("duplicate-finder.title-must-match") }}
+					</label>
+					<SETag />
+				</li>
+				<li class="ml-2 pt-1 flex items-center gap-x-4">
+					<Checkbox
+						v-model="withAlbumConstraint"
+						binary
+						inputId="withAlbumConstraint"
+						:disabled="is_se_preview_enabled"
+						@update:modelValue="fetch"
+					/>
+					<label for="withAlbumConstraint" class="text-muted-color"> {{ $t("duplicate-finder.must-be-in-same-album") }} </label>
+					<SETag />
+				</li>
+			</ul>
+		</div>
+		<div class="flex justify-between md:max-w-3xl lg:max-w-5xl xl:max-w-7xl mx-auto gap-4 xl:gap-8">
+			<div class="w-1/4 flex-none"></div>
+			<div class="pb-2 w-full flex justify-between items-center font-bold text-lg text-color-emphasis border-b border-b-white/50">
+				<div class=""><i class="pi pi-trash text-transparent mr-2" /></div>
+				<div class="w-1/3">{{ $t("duplicate-finder.columns.album") }}</div>
+				<div class="w-1/3">{{ $t("duplicate-finder.columns.photo") }}</div>
+				<div class="w-1/4">{{ $t("duplicate-finder.columns.checksum") }}</div>
+			</div>
+		</div>
+		<div class="flex justify-between md:max-w-3xl lg:max-w-5xl xl:max-w-7xl mx-auto gap-4 xl:gap-8">
+			<div class="w-1/4 flex flex-col flex-none">
+				<Button
+					severity="danger"
+					class="w-full font-bold border-none mb-4"
+					@click="isDeleteVisible = true"
+					:disabled="selectedIds.length === 0"
+					>{{ $t("duplicate-finder.delete-selected") }}</Button
+				>
+
+				<img :src="hoverImgSrc" class="w-full" />
+				<div class="text-center mt-2 font-bold text-muted-color-emphasis">
+					<span class="inline-block w-full text-ellipsis text-nowrap whitespace-nowrap overflow-hidden">{{ hoverTitle }}</span>
+				</div>
+			</div>
+			<VirtualScroller :items="groupedDuplicates" :itemSize="50" class="h-screen w-full">
+				<template v-slot:item="{ item, options }">
+					<DuplicateLine :duplicates="item" :selected-ids="selectedIds" @hover="onHover" @click="onClick" />
+				</template>
+			</VirtualScroller>
+		</div>
+	</div>
+	<DeleteDialog v-model:visible="isDeleteVisible" :parent-id="undefined" :photo-ids="selectedIds" @deleted="onDeleted" />
+</template>
+<script setup lang="ts">
+import { ref, computed, onMounted } from "vue";
+import MaintenanceService from "@/services/maintenance-service";
+import Toolbar from "primevue/toolbar";
+import ProgressBar from "primevue/progressbar";
+import Button from "primevue/button";
+import VirtualScroller from "primevue/virtualscroller";
+import Checkbox from "primevue/checkbox";
+import OpenLeftMenu from "@/components/headers/OpenLeftMenu.vue";
+import DuplicateLine from "@/components/maintenance/DuplicateLine.vue";
+import { useLycheeStateStore } from "@/stores/LycheeState";
+import { storeToRefs } from "pinia";
+import SETag from "@/components/icons/SETag.vue";
+import { type SplitData, useSplitter } from "@/composables/album/splitter";
+import DeleteDialog from "@/components/forms/gallery-dialogs/DeleteDialog.vue";
+
+const duplicates = ref<App.Http.Resources.Models.Duplicates.Duplicate[] | undefined>(undefined);
+const groupedDuplicates = ref<SplitData<App.Http.Resources.Models.Duplicates.Duplicate>[] | undefined>(undefined);
+const isDeleteVisible = ref(false);
+const selectedIds = ref<string[]>([]);
+const lycheeStore = useLycheeStateStore();
+
+const { spliter } = useSplitter();
+
+const withAlbumConstraint = ref(false);
+const withChecksumConstraint = ref(true);
+const withTitleConstraint = ref(false);
+const { is_se_preview_enabled, is_se_enabled } = storeToRefs(lycheeStore);
+
+const hoverImgSrc = ref("");
+const hoverTitle = ref("");
+const isValid = computed(() => withChecksumConstraint.value || withTitleConstraint.value);
+
+function onHover(src: string, title: string) {
+	hoverImgSrc.value = src;
+	hoverTitle.value = title;
+}
+
+function onClick(id: string) {
+	const index = selectedIds.value.indexOf(id);
+	if (index === -1) {
+		selectedIds.value.push(id);
+	} else {
+		selectedIds.value.splice(index, 1);
+	}
+}
+
+function onDeleted() {
+	// Remove the deleted photos (no need to fetch data again).
+	duplicates.value = duplicates.value?.filter((duplicate) => !selectedIds.value.includes(duplicate.photo_id));
+	groupData();
+
+	// Remove groups with only one element => no duplicates
+	groupedDuplicates.value = groupedDuplicates.value?.filter((group) => group.data.length > 1);
+}
+
+function fetch() {
+	if (!isValid.value) {
+		return;
+	}
+
+	duplicates.value = undefined;
+	groupedDuplicates.value = undefined;
+	selectedIds.value = [];
+	hoverImgSrc.value = "";
+	hoverTitle.value = "";
+
+	MaintenanceService.getDuplicates(withAlbumConstraint.value, withChecksumConstraint.value, withTitleConstraint.value).then((response) => {
+		duplicates.value = response.data;
+		groupData();
+	});
+}
+
+function groupData() {
+	if (duplicates.value === undefined) {
+		return;
+	}
+
+	if (withChecksumConstraint.value) {
+		groupedDuplicates.value = spliter(
+			duplicates.value,
+			(a) => a.checksum,
+			(a) => a.checksum,
+		);
+	} else {
+		groupedDuplicates.value = spliter(
+			duplicates.value,
+			(a) => a.photo_title,
+			(a) => a.photo_title,
+		);
+	}
+}
+
+onMounted(() => {
+	fetch();
+});
+</script>
+<style lang="css">
+/* Kill the border of ScrollTop */
+.p-scrolltop {
+	border: none;
+}
+</style>
diff --git a/resources/js/views/Maintenance.vue b/resources/js/views/Maintenance.vue
index 4ac32223962..ef7cbeacae3 100644
--- a/resources/js/views/Maintenance.vue
+++ b/resources/js/views/Maintenance.vue
@@ -18,6 +18,7 @@
 	>
 		<MaintenanceUpdate />
 		<MaintenanceOptimize />
+		<MaintenanceDuplicateChecker />
 		<MaintenanceFlushCache />
 		<MaintenanceGenSizevariants :sv="1" />
 		<MaintenanceGenSizevariants :sv="2" />
@@ -38,6 +39,7 @@ import MaintenanceCleaning from "@/components/maintenance/MaintenanceCleaning.vu
 import MaintenanceFilesize from "@/components/maintenance/MaintenanceFilesize.vue";
 import MaintenanceFixJobs from "@/components/maintenance/MaintenanceFixJobs.vue";
 import MaintenanceFixTree from "@/components/maintenance/MaintenanceFixTree.vue";
+import MaintenanceDuplicateChecker from "@/components/maintenance/MaintenanceDuplicateChecker.vue";
 import MaintenanceGenSizevariants from "@/components/maintenance/MaintenanceGenSizevariants.vue";
 import MaintenanceOptimize from "@/components/maintenance/MaintenanceOptimize.vue";
 import MaintenanceUpdate from "@/components/maintenance/MaintenanceUpdate.vue";


Test patch: