diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py
index 976dc14..b30668c 100644
--- a/backend/app/routers/library.py
+++ b/backend/app/routers/library.py
@@ -733,6 +733,7 @@ async def get_duplicate_groups(
select(
Photo.id,
Photo.filename,
+ Photo.filepath,
Photo.taken_at,
Photo.file_size,
Photo.width,
@@ -751,22 +752,25 @@ async def get_duplicate_groups(
)
).all()
- # Bucket members by group_id.
+ # Bucket members by group_id. filepath is included so the
+ # Duplicates view can show "which folder does this copy live in"
+ # — the discriminator the user needs to pick a winner.
groups: dict[str, list[dict]] = {}
for row in rows:
member = {
"id": row[0],
"filename": row[1],
- "taken_at": row[2].isoformat() if row[2] else None,
- "file_size": row[3],
- "width": row[4],
- "height": row[5],
- "thumb_small": row[6],
- "file_hash": row[7],
- "folder_id": row[8],
- "media_type": row[9],
+ "filepath": row[2],
+ "taken_at": row[3].isoformat() if row[3] else None,
+ "file_size": row[4],
+ "width": row[5],
+ "height": row[6],
+ "thumb_small": row[7],
+ "file_hash": row[8],
+ "folder_id": row[9],
+ "media_type": row[10],
}
- groups.setdefault(row[10], []).append(member)
+ groups.setdefault(row[11], []).append(member)
def earliest(g: list[dict]) -> str:
# Used as a secondary sort key. Photos with no taken_at sort last
diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx
index 91555bf..4d3264f 100644
--- a/frontend/src/components/duplicates/DuplicatesView.tsx
+++ b/frontend/src/components/duplicates/DuplicatesView.tsx
@@ -450,19 +450,28 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
Keep this
)}
- {/* Dimensions chip — bottom-LEFT. Neutral metadata variant
- * matches the family. Rare collision with a manual rating
- * (also bottom-left) is tolerated: rated duplicates are
- * uncommon in practice. */}
+ {/* Dimensions chip — top-LEFT (Best / Keep this lives
+ * top-right; bottom is reserved for the path strip). */}
{formatDimensions(member)}
+ {/* Path strip — full-width across the bottom. Shows the
+ * parent folder name (the actual discriminator when two
+ * copies share the same filename), truncating from the
+ * right if necessary. Full filepath surfaces via the
+ * native tooltip on hover. */}
+
+ {duplicatePathLabel(photoByMemberId.get(member.id)!.filepath)}
+
)
})}
@@ -505,6 +514,23 @@ function formatBytes(n: number): string {
return `${n}B`
}
+/** Short label for the bottom path strip on a duplicate thumbnail.
+ * Goal: tell two copies-with-the-same-filename apart at a glance, so
+ * we want the parent folder name — that's almost always the
+ * discriminator (different shoots, different years, different
+ * upload sources). When the parent folder is unhelpful (e.g. the
+ * filename is itself sitting at the root) we fall back to the file
+ * basename. Long folder names get text-overflow-ellipsis'd by the
+ * enclosing strip's `truncate`. */
+function duplicatePathLabel(filepath: string): string {
+ if (!filepath) return ''
+ const parts = filepath.split('/').filter(Boolean)
+ if (parts.length === 0) return filepath
+ if (parts.length === 1) return parts[0]
+ // The second-to-last segment IS the parent directory.
+ return parts[parts.length - 2]
+}
+
/** Adapt a DuplicateGroupMember (the slim API shape) to a Photo, which
* is what PhotoThumbnail expects. We deliberately set is_duplicate=false
* on the synthetic Photo so the duplicate badge isn't drawn on every
@@ -512,7 +538,7 @@ function formatBytes(n: number): string {
function memberToPhoto(m: DuplicateGroupMember): Photo {
return {
id: m.id,
- filepath: m.filename, // good enough for the RAW/video extension regex
+ filepath: m.filepath,
filename: m.filename,
media_type: m.media_type,
width: m.width,
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 5c77d73..ee0f1eb 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -538,6 +538,10 @@ export const library = {
export interface DuplicateGroupMember {
id: string
filename: string
+ /** Full path of the file on disk. Powers the path strip in the
+ * Duplicates view so the user can tell two same-named-different-
+ * folder copies apart at a glance. */
+ filepath: string
taken_at: string | null
file_size: number | null
width: number | null