Skip to content

Commit 2fdc7fc

Browse files
zhogovmtalcott
andauthored
Deduplicate media items by dedupKey (#126)
* Deduplicate media items by dedupKey There can be photos with different mediaKeys but same dedupKey, likely caused by saving shared albums. We should not solve for them, since - Google Photos natively collapses them into one in timeline/albums - API doesn't allow to trash one of them without trashing others So keep only the first item we see for each dedupKey. * Fix totalItems/log to report raw item count, not post-dedup count mediaItems was reassigned to the deduped set, so ScanTiming.totalItems and the "[GPD] detectDuplicates" log line silently started meaning "items after dedup" instead of "items scanned". Use a separate dedupedItems binding for candidate filtering so totalItems keeps its original meaning while duplicate detection still only runs on the deduped set. Claude-Session: https://claude.ai/code/session_01EwfkVcjRcCwgv9oMRhnMqu * Rename dropDuplicateDedupKeys to dedupeByDedupKey; tidy test formatting Claude-Session: https://claude.ai/code/session_01EwfkVcjRcCwgv9oMRhnMqu --------- Co-authored-by: Mack Talcott <mack@tal.co.tt>
1 parent 326ba1a commit 2fdc7fc

2 files changed

Lines changed: 66 additions & 3 deletions

File tree

lib/duplicate-detector.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ export function selectDefaultKeep(items: GpdMediaItem[]): string {
2929
return best[0].mediaKey;
3030
}
3131

32+
/**
33+
* Deduplicate media items by dedupKey.
34+
* There can be photos with different mediaKeys but same dedupKey,
35+
* likely caused by saving shared albums.
36+
* We should not solve for them, since
37+
* - Google Photos natively collapses them into one in timeline/albums
38+
* - API doesn't allow to trash one of them without trashing others
39+
* So keep only the first item we see for each dedupKey.
40+
*/
41+
export function dedupeByDedupKey(items: GpdMediaItem[]): GpdMediaItem[] {
42+
const seen = new Set<string>();
43+
const uniqueItems: GpdMediaItem[] = [];
44+
for (const item of items) {
45+
if (item.dedupKey) {
46+
if (seen.has(item.dedupKey)) continue;
47+
seen.add(item.dedupKey);
48+
}
49+
uniqueItems.push(item);
50+
}
51+
return uniqueItems;
52+
}
53+
3254
const MODEL_URL =
3355
"https://storage.googleapis.com/mediapipe-models/image_embedder/mobilenet_v3_large/float32/latest/mobilenet_v3_large.tflite";
3456

@@ -160,11 +182,13 @@ export async function fullDetectDuplicates(
160182
): Promise<{ groups: DuplicateGroup[]; timing: ScanTiming }> {
161183
const scanStart = performance.now();
162184

185+
const dedupedItems = dedupeByDedupKey(mediaItems);
186+
163187
// Filter to items with thumbnails (photos only, skip videos)
164188
// Include items with thumbnails. Video posters work too — two copies of the
165189
// same clip have identical poster frames, which produce near-identical
166190
// embeddings.
167-
const candidates = mediaItems.filter((item) => item.thumb);
191+
const candidates = dedupedItems.filter((item) => item.thumb);
168192
console.log(
169193
`[GPD] detectDuplicates: ${mediaItems.length} items → ${candidates.length} candidates`,
170194
);
@@ -466,10 +490,13 @@ export async function smartDetectDuplicates(
466490
logger?: ScanLogger,
467491
): Promise<DuplicateGroup[]> {
468492
const scanStart = performance.now();
493+
494+
const dedupedItems = dedupeByDedupKey(mediaItems);
495+
469496
// Include items with thumbnails. Video posters work too — two copies of the
470497
// same clip have identical poster frames, which produce near-identical
471498
// embeddings.
472-
const candidates = mediaItems.filter((item) => item.thumb);
499+
const candidates = dedupedItems.filter((item) => item.thumb);
473500

474501
// Step 1: Bucket by timestamp — no I/O, instant
475502
const buckets = groupByTimestamp(candidates, windowMs);

tests/lib/duplicate-detector.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest"
2-
import { communityDetection, matMul, topK, groupByTimestamp, withinGroupDuplicates, selectDefaultKeep, fullDetectDuplicates } from "../../lib/duplicate-detector"
2+
import { communityDetection, matMul, topK, groupByTimestamp, withinGroupDuplicates, selectDefaultKeep, fullDetectDuplicates, dedupeByDedupKey } from "../../lib/duplicate-detector"
33
import type { GpdMediaItem } from "../../lib/types"
44

55
// ============================================================
@@ -535,3 +535,39 @@ describe("selectDefaultKeep", () => {
535535
expect(["a", "b"]).toContain(result)
536536
})
537537
})
538+
539+
// ============================================================
540+
// dedupeByDedupKey
541+
// ============================================================
542+
543+
describe("dedupeByDedupKey", () => {
544+
it("keeps items with unique dedupKeys", () => {
545+
const a = makeItem("media1", 1000)
546+
a.dedupKey = "dedup1"
547+
const b = makeItem("media2", 1000)
548+
b.dedupKey = "dedup2"
549+
550+
const result = dedupeByDedupKey([a, b])
551+
expect(result).toHaveLength(2)
552+
expect(result).toContain(a)
553+
expect(result).toContain(b)
554+
})
555+
556+
it("keeps only the first item with a duplicated dedupKey", () => {
557+
const a = makeItem("media1", 1000)
558+
a.dedupKey = "shared-dedup"
559+
const b = makeItem("media2", 1000)
560+
b.dedupKey = "shared-dedup"
561+
const c = makeItem("media3", 1000)
562+
c.dedupKey = "other-dedup"
563+
564+
const result = dedupeByDedupKey([a, b, c])
565+
expect(result).toHaveLength(2)
566+
expect(result[0]).toBe(a)
567+
expect(result[1]).toBe(c)
568+
})
569+
570+
it("handles empty arrays", () => {
571+
expect(dedupeByDedupKey([])).toEqual([])
572+
})
573+
})

0 commit comments

Comments
 (0)