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