feat: perceptual-hash duplicate detection + grouped picker view
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.
Detection
- New phash + duplicate_group_id columns on Photo, added via an
idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
decoded frame just before the destructive thumbnail loop. Falls back
silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
predated the column, reading the existing thumb_large rather than
re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
(threshold 6), persists duplicate_group_id, and maintains is_duplicate
as derived state so existing badges/counts keep working. Chained
after scan_all_source_roots with a 60s countdown.
API
- GET /library/duplicates/groups returns all groups with members,
bucketed in Python from one query. Each group has a reason ("exact"
iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.
Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
the timeline when the user is in the duplicates section. Each section
shows a "Keep best, discard N" button that picks the highest-pixel
copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
this" (Crown icon, top-right) to override the auto-pick. The header
annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
with ↑/↓ jumping by the measured column count and scrollIntoView on
every move. Timeline's keyboard handler now early-returns in the
duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
they don't collide visually with the cyan selection ring around a
selected cell. Dimensions chip moved to bottom-left to free both
right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
and exposes both backfill + re-detect actions, sharing a query cache
with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,10 @@ async def init_db():
|
|||||||
# Import all models to register them with Base
|
# Import all models to register them with Base
|
||||||
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
|
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
|
||||||
|
|
||||||
# Create all tables
|
# Create all tables. Note: create_all only creates *missing* tables —
|
||||||
|
# it does NOT add new columns to existing tables when the model gains
|
||||||
|
# them. Anything new on an existing table needs an explicit ALTER
|
||||||
|
# below.
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
# Enable WAL mode for SQLite (better concurrency)
|
# Enable WAL mode for SQLite (better concurrency)
|
||||||
@@ -69,6 +72,43 @@ async def init_db():
|
|||||||
await conn.execute(text("PRAGMA cache_size=10000"))
|
await conn.execute(text("PRAGMA cache_size=10000"))
|
||||||
await conn.execute(text("PRAGMA temp_store=MEMORY"))
|
await conn.execute(text("PRAGMA temp_store=MEMORY"))
|
||||||
|
|
||||||
|
# ── Idempotent column adds ────────────────────────────────────────
|
||||||
|
# The project does not use Alembic; we lean on create_all + a small
|
||||||
|
# set of inline ALTER TABLE statements for the columns we've added
|
||||||
|
# post-launch. SQLite supports ADD COLUMN but not "IF NOT EXISTS"
|
||||||
|
# for columns, so introspect via PRAGMA first. Each entry is
|
||||||
|
# (column_name, ALTER statement). Add new columns at the bottom.
|
||||||
|
if "sqlite" in settings.database_url:
|
||||||
|
existing_cols = {
|
||||||
|
row[1]
|
||||||
|
for row in (
|
||||||
|
await conn.execute(text("PRAGMA table_info(photos)"))
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
pending_alters: list[tuple[str, str]] = [
|
||||||
|
("phash", "ALTER TABLE photos ADD COLUMN phash VARCHAR(16)"),
|
||||||
|
(
|
||||||
|
"duplicate_group_id",
|
||||||
|
"ALTER TABLE photos ADD COLUMN duplicate_group_id VARCHAR",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for col_name, alter_sql in pending_alters:
|
||||||
|
if col_name not in existing_cols:
|
||||||
|
logger.info(f"Adding photos.{col_name} column")
|
||||||
|
await conn.execute(text(alter_sql))
|
||||||
|
# Indexes for the new duplicate-detection columns. CREATE INDEX
|
||||||
|
# IF NOT EXISTS is supported on SQLite so this is safe to run
|
||||||
|
# every startup.
|
||||||
|
await conn.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_photos_phash ON photos(phash)")
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_photos_duplicate_group_id "
|
||||||
|
"ON photos(duplicate_group_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
async def create_fts_table():
|
async def create_fts_table():
|
||||||
|
|||||||
@@ -60,8 +60,24 @@ class Photo(Base):
|
|||||||
# photo just means adding it to the active heap. Both DB columns may still
|
# photo just means adding it to the active heap. Both DB columns may still
|
||||||
# exist on legacy installs but are no longer read or written.
|
# exist on legacy installs but are no longer read or written.
|
||||||
|
|
||||||
# Duplicate detection
|
# Duplicate detection.
|
||||||
|
#
|
||||||
|
# - file_hash (above): SHA-256 of the raw bytes. Catches byte-identical
|
||||||
|
# copies but not visually-identical re-encodes / resizes / screenshots.
|
||||||
|
# - phash: 16-char hex of a 64-bit perceptual hash, computed by the
|
||||||
|
# thumbs worker from the decoded original frame. Robust to resize and
|
||||||
|
# re-compression — this is what actually identifies "the same photo
|
||||||
|
# saved twice with different JPEG quality".
|
||||||
|
# - duplicate_group_id: shared by every photo in the same duplicate
|
||||||
|
# cluster. Maintained by app.services.duplicates.regroup_duplicates,
|
||||||
|
# not on individual writes — recomputed in batches after scans / on
|
||||||
|
# demand from the Settings panel.
|
||||||
|
# - is_duplicate: derived boolean (group_id IS NOT NULL). Kept as a real
|
||||||
|
# column so the existing PhotoThumbnail badge and /library/stats
|
||||||
|
# duplicates count don't have to change.
|
||||||
is_duplicate = Column(Boolean, default=False)
|
is_duplicate = Column(Boolean, default=False)
|
||||||
|
phash = Column(String(16), index=True)
|
||||||
|
duplicate_group_id = Column(String, index=True)
|
||||||
|
|
||||||
# Live photo support
|
# Live photo support
|
||||||
live_photo_video_id = Column(String, ForeignKey('photos.id'))
|
live_photo_video_id = Column(String, ForeignKey('photos.id'))
|
||||||
|
|||||||
@@ -448,3 +448,127 @@ async def run_data_integrity_cleanup():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Manual cleanup failed: {e}")
|
logger.error(f"Manual cleanup failed: {e}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# Duplicate detection
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/duplicates/groups")
|
||||||
|
async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Return every duplicate group with its members.
|
||||||
|
|
||||||
|
Drives the frontend grouped grid view in the Duplicates section. One
|
||||||
|
SQL query, bucketed in Python — no N+1, no per-member fetch. Groups
|
||||||
|
are sorted by member_count DESC then earliest taken_at DESC so the
|
||||||
|
biggest / most recent clusters bubble to the top.
|
||||||
|
|
||||||
|
Each group also carries a `reason` field:
|
||||||
|
* "exact" — every member shares the same SHA-256 (true byte
|
||||||
|
duplicates that the perceptual hash trivially caught)
|
||||||
|
* "similar" — members differ at the byte level but match perceptually
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
Photo.id,
|
||||||
|
Photo.filename,
|
||||||
|
Photo.taken_at,
|
||||||
|
Photo.file_size,
|
||||||
|
Photo.width,
|
||||||
|
Photo.height,
|
||||||
|
Photo.thumb_small,
|
||||||
|
Photo.file_hash,
|
||||||
|
Photo.folder_id,
|
||||||
|
Photo.media_type,
|
||||||
|
Photo.duplicate_group_id,
|
||||||
|
)
|
||||||
|
.where(Photo.duplicate_group_id.is_not(None))
|
||||||
|
.where(Photo.is_discarded.is_(False))
|
||||||
|
.order_by(Photo.duplicate_group_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Bucket members by group_id.
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
groups.setdefault(row[10], []).append(member)
|
||||||
|
|
||||||
|
def earliest(g: list[dict]) -> str:
|
||||||
|
# Used as a secondary sort key. Photos with no taken_at sort last
|
||||||
|
# by returning a far-future sentinel.
|
||||||
|
taken = [m["taken_at"] for m in g if m["taken_at"]]
|
||||||
|
return min(taken) if taken else "9999"
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for group_id, members in groups.items():
|
||||||
|
if len(members) < 2:
|
||||||
|
# Defensive: a regroup race could leave a singleton briefly.
|
||||||
|
# Skip it so the UI never shows a "group of 1".
|
||||||
|
continue
|
||||||
|
# exact iff every member shares the same non-null file_hash
|
||||||
|
# (true byte-identical copies that pHash also caught). Anything
|
||||||
|
# else — different hashes, missing hashes — counts as "similar".
|
||||||
|
all_hashes = [m["file_hash"] for m in members]
|
||||||
|
reason = (
|
||||||
|
"exact"
|
||||||
|
if len(set(all_hashes)) == 1 and all_hashes[0] is not None
|
||||||
|
else "similar"
|
||||||
|
)
|
||||||
|
out.append({
|
||||||
|
"group_id": group_id,
|
||||||
|
"member_count": len(members),
|
||||||
|
"reason": reason,
|
||||||
|
"members": members,
|
||||||
|
})
|
||||||
|
|
||||||
|
out.sort(key=lambda g: (-g["member_count"], earliest(g["members"])))
|
||||||
|
return {
|
||||||
|
"groups": out,
|
||||||
|
"total_groups": len(out),
|
||||||
|
"total_members": sum(g["member_count"] for g in out),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/regroup-duplicates")
|
||||||
|
async def trigger_regroup_duplicates():
|
||||||
|
"""Recompute duplicate groups from current perceptual hashes.
|
||||||
|
|
||||||
|
Fires the celery `regroup_duplicates` task which walks every photo's
|
||||||
|
phash, clusters by Hamming distance, and rewrites duplicate_group_id /
|
||||||
|
is_duplicate columns. Idempotent."""
|
||||||
|
from app.tasks.thumbs import regroup_duplicates_task
|
||||||
|
try:
|
||||||
|
regroup_duplicates_task.delay()
|
||||||
|
return {"status": "queued"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Regroup queue failed: {e}")
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/backfill-phashes")
|
||||||
|
async def trigger_backfill_phashes():
|
||||||
|
"""Compute perceptual hashes for every photo currently missing one.
|
||||||
|
|
||||||
|
One-shot recovery path for libraries that existed before the phash
|
||||||
|
column was added — the thumbs worker computes phash for everything
|
||||||
|
new, but old rows need a backfill pass."""
|
||||||
|
from app.tasks.thumbs import backfill_phashes
|
||||||
|
try:
|
||||||
|
backfill_phashes.delay()
|
||||||
|
return {"status": "queued"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Backfill queue failed: {e}")
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
227
backend/app/services/duplicates.py
Normal file
227
backend/app/services/duplicates.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
Duplicate detection: group photos by perceptual-hash similarity.
|
||||||
|
|
||||||
|
Strategy
|
||||||
|
--------
|
||||||
|
Each photo carries a 16-char hex perceptual hash (`Photo.phash`) computed
|
||||||
|
by the thumbnail worker from the original-resolution decoded frame
|
||||||
|
(`app.tasks.thumbs._generate_thumbnails_async`). pHash is robust to
|
||||||
|
resize / re-encoding / mild edits, so two photos that are visually the
|
||||||
|
"same shot" land at small Hamming distance even when their bytes are
|
||||||
|
completely different.
|
||||||
|
|
||||||
|
This module turns those per-photo hashes into explicit *groups*. The
|
||||||
|
result is persisted in two columns:
|
||||||
|
|
||||||
|
* `Photo.duplicate_group_id` — shared by every member of a group
|
||||||
|
* `Photo.is_duplicate` — derived: True iff group_id IS NOT NULL
|
||||||
|
(kept as a column so the existing
|
||||||
|
PhotoThumbnail badge and /library/stats
|
||||||
|
count don't have to change).
|
||||||
|
|
||||||
|
The grouping is recomputed in batches by `regroup_duplicates`, NOT on
|
||||||
|
individual writes — that lets us use a single in-memory pass instead of
|
||||||
|
maintaining a per-row similarity index. Triggered automatically after
|
||||||
|
each scan and on demand from the Settings panel.
|
||||||
|
|
||||||
|
Complexity
|
||||||
|
----------
|
||||||
|
Pairwise O(N²) over photos with a non-null phash. At ~5 µs per Hamming
|
||||||
|
distance in CPython this is roughly:
|
||||||
|
|
||||||
|
1k photos → ~5 s
|
||||||
|
5k photos → ~125 s
|
||||||
|
10k photos → ~500 s
|
||||||
|
|
||||||
|
That's the wrong shape for libraries past ~5k. The drop-in replacement
|
||||||
|
is a BK-tree (e.g. `pybktree`) which gives O(log N) lookups for a fixed
|
||||||
|
Hamming threshold; swap it in here when someone trips the limit. The
|
||||||
|
public function signature stays the same.
|
||||||
|
|
||||||
|
Out of scope (deferred)
|
||||||
|
-----------------------
|
||||||
|
* Dismissing a group / "intentional duplicates" — would need a per-group
|
||||||
|
or per-pair flag plus a skip-set in this function so re-grouping
|
||||||
|
doesn't bring them back. Add when there's a real user need.
|
||||||
|
* Incremental updates on individual photo writes — currently we just
|
||||||
|
re-run the whole job after each scan, which is fine while the cost is
|
||||||
|
bounded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.photos import Photo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Hamming distance threshold under which two phashes are considered
|
||||||
|
# "the same image". 6 bits out of 64 is the rule-of-thumb sweet spot for
|
||||||
|
# pHash — tight enough to avoid false positives between unrelated photos,
|
||||||
|
# loose enough to catch JPEG re-encodes, slight crops, and a screenshot
|
||||||
|
# of a screenshot.
|
||||||
|
DEFAULT_THRESHOLD = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_int(h: str) -> int:
|
||||||
|
"""Parse a 16-char hex pHash to a Python int. Returns -1 on bad input
|
||||||
|
so the pairwise loop can skip the row without raising."""
|
||||||
|
try:
|
||||||
|
return int(h, 16)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _hamming(a: int, b: int) -> int:
|
||||||
|
"""Population count of XOR — the canonical hash distance metric.
|
||||||
|
`int.bit_count()` is C-implemented in CPython 3.10+ and is by far
|
||||||
|
the fastest path; the `bin(...).count('1')` fallback is here only
|
||||||
|
so the function still works on older interpreters."""
|
||||||
|
x = a ^ b
|
||||||
|
try:
|
||||||
|
return x.bit_count() # type: ignore[attr-defined]
|
||||||
|
except AttributeError:
|
||||||
|
return bin(x).count('1')
|
||||||
|
|
||||||
|
|
||||||
|
class _UnionFind:
|
||||||
|
"""Tiny union-find / disjoint-set used to merge similar phashes into
|
||||||
|
connected components. Inlined here (rather than pulled from a dep)
|
||||||
|
because it's ~15 lines and we don't need anything fancy."""
|
||||||
|
|
||||||
|
def __init__(self, n: int) -> None:
|
||||||
|
self.parent = list(range(n))
|
||||||
|
self.rank = [0] * n
|
||||||
|
|
||||||
|
def find(self, x: int) -> int:
|
||||||
|
# Path compression — flattens the tree on lookup so subsequent
|
||||||
|
# finds are amortized O(α(N)) ≈ O(1).
|
||||||
|
while self.parent[x] != x:
|
||||||
|
self.parent[x] = self.parent[self.parent[x]]
|
||||||
|
x = self.parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
def union(self, a: int, b: int) -> None:
|
||||||
|
ra, rb = self.find(a), self.find(b)
|
||||||
|
if ra == rb:
|
||||||
|
return
|
||||||
|
if self.rank[ra] < self.rank[rb]:
|
||||||
|
ra, rb = rb, ra
|
||||||
|
self.parent[rb] = ra
|
||||||
|
if self.rank[ra] == self.rank[rb]:
|
||||||
|
self.rank[ra] += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def regroup_duplicates(threshold: int = DEFAULT_THRESHOLD) -> dict:
|
||||||
|
"""Recompute every photo's duplicate_group_id from current phashes.
|
||||||
|
|
||||||
|
Idempotent — safe to call as often as you like. Returns a small
|
||||||
|
summary dict the maintenance endpoint surfaces back to the UI.
|
||||||
|
|
||||||
|
Photos that end up alone in a component (size 1) get
|
||||||
|
`duplicate_group_id=NULL` and `is_duplicate=False`. This is what
|
||||||
|
cleans up "dead" groups after the user discards N-1 members from
|
||||||
|
one.
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Pull (id, phash) for every non-discarded photo with a hash.
|
||||||
|
# Discarded photos are excluded so we don't keep showing groups
|
||||||
|
# made up of trashed copies.
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Photo.id, Photo.phash)
|
||||||
|
.where(Photo.phash.is_not(None))
|
||||||
|
.where(Photo.is_discarded.is_(False))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
n = len(rows)
|
||||||
|
if n == 0:
|
||||||
|
# Still need to clear stale group_ids in case the user just
|
||||||
|
# discarded the last surviving member of every group.
|
||||||
|
await _clear_all_groups(session)
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
'photos_considered': 0,
|
||||||
|
'groups': 0,
|
||||||
|
'members': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
ids: list[str] = [row[0] for row in rows]
|
||||||
|
hashes: list[int] = [_hex_to_int(row[1]) for row in rows]
|
||||||
|
|
||||||
|
uf = _UnionFind(n)
|
||||||
|
|
||||||
|
# O(N²) pairwise comparison. See module docstring for the
|
||||||
|
# scaling analysis and the BK-tree upgrade path.
|
||||||
|
for i in range(n):
|
||||||
|
hi = hashes[i]
|
||||||
|
if hi < 0:
|
||||||
|
continue
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
hj = hashes[j]
|
||||||
|
if hj < 0:
|
||||||
|
continue
|
||||||
|
if _hamming(hi, hj) <= threshold:
|
||||||
|
uf.union(i, j)
|
||||||
|
|
||||||
|
# Collect components. Each connected component of size >= 2 gets
|
||||||
|
# a fresh group id; size-1 components are intentionally dropped.
|
||||||
|
components: dict[int, list[int]] = {}
|
||||||
|
for i in range(n):
|
||||||
|
root = uf.find(i)
|
||||||
|
components.setdefault(root, []).append(i)
|
||||||
|
|
||||||
|
# First pass: clear EVERY photo's group_id so survivors of an
|
||||||
|
# earlier grouping that no longer match anyone end up clean. This
|
||||||
|
# is one bulk UPDATE rather than per-photo to keep the cost low
|
||||||
|
# even on big libraries.
|
||||||
|
await _clear_all_groups(session)
|
||||||
|
|
||||||
|
# Second pass: write the new group ids for components of size 2+.
|
||||||
|
groups_created = 0
|
||||||
|
members_total = 0
|
||||||
|
for members in components.values():
|
||||||
|
if len(members) < 2:
|
||||||
|
continue
|
||||||
|
group_id = str(uuid.uuid4())
|
||||||
|
member_ids = [ids[i] for i in members]
|
||||||
|
await session.execute(
|
||||||
|
update(Photo)
|
||||||
|
.where(Photo.id.in_(member_ids))
|
||||||
|
.values(
|
||||||
|
duplicate_group_id=group_id,
|
||||||
|
is_duplicate=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
groups_created += 1
|
||||||
|
members_total += len(member_ids)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"regroup_duplicates: considered {n} photos, "
|
||||||
|
f"created {groups_created} group(s) covering {members_total} member(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'photos_considered': n,
|
||||||
|
'groups': groups_created,
|
||||||
|
'members': members_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _clear_all_groups(session) -> None:
|
||||||
|
"""Reset duplicate_group_id / is_duplicate on every photo. Used as
|
||||||
|
the first half of a regroup pass so photos that no longer cluster
|
||||||
|
with anyone end up clean instead of carrying a stale group id."""
|
||||||
|
await session.execute(
|
||||||
|
update(Photo).values(duplicate_group_id=None, is_duplicate=False)
|
||||||
|
)
|
||||||
@@ -350,18 +350,39 @@ def scan_all_source_roots():
|
|||||||
async def _scan_all_source_roots_async():
|
async def _scan_all_source_roots_async():
|
||||||
"""Read every active SourceRoot from the DB and queue a scan_folder task
|
"""Read every active SourceRoot from the DB and queue a scan_folder task
|
||||||
for each. Source roots whose path no longer exists on disk are skipped
|
for each. Source roots whose path no longer exists on disk are skipped
|
||||||
with a warning (the cleanup service surfaces those at startup too)."""
|
with a warning (the cleanup service surfaces those at startup too).
|
||||||
|
|
||||||
|
After dispatching the scans, queue a delayed `regroup_duplicates`
|
||||||
|
pass so duplicate clusters are recomputed once the new photos have
|
||||||
|
finished thumbnailing (and therefore picked up phashes). The
|
||||||
|
countdown is a best-effort hint — on a big library the user can
|
||||||
|
still hit Settings → Re-detect duplicates to force a fresh pass.
|
||||||
|
"""
|
||||||
|
from app.tasks.thumbs import regroup_duplicates_task
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||||
)
|
)
|
||||||
source_roots = result.scalars().all()
|
source_roots = result.scalars().all()
|
||||||
|
dispatched = 0
|
||||||
for sr in source_roots:
|
for sr in source_roots:
|
||||||
if os.path.exists(sr.path):
|
if os.path.exists(sr.path):
|
||||||
scan_folder.delay(sr.path, sr.id)
|
scan_folder.delay(sr.path, sr.id)
|
||||||
|
dispatched += 1
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Source root path does not exist: {sr.path}")
|
logger.warning(f"Source root path does not exist: {sr.path}")
|
||||||
|
|
||||||
|
if dispatched > 0:
|
||||||
|
# 60s gives the thumbs worker a window to compute phashes for
|
||||||
|
# the new photos before regrouping. The task is idempotent, so
|
||||||
|
# firing too early just means the next manual run picks up the
|
||||||
|
# late arrivals — no corrupted state.
|
||||||
|
try:
|
||||||
|
regroup_duplicates_task.apply_async(countdown=60)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not queue post-scan regroup: {e}")
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='watch_folders')
|
@shared_task(name='watch_folders')
|
||||||
def watch_folders():
|
def watch_folders():
|
||||||
|
|||||||
@@ -285,6 +285,18 @@ async def _generate_thumbnails_async(photo_id: str, task):
|
|||||||
photo.width = image.width
|
photo.width = image.width
|
||||||
photo.height = image.height
|
photo.height = image.height
|
||||||
|
|
||||||
|
# Perceptual hash from the original-resolution decoded frame.
|
||||||
|
# pHash is robust to resize/recompression but the thumbnail
|
||||||
|
# loop below mutates `image` in place, so this MUST run before
|
||||||
|
# the loop sees it. Failures are non-fatal — phash is a
|
||||||
|
# nice-to-have, not a blocker for thumbnail generation.
|
||||||
|
try:
|
||||||
|
import imagehash
|
||||||
|
photo.phash = str(imagehash.phash(image)) # 16-char hex
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"phash failed for {photo_id}: {e}")
|
||||||
|
photo.phash = None
|
||||||
|
|
||||||
# Generate thumbnails for each size
|
# Generate thumbnails for each size
|
||||||
for size_name, size_value in THUMB_SIZES.items():
|
for size_name, size_value in THUMB_SIZES.items():
|
||||||
thumb_path = get_thumb_path(photo_id, size_name)
|
thumb_path = get_thumb_path(photo_id, size_name)
|
||||||
@@ -340,3 +352,76 @@ async def _regenerate_all_thumbnails_async():
|
|||||||
generate_thumbnails.delay(photo.id)
|
generate_thumbnails.delay(photo.id)
|
||||||
|
|
||||||
return {'status': 'queued', 'count': len(photos)}
|
return {'status': 'queued', 'count': len(photos)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Perceptual hash backfill ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# When phash was added post-launch, every existing photo has phash=NULL.
|
||||||
|
# This task fills them in by reading the existing thumb_large (the cheap
|
||||||
|
# option — pHash is robust to scale, and the thumb is already on local
|
||||||
|
# disk so we avoid re-decoding the original RAW/HEIC). Falls back to the
|
||||||
|
# original filepath if the thumb isn't available for some reason. Runs
|
||||||
|
# in batches to keep memory bounded and to give the user incremental
|
||||||
|
# progress visible in the worker logs.
|
||||||
|
|
||||||
|
@shared_task(name='backfill_phashes')
|
||||||
|
def backfill_phashes():
|
||||||
|
"""Compute and persist phash for every photo currently missing one."""
|
||||||
|
return asyncio.run(_backfill_phashes_async())
|
||||||
|
|
||||||
|
|
||||||
|
async def _backfill_phashes_async():
|
||||||
|
import imagehash
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
|
BATCH = 100
|
||||||
|
total_done = 0
|
||||||
|
total_failed = 0
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
while True:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Photo)
|
||||||
|
.where(Photo.phash.is_(None))
|
||||||
|
.where(Photo.processing_status == 'completed')
|
||||||
|
.limit(BATCH)
|
||||||
|
)
|
||||||
|
batch = result.scalars().all()
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
for photo in batch:
|
||||||
|
source = photo.thumb_large or photo.filepath
|
||||||
|
try:
|
||||||
|
if not source or not os.path.exists(source):
|
||||||
|
photo.phash = None
|
||||||
|
total_failed += 1
|
||||||
|
continue
|
||||||
|
with _PILImage.open(source) as im:
|
||||||
|
photo.phash = str(imagehash.phash(im))
|
||||||
|
total_done += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"phash backfill failed for {photo.id}: {e}")
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Backfilled phashes: {total_done} done, {total_failed} failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'computed': total_done,
|
||||||
|
'failed': total_failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='regroup_duplicates')
|
||||||
|
def regroup_duplicates_task():
|
||||||
|
"""Celery wrapper around app.services.duplicates.regroup_duplicates.
|
||||||
|
|
||||||
|
Importing the service inside the task body avoids a circular import
|
||||||
|
at worker boot (the service uses AsyncSessionLocal which is also
|
||||||
|
imported here at module top)."""
|
||||||
|
from app.services.duplicates import regroup_duplicates
|
||||||
|
return asyncio.run(regroup_duplicates())
|
||||||
@@ -18,6 +18,7 @@ flower==2.0.1
|
|||||||
# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback
|
# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback
|
||||||
pillow==10.2.0
|
pillow==10.2.0
|
||||||
pillow-heif==0.15.0
|
pillow-heif==0.15.0
|
||||||
|
imagehash==4.3.1 # perceptual hash for duplicate detection
|
||||||
imageio==2.33.1
|
imageio==2.33.1
|
||||||
imageio-ffmpeg==0.4.9
|
imageio-ffmpeg==0.4.9
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Timeline } from './components/timeline/Timeline'
|
import { Timeline } from './components/timeline/Timeline'
|
||||||
|
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
import { RightSidebar } from './components/layout/RightSidebar'
|
||||||
import { TopBar } from './components/layout/TopBar'
|
import { TopBar } from './components/layout/TopBar'
|
||||||
@@ -12,6 +13,7 @@ import { FilterBar } from './components/filter/FilterBar'
|
|||||||
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||||
import { SettingsDialog } from './components/dialogs/SettingsDialog'
|
import { SettingsDialog } from './components/dialogs/SettingsDialog'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
|
import { useFilterStore } from './store/filterStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||||
import { usePhotosQuery } from './hooks/usePhotosQuery'
|
import { usePhotosQuery } from './hooks/usePhotosQuery'
|
||||||
@@ -21,6 +23,7 @@ function App() {
|
|||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
|
|
||||||
// Bidirectional sync of filter store with URL query params.
|
// Bidirectional sync of filter store with URL query params.
|
||||||
useFilterUrlSync()
|
useFilterUrlSync()
|
||||||
@@ -71,7 +74,11 @@ function App() {
|
|||||||
<FilterBar />
|
<FilterBar />
|
||||||
<DiscardActionBar />
|
<DiscardActionBar />
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Timeline />
|
{/* The Duplicates section gets its own grouped grid view —
|
||||||
|
* the regular timeline can't represent groups, and a flat
|
||||||
|
* filtered list of "is_duplicate=true" photos was the old
|
||||||
|
* half-broken UX. */}
|
||||||
|
{currentSection === 'duplicates' ? <DuplicatesView /> : <Timeline />}
|
||||||
</div>
|
</div>
|
||||||
{/* Floating keyboard hints — bottom-center of the main column,
|
{/* Floating keyboard hints — bottom-center of the main column,
|
||||||
* glassy. Mounted here so it's centered against the timeline,
|
* glassy. Mounted here so it's centered against the timeline,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -27,6 +29,9 @@ const SETTINGS_THUMB_STATS_KEY = ['settings', 'thumbnail-stats'] as const
|
|||||||
const SETTINGS_LIB_STATS_KEY = ['settings', 'library-stats'] as const
|
const SETTINGS_LIB_STATS_KEY = ['settings', 'library-stats'] as const
|
||||||
const SETTINGS_WORKER_STATUS_KEY = ['settings', 'worker-status'] as const
|
const SETTINGS_WORKER_STATUS_KEY = ['settings', 'worker-status'] as const
|
||||||
const SETTINGS_MISSING_STATS_KEY = ['settings', 'missing-stats'] as const
|
const SETTINGS_MISSING_STATS_KEY = ['settings', 'missing-stats'] as const
|
||||||
|
// Shared with the DuplicatesView so a regroup invalidates the same cache
|
||||||
|
// the grid renders from. Imported via the canonical hook key.
|
||||||
|
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -86,6 +91,14 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
refetchInterval: isOpen ? 5000 : false,
|
refetchInterval: isOpen ? 5000 : false,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
// Duplicates: shares the cache with DuplicatesView so a regroup
|
||||||
|
// triggered from Settings updates the grid view immediately.
|
||||||
|
const duplicatesQuery = useQuery({
|
||||||
|
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
|
||||||
|
queryFn: library.duplicates.groups,
|
||||||
|
enabled: isOpen,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
const thumbStats = thumbStatsQuery.data
|
const thumbStats = thumbStatsQuery.data
|
||||||
const libStats = libStatsQuery.data
|
const libStats = libStatsQuery.data
|
||||||
@@ -103,6 +116,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
const refreshStats = useCallback(() => {
|
const refreshStats = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY })
|
queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY })
|
||||||
queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_KEY })
|
queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
|
||||||
}, [queryClient])
|
}, [queryClient])
|
||||||
const refreshWorkers = useCallback(() => {
|
const refreshWorkers = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: SETTINGS_WORKER_STATUS_KEY })
|
queryClient.invalidateQueries({ queryKey: SETTINGS_WORKER_STATUS_KEY })
|
||||||
@@ -249,6 +263,60 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ----------------------------------------------------- */}
|
||||||
|
{/* Duplicate detection */}
|
||||||
|
{/* ----------------------------------------------------- */}
|
||||||
|
<Section
|
||||||
|
icon={<Copy className="h-4 w-4" />}
|
||||||
|
title="Duplicates"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<Stat
|
||||||
|
label="Groups"
|
||||||
|
value={duplicatesQuery.data?.total_groups}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Members"
|
||||||
|
value={duplicatesQuery.data?.total_members}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-text-muted">
|
||||||
|
Duplicates are detected by perceptual hash (pHash), which catches
|
||||||
|
visually-identical photos even when their bytes differ — re-encoded
|
||||||
|
JPEGs, screenshots, resized exports. Backfill computes hashes for
|
||||||
|
photos that existed before pHash was added; Re-detect re-runs
|
||||||
|
clustering across the whole library.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<ActionButton
|
||||||
|
loading={busy['backfill-phashes']}
|
||||||
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
'backfill-phashes',
|
||||||
|
() => library.maintenance.backfillPhashes(),
|
||||||
|
'pHash backfill queued'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Backfill perceptual hashes
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
loading={busy['regroup-duplicates']}
|
||||||
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
'regroup-duplicates',
|
||||||
|
() => library.maintenance.regroupDuplicates(),
|
||||||
|
'Duplicate detection queued'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Re-detect duplicates
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* ----------------------------------------------------- */}
|
{/* ----------------------------------------------------- */}
|
||||||
{/* Thumbnail maintenance */}
|
{/* Thumbnail maintenance */}
|
||||||
{/* ----------------------------------------------------- */}
|
{/* ----------------------------------------------------- */}
|
||||||
|
|||||||
408
frontend/src/components/duplicates/DuplicatesView.tsx
Normal file
408
frontend/src/components/duplicates/DuplicatesView.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
useDuplicateGroupsQuery,
|
||||||
|
DUPLICATE_GROUPS_QUERY_KEY,
|
||||||
|
} from '../../hooks/useDuplicateGroupsQuery'
|
||||||
|
import {
|
||||||
|
photos as photosApi,
|
||||||
|
type DuplicateGroup,
|
||||||
|
type DuplicateGroupMember,
|
||||||
|
} from '../../services/api'
|
||||||
|
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
|
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||||
|
import { toast } from '../ToastContainer'
|
||||||
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sectioned grid view of duplicate clusters. Replaces the old flat
|
||||||
|
* "is_duplicate=true" timeline. Each section is one cluster the
|
||||||
|
* regroup_duplicates task identified — header on top with a count and a
|
||||||
|
* "keep best, discard rest" button, members rendered as PhotoThumbnail
|
||||||
|
* cards below.
|
||||||
|
*
|
||||||
|
* Mounted from App.tsx in place of <Timeline /> when the user is in the
|
||||||
|
* duplicates section. Touches no filter store state.
|
||||||
|
*/
|
||||||
|
export function DuplicatesView() {
|
||||||
|
const { data, isLoading, isError, error } = useDuplicateGroupsQuery()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||||
|
const selectPhoto = usePhotoStore((s) => s.selectPhoto)
|
||||||
|
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
||||||
|
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||||
|
|
||||||
|
// Bulk discard with the same undoable wrapper the timeline uses, so
|
||||||
|
// Cmd+Z restores the discarded copies. invalidate ['library', 'duplicates']
|
||||||
|
// so the group disappears from the view immediately.
|
||||||
|
const discardMutation = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||||
|
onSuccess: (_, ids) => {
|
||||||
|
registerUndoable(
|
||||||
|
`Discarded ${ids.length} duplicate${ids.length === 1 ? '' : 's'}`,
|
||||||
|
async () => {
|
||||||
|
await photosApi.bulkRestore(ids)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Discard failed', e?.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hooks below this point must run on every render — rules of hooks
|
||||||
|
// forbid early returns above any useState/useEffect/useMemo. The early
|
||||||
|
// loading/error/empty branches sit AFTER the hook block.
|
||||||
|
const groups = data?.groups ?? []
|
||||||
|
|
||||||
|
// Flat sequence of member ids in visual order. Drives both preview
|
||||||
|
// navigation and the in-grid keyboard walker. useMemo so the keyboard
|
||||||
|
// effect doesn't tear down on every render.
|
||||||
|
const allMemberIds = useMemo(
|
||||||
|
() => groups.flatMap((g) => g.members.map((m) => m.id)),
|
||||||
|
[groups]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||||
|
// skip a row instead of jumping a single cell. The grid uses
|
||||||
|
// `repeat(auto-fill, minmax(180px, 1fr))` so columns = floor(width/180).
|
||||||
|
// We measure the FIRST section's grid container — every section uses
|
||||||
|
// the same auto-fill rule so any one is representative.
|
||||||
|
const [columns, setColumns] = useState(4)
|
||||||
|
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
if (!el) return
|
||||||
|
const measure = () => {
|
||||||
|
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
|
||||||
|
setColumns(cols)
|
||||||
|
}
|
||||||
|
measure()
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
ro.observe(el)
|
||||||
|
// Caller doesn't get the cleanup hook but ResizeObserver disconnects
|
||||||
|
// when the element unmounts, which is fine for our lifecycle.
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Window-level keyboard nav. Mirrors Timeline's handler but walks
|
||||||
|
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
||||||
|
// grid so we approximate ↑/↓ via the measured `columns` count and
|
||||||
|
// wrap ←/→ across group boundaries.
|
||||||
|
useEffect(() => {
|
||||||
|
if (allMemberIds.length === 0) return
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = e.key
|
||||||
|
if (
|
||||||
|
key !== 'ArrowLeft' &&
|
||||||
|
key !== 'ArrowRight' &&
|
||||||
|
key !== 'ArrowUp' &&
|
||||||
|
key !== 'ArrowDown'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
const currentIdx = activePhotoId ? allMemberIds.indexOf(activePhotoId) : -1
|
||||||
|
const startIdx = currentIdx >= 0 ? currentIdx : 0
|
||||||
|
let nextIdx = startIdx
|
||||||
|
if (key === 'ArrowLeft') nextIdx = startIdx - 1
|
||||||
|
else if (key === 'ArrowRight') nextIdx = startIdx + 1
|
||||||
|
else if (key === 'ArrowUp') nextIdx = startIdx - columns
|
||||||
|
else if (key === 'ArrowDown') nextIdx = startIdx + columns
|
||||||
|
// Clamp to bounds — we don't wrap on out-of-range vertical moves
|
||||||
|
// since the grid is partitioned into groups and a "wrap" would
|
||||||
|
// skip across visually unrelated content.
|
||||||
|
nextIdx = Math.max(0, Math.min(allMemberIds.length - 1, nextIdx))
|
||||||
|
const nextId = allMemberIds[nextIdx]
|
||||||
|
if (!nextId) return
|
||||||
|
selectPhoto(nextId)
|
||||||
|
// Scroll the now-active cell into view if it's off-screen. The
|
||||||
|
// PhotoThumbnail wrapper carries data-dup-id so we can find it
|
||||||
|
// without threading refs through every cell.
|
||||||
|
const el = document.querySelector<HTMLElement>(
|
||||||
|
`[data-dup-id="${nextId}"]`
|
||||||
|
)
|
||||||
|
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [allMemberIds, activePhotoId, columns, selectPhoto])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-text-muted">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading duplicate groups…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-text-muted">
|
||||||
|
Could not load duplicate groups: {(error as any)?.message ?? 'unknown error'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-text-muted">
|
||||||
|
<Sparkles className="h-8 w-8" />
|
||||||
|
<div className="text-sm font-medium text-text">No duplicates found</div>
|
||||||
|
<p className="max-w-sm text-xs">
|
||||||
|
Nothing in your library matches another photo at the perceptual-hash
|
||||||
|
level. If you've just added new photos, give the worker a minute and
|
||||||
|
re-run "Re-detect duplicates" from Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto bg-bg p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
<span>
|
||||||
|
{data?.total_groups} group{data?.total_groups === 1 ? '' : 's'} ·{' '}
|
||||||
|
{data?.total_members} photo{data?.total_members === 1 ? '' : 's'}.
|
||||||
|
Click "Keep best" to auto-discard all but the highest-resolution
|
||||||
|
copy of each group. Cmd+Z to undo.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{groups.map((group, idx) => (
|
||||||
|
<DuplicateGroupSection
|
||||||
|
key={group.group_id}
|
||||||
|
group={group}
|
||||||
|
onKeepBest={(discardIds) => discardMutation.mutate(discardIds)}
|
||||||
|
onPreviewMember={(memberId) => openPreview(memberId, allMemberIds)}
|
||||||
|
onSelectMember={(memberId) => selectPhoto(memberId)}
|
||||||
|
selectedPhotos={selectedPhotos}
|
||||||
|
isPending={discardMutation.isPending}
|
||||||
|
// Hand the column-measurement ref to the first section only
|
||||||
|
// — every section's grid uses the same auto-fill rule so any
|
||||||
|
// one is representative of the rendered column count.
|
||||||
|
gridRef={idx === 0 ? sampleGridRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DuplicateGroupSectionProps {
|
||||||
|
group: DuplicateGroup
|
||||||
|
onKeepBest: (discardIds: string[]) => void
|
||||||
|
onPreviewMember: (memberId: string) => void
|
||||||
|
onSelectMember: (memberId: string) => void
|
||||||
|
selectedPhotos: string[]
|
||||||
|
isPending: boolean
|
||||||
|
/** Optional callback ref attached to this section's grid container.
|
||||||
|
* Used by DuplicatesView to measure the rendered column count for
|
||||||
|
* ↑/↓ keyboard navigation. Only the first section gets one. */
|
||||||
|
gridRef?: (el: HTMLDivElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DuplicateGroupSection({
|
||||||
|
group,
|
||||||
|
onKeepBest,
|
||||||
|
onPreviewMember,
|
||||||
|
onSelectMember,
|
||||||
|
selectedPhotos,
|
||||||
|
isPending,
|
||||||
|
gridRef,
|
||||||
|
}: DuplicateGroupSectionProps) {
|
||||||
|
// Auto-pick "best" copy: highest pixel count, ties broken by file_size,
|
||||||
|
// then earliest taken_at, then id for determinism. This is just the
|
||||||
|
// default — the user can override it by clicking the crown button on
|
||||||
|
// any other thumbnail (see `manualBestId`).
|
||||||
|
const autoBest = useMemo(() => pickBestMember(group.members), [group.members])
|
||||||
|
// When the user clicks "make this the best" on a non-default thumb,
|
||||||
|
// we override the auto-pick. Local to the section so different groups
|
||||||
|
// remember independent overrides; resets if the group itself changes.
|
||||||
|
const [manualBestId, setManualBestId] = useState<string | null>(null)
|
||||||
|
const bestId =
|
||||||
|
manualBestId && group.members.some((m) => m.id === manualBestId)
|
||||||
|
? manualBestId
|
||||||
|
: autoBest.id
|
||||||
|
const best = group.members.find((m) => m.id === bestId) ?? autoBest
|
||||||
|
const discardCount = group.member_count - 1
|
||||||
|
const isExact = group.reason === 'exact'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-border bg-surface">
|
||||||
|
<header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{isExact ? (
|
||||||
|
<Copy className="h-4 w-4 text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<Layers className="h-4 w-4 text-text-muted" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-text">
|
||||||
|
{group.member_count} {isExact ? 'exact' : 'similar'} photos
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-faint">
|
||||||
|
keeping: {formatDimensions(best)}
|
||||||
|
{best.file_size != null && ` · ${formatBytes(best.file_size)}`}
|
||||||
|
{manualBestId && manualBestId !== autoBest.id && (
|
||||||
|
<span className="ml-1 text-text-muted">(manual)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const discardIds = group.members
|
||||||
|
.filter((m) => m.id !== best.id)
|
||||||
|
.map((m) => m.id)
|
||||||
|
if (discardIds.length === 0) return
|
||||||
|
onKeepBest(discardIds)
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs font-medium transition-colors',
|
||||||
|
'hover:border-reject/50 hover:bg-reject/10 hover:text-reject',
|
||||||
|
isPending && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
title="Keep the highest-resolution copy and discard the rest"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
Keep best, discard {discardCount}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className="grid gap-1 p-2"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns:
|
||||||
|
'repeat(auto-fill, minmax(180px, 1fr))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.members.map((member) => {
|
||||||
|
const isBest = member.id === best.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
data-dup-id={member.id}
|
||||||
|
className="group/dup relative"
|
||||||
|
>
|
||||||
|
<PhotoThumbnail
|
||||||
|
photo={memberToPhoto(member)}
|
||||||
|
size={180}
|
||||||
|
fill
|
||||||
|
isSelected={selectedPhotos.includes(member.id)}
|
||||||
|
onClick={() => onSelectMember(member.id)}
|
||||||
|
onDoubleClick={() => onPreviewMember(member.id)}
|
||||||
|
/>
|
||||||
|
{/* "BEST" pill marks the photo that will be KEPT when the
|
||||||
|
* user clicks the discard button. Lives at top-right where
|
||||||
|
* it doesn't collide with the cyan selection ring (which
|
||||||
|
* draws around the cell perimeter); kept inset by 6px so
|
||||||
|
* the ring's outer edge has clearance on either side. */}
|
||||||
|
{isBest && (
|
||||||
|
<span className="pointer-events-none absolute right-1.5 top-1.5 z-10 flex items-center gap-1 rounded bg-pick px-1.5 py-0.5 text-[10px] font-bold uppercase text-white shadow ring-1 ring-black/30">
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
Best
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* "Make this best" affordance — shown on hover for non-best
|
||||||
|
* thumbnails. Mirrors the BEST pill's top-right placement
|
||||||
|
* so the eye doesn't have to retarget when the user is
|
||||||
|
* scanning a row of thumbnails. Stops propagation so it
|
||||||
|
* doesn't double-fire as a selection click. */}
|
||||||
|
{!isBest && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setManualBestId(member.id)
|
||||||
|
}}
|
||||||
|
className="absolute right-1.5 top-1.5 z-10 hidden items-center gap-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white shadow ring-1 ring-white/20 transition hover:bg-pick hover:text-white group-hover/dup:flex"
|
||||||
|
title="Keep this one instead"
|
||||||
|
>
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
Keep this
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Dimensions chip — bottom-LEFT (the BEST/Keep affordances
|
||||||
|
* own the top-right corner). */}
|
||||||
|
<div className="pointer-events-none absolute bottom-1 left-1 z-10 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-mono text-white">
|
||||||
|
{formatDimensions(member)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Score a member by (pixels, file_size, -taken_at) and return the winner.
|
||||||
|
* Larger pixel count wins; ties broken by file_size; final tie by earliest
|
||||||
|
* taken_at (more likely the original capture). */
|
||||||
|
function pickBestMember(members: DuplicateGroupMember[]): DuplicateGroupMember {
|
||||||
|
return members.reduce((best, m) => {
|
||||||
|
const bestPixels = (best.width ?? 0) * (best.height ?? 0)
|
||||||
|
const mPixels = (m.width ?? 0) * (m.height ?? 0)
|
||||||
|
if (mPixels !== bestPixels) return mPixels > bestPixels ? m : best
|
||||||
|
const bestSize = best.file_size ?? 0
|
||||||
|
const mSize = m.file_size ?? 0
|
||||||
|
if (mSize !== bestSize) return mSize > bestSize ? m : best
|
||||||
|
// Earliest taken_at wins (treat null as far-future).
|
||||||
|
const bestTaken = best.taken_at ?? '9999'
|
||||||
|
const mTaken = m.taken_at ?? '9999'
|
||||||
|
if (mTaken !== bestTaken) return mTaken < bestTaken ? m : best
|
||||||
|
return best
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDimensions(m: DuplicateGroupMember): string {
|
||||||
|
if (!m.width || !m.height) return '?'
|
||||||
|
const mp = (m.width * m.height) / 1_000_000
|
||||||
|
if (mp >= 1) return `${mp.toFixed(1)}MP`
|
||||||
|
return `${m.width}×${m.height}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`
|
||||||
|
if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`
|
||||||
|
return `${n}B`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* cell — the entire view is duplicates, the badge would be redundant. */
|
||||||
|
function memberToPhoto(m: DuplicateGroupMember): Photo {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
filepath: m.filename, // good enough for the RAW/video extension regex
|
||||||
|
filename: m.filename,
|
||||||
|
media_type: m.media_type,
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
taken_at: m.taken_at,
|
||||||
|
rating: 0,
|
||||||
|
is_discarded: false,
|
||||||
|
is_duplicate: false,
|
||||||
|
file_hash: m.file_hash ?? '',
|
||||||
|
folder_id: m.folder_id,
|
||||||
|
added_at: null,
|
||||||
|
thumb_small: m.thumb_small ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -481,6 +481,16 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Filepath spans the full sidebar width — most paths are long
|
||||||
|
* enough that the two-column grid above wraps them painfully.
|
||||||
|
* Mono so each character lines up under the next, break-all
|
||||||
|
* so we never overflow horizontally on a long basename. */}
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
<span className="text-text-muted">Path:</span>
|
||||||
|
<p className="mt-0.5 break-all font-mono text-[11px] text-text" title={photo.filepath}>
|
||||||
|
{photo.filepath || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const groupBy = useFilterStore((s) => s.groupBy)
|
const groupBy = useFilterStore((s) => s.groupBy)
|
||||||
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
|
|
||||||
// Calculate number of columns + actual cell size based on container
|
// Calculate number of columns + actual cell size based on container
|
||||||
@@ -393,6 +394,11 @@ export function Timeline() {
|
|||||||
// moment the user closes preview.
|
// moment the user closes preview.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode !== 'grid') return
|
if (viewMode !== 'grid') return
|
||||||
|
// The duplicates section mounts its own grouped view (DuplicatesView)
|
||||||
|
// with its own keyboard nav — bail out so we don't double-handle
|
||||||
|
// arrow keys and try to navigate against a photoRows snapshot that
|
||||||
|
// doesn't match what the user actually sees on screen.
|
||||||
|
if (currentSection === 'duplicates') return
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (photoRows.length === 0) return
|
if (photoRows.length === 0) return
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
@@ -512,7 +518,7 @@ export function Timeline() {
|
|||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize])
|
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize, currentSection])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
17
frontend/src/hooks/useDuplicateGroupsQuery.ts
Normal file
17
frontend/src/hooks/useDuplicateGroupsQuery.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { library, type DuplicateGroupsResponse } from '../services/api'
|
||||||
|
|
||||||
|
export const DUPLICATE_GROUPS_QUERY_KEY = ['library', 'duplicates'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate groups computed by the backend regroup_duplicates task. The
|
||||||
|
* cache is shared between DuplicatesView and the Settings panel stats so
|
||||||
|
* the user only ever pays for one fetch per refresh.
|
||||||
|
*/
|
||||||
|
export function useDuplicateGroupsQuery() {
|
||||||
|
return useQuery<DuplicateGroupsResponse>({
|
||||||
|
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
|
||||||
|
queryFn: library.duplicates.groups,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -349,7 +349,61 @@ export const library = {
|
|||||||
const response = await api.post('/library/maintenance/cleanup')
|
const response = await api.post('/library/maintenance/cleanup')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Recompute duplicate groups from current perceptual hashes.
|
||||||
|
* Idempotent — safe to fire repeatedly. */
|
||||||
|
regroupDuplicates: async (): Promise<{ status: string; message?: string }> => {
|
||||||
|
const response = await api.post('/library/maintenance/regroup-duplicates')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Compute pHash for every photo currently missing one. One-shot
|
||||||
|
* recovery path for libraries that existed before the phash column
|
||||||
|
* was added. */
|
||||||
|
backfillPhashes: async (): Promise<{ status: string; message?: string }> => {
|
||||||
|
const response = await api.post('/library/maintenance/backfill-phashes')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Duplicate groups computed by app.services.duplicates.regroup_duplicates.
|
||||||
|
* Drives the grouped grid view in the Duplicates section. */
|
||||||
|
duplicates: {
|
||||||
|
groups: async (): Promise<DuplicateGroupsResponse> => {
|
||||||
|
const response = await api.get('/library/duplicates/groups')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Duplicate groups ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DuplicateGroupMember {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
taken_at: string | null
|
||||||
|
file_size: number | null
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
thumb_small: string | null
|
||||||
|
file_hash: string | null
|
||||||
|
folder_id: string | null
|
||||||
|
media_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
group_id: string
|
||||||
|
member_count: number
|
||||||
|
/** "exact" iff every member shares the same SHA-256 (true byte
|
||||||
|
* duplicates that pHash also caught). "similar" otherwise. */
|
||||||
|
reason: 'exact' | 'similar'
|
||||||
|
members: DuplicateGroupMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateGroupsResponse {
|
||||||
|
groups: DuplicateGroup[]
|
||||||
|
total_groups: number
|
||||||
|
total_members: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryStats {
|
export interface LibraryStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user