8 Commits

Author SHA1 Message Date
H1K0 595eb5e06a docs(project): document DUPLICATE_HASH_THRESHOLD in .env.example
deploy / deploy (push) Successful in 1m3s
The dedup pairs rebuild reads this tunable (default 10/64); it was added to the
backend config but never documented for operators. No other new env vars were
introduced by duplicate detection — the dedup compose service reuses the
existing PUID/PGID/FILES_DIR/THUMBS_DIR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:21:23 +03:00
H1K0 19bdd3faa9 build(project): add one-shot dedup compose service
Adds a `dedup` task service under the "tools" profile so it's kept out of
`docker compose up` and run on demand:

  docker compose run --rm dedup            # hashes, then rebuild pairs
  docker compose run --rm dedup -pairs     # only rebuild pairs
  docker compose run --rm dedup -hashes    # only backfill hashes

It reuses the app image, .env, volumes and networks, overriding only the
entrypoint to /app/dedup. Unlike `docker exec` on the live server, this runs in
its own container and is self-documented for cron/CI use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:20:27 +03:00
H1K0 16e68236a0 build(project): ship the dedup CLI in the runtime image
The backend build stage compiled only ./cmd/server, so the dedup maintenance
tool was never available on deploy. Build it alongside the server and copy
/out/dedup to /app/dedup in the runtime image (which already has ffmpeg/ffprobe
for video frames and the /data volume). Run it with `docker exec`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:13:53 +03:00
H1K0 dcbe640fae feat(frontend): duplicates view, field-by-field merge dialog, api module
Adds the duplicate-detection UI:

- api/duplicates.ts: getDuplicates / dismissDuplicate / resolveDuplicate, plus
  the cluster and merge-field types.
- /files/duplicates: an offset-paginated list of clusters. Each cluster shows its
  files (auth-loaded thumbnails via a reusable Thumb component); the user clicks a
  file to mark it the survivor, then per other file: Merge, Delete, or "Not a dup"
  (dismiss). The list reloads after each action so it stays consistent with the
  rescan-gated server state.
- DuplicateMergeDialog: a bottom sheet to merge two files field-by-field — each
  scalar from the kept or other file, metadata keep/other/merge, tags & pools
  keep-or-union, with a swap-survivor toggle and an optional trash-the-other box.
- Entry point: a Duplicates action in the files Header next to Trash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:08:46 +03:00
H1K0 96a903aaff docs(project): document duplicate detection endpoints
Adds GET /files/duplicates, POST /files/duplicates/dismiss and POST
/files/duplicates/resolve to the OpenAPI spec, plus the DuplicateCluster,
DuplicateClusterPage and DuplicateResolve (with MergeScalarChoice /
MergeRelationChoice) schemas describing the field-by-field merge contract.

Also fills a pre-existing gap in the File schema: it now documents the `tags`
array (always returned by the API) and marks the always-present fields required,
so generated clients type these as non-optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:07:37 +03:00
H1K0 6e3e6a4194 feat(backend): dedup CLI (hash backfill + pairs rescan)
cmd/dedup is the offline maintenance tool for duplicate detection. It reuses the
server's config and runs two phases (both by default; -hashes / -pairs to pick):

- hashes: compute the perceptual hash of every live image/video missing one —
  images from their bytes, videos from a middle frame via DiskStorage.
  VideoFrameMiddle. Per-file failures are reported and counted, not fatal.
- pairs: rebuild data.duplicate_pairs from all current hashes (DuplicateService.
  Rescan).

Idempotent and safe to re-run: hashing only touches NULL phashes, the pairs
rebuild is a full replace. This is how video phashes and any backlog get
computed, and how newly uploaded duplicates become visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:46:40 +03:00
H1K0 9216a8687f feat(backend): duplicate pairs, dismissals, and merge resolution
Adds the duplicate-detection backend on top of perceptual hashing:

- Two tables (edited into the original migrations): data.duplicate_pairs holds
  precomputed near-duplicate candidates (rebuilt wholesale by the rescan), and
  data.duplicate_dismissals is a global "not a duplicate" overlay that survives
  rescans. New audit actions file_merge / duplicate_dismiss.
- DuplicateService:
  - Rescan builds every pair within DUPLICATE_HASH_THRESHOLD via a BK-tree over
    the perceptual hashes and replaces the pairs table. This is the only thing
    that populates pairs, so GET never compares all-vs-all (scales to 110k+).
  - Clusters reads the precomputed pairs (ACL-filtered, non-trashed, non-
    dismissed), groups them into connected components via union-find, and
    paginates whole clusters.
  - Resolve merges a pair field-by-field: each scalar from keep or discard,
    metadata keep/discard/shallow-merge, tags/pools keep or union; then trashes
    the discarded file. Enforces edit ACL on both.
  - Dismiss records a canonical pair (view ACL on both).
- Endpoints under /files: GET /files/duplicates, POST /files/duplicates/dismiss,
  POST /files/duplicates/resolve (registered before /:id to avoid collision).
  Plain delete reuses /files/bulk/delete.
- Repo support: ListMissingPHash, ListAllPHashes, CopyPoolMemberships, plus the
  DuplicatePairRepo (ReplaceAll via COPY, ListVisible) and DismissalRepo.

Unit tests cover the BK-tree pairing, union-find clustering, metadata merge and
field validation; an integration test covers rescan -> list -> merge -> dismiss
(including that a dismissal survives a re-rescan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:42:37 +03:00
H1K0 88849cc16b feat(backend): perceptual hashing for images and video
Adds a 64-bit dHash perceptual hash (internal/imagehash, built on the existing
disintegration/imaging — no new dependency) and starts populating the long-unused
data.files.phash column:

- Upload sets phash inline for images (cheap, from the in-memory bytes).
- Replace recomputes it from new content for images and clears it for anything
  else, so a stale hash never survives a content swap.
- FileRepo.SetPHash sets/clears the hash (used by Replace and, later, the dedup
  backfill).
- DiskStorage.VideoFrameMiddle extracts a frame from the middle of a clip
  (ffprobe duration -> ffmpeg -ss duration/2), avoiding the shared-intro collision
  a fixed early offset causes. It is a concrete method, not part of the storage
  port: only the dedup CLI needs it, keeping ffmpeg off the upload path. Video
  phashes are therefore computed by that CLI, not at upload time.
- DUPLICATE_HASH_THRESHOLD config (default 10/64) for the later pair rescan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:20:52 +03:00
31 changed files with 2831 additions and 12 deletions
+10
View File
@@ -125,6 +125,16 @@ THUMB_CONCURRENCY=0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
IMPORT_PATH=/data/import IMPORT_PATH=/data/import
# ---------------------------------------------------------------------------
# Duplicate detection
# ---------------------------------------------------------------------------
# Maximum perceptual-hash distance (Hamming, out of 64 bits) for two files to be
# treated as duplicate candidates. Lower = stricter (fewer, more confident
# matches); higher = looser (catches more re-encodes/resizes but risks false
# positives). Used only by the dedup tool's pairs rebuild — see the dedup CLI /
# `docker compose run --rm dedup`. Default 10.
DUPLICATE_HASH_THRESHOLD=10
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Static SPA # Static SPA
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+5
View File
@@ -44,6 +44,9 @@ COPY backend/ ./
# metadata) and falls back to pure-Go image processing (disintegration/imaging) # metadata) and falls back to pure-Go image processing (disintegration/imaging)
# when vips is absent, so it stays fully static and portable across base images. # when vips is absent, so it stays fully static and portable across base images.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
# dedup: offline maintenance CLI for duplicate detection (hash backfill + pairs
# rescan). Shipped alongside the server so it can be run with `docker exec`.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dedup ./cmd/dedup
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Stage 3 — minimal runtime # Stage 3 — minimal runtime
@@ -68,6 +71,8 @@ WORKDIR /app
COPY --from=frontend --chown=tanabata:tanabata /src/frontend/build /app/static COPY --from=frontend --chown=tanabata:tanabata /src/frontend/build /app/static
# The server binary. # The server binary.
COPY --from=backend --chown=tanabata:tanabata /out/server /app/server COPY --from=backend --chown=tanabata:tanabata /out/server /app/server
# The dedup maintenance CLI (run via `docker exec`, not the entrypoint).
COPY --from=backend --chown=tanabata:tanabata /out/dedup /app/dedup
# Data directories (overridable via FILES_PATH/THUMBS_CACHE_PATH/IMPORT_PATH). # Data directories (overridable via FILES_PATH/THUMBS_CACHE_PATH/IMPORT_PATH).
# Created and owned by the tanabata user so a fresh named volume inherits write access. # Created and owned by the tanabata user so a fresh named volume inherits write access.
+165
View File
@@ -0,0 +1,165 @@
// Command dedup is the offline maintenance tool for duplicate detection. It runs
// in two phases:
//
// hashes — compute the perceptual hash of every live image/video that has none
// yet (images from their bytes, videos from a middle frame via ffmpeg).
// pairs — rebuild data.duplicate_pairs from all current hashes.
//
// Both phases run by default; pass -hashes or -pairs to run only one. It reuses
// the server's configuration (DATABASE_URL, FILES_PATH, THUMBS_CACHE_PATH, …) and
// is safe to re-run: hashing only touches files whose phash is NULL, and the
// pairs rebuild is a full replace.
//
// go run ./cmd/dedup # hashes, then pairs
// go run ./cmd/dedup -pairs # only rebuild pairs
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/google/uuid"
"tanabata/backend/internal/config"
"tanabata/backend/internal/db/postgres"
"tanabata/backend/internal/imagehash"
"tanabata/backend/internal/service"
"tanabata/backend/internal/storage"
)
func main() {
hashesOnly := flag.Bool("hashes", false, "only (re)compute missing perceptual hashes")
pairsOnly := flag.Bool("pairs", false, "only rebuild the duplicate pairs table")
flag.Parse()
// No flag, or both, means run everything.
doHashes := *hashesOnly || !*pairsOnly
doPairs := *pairsOnly || !*hashesOnly
cfg, err := config.Load()
if err != nil {
fatal("load config", err)
}
ctx := context.Background()
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
fatal("connect to database", err)
}
defer pool.Close()
diskStorage, err := storage.NewDiskStorage(
cfg.FilesPath, cfg.ThumbsCachePath,
cfg.ThumbWidth, cfg.ThumbHeight,
cfg.PreviewWidth, cfg.PreviewHeight,
cfg.ThumbMaxPixels, cfg.ThumbConcurrency,
)
if err != nil {
fatal("init storage", err)
}
fileRepo := postgres.NewFileRepo(pool)
pairRepo := postgres.NewDuplicatePairRepo(pool)
dismissalRepo := postgres.NewDismissalRepo(pool)
aclRepo := postgres.NewACLRepo(pool)
auditRepo := postgres.NewAuditRepo(pool)
tagRepo := postgres.NewTagRepo(pool)
categoryRepo := postgres.NewCategoryRepo(pool)
poolRepo := postgres.NewPoolRepo(pool)
transactor := postgres.NewTransactor(pool)
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
dupSvc := service.NewDuplicateService(
fileRepo, pairRepo, dismissalRepo, aclSvc, auditSvc, transactor, cfg.DuplicateHashThreshold,
)
if doHashes {
if err := backfillHashes(ctx, fileRepo, diskStorage); err != nil {
fatal("backfill hashes", err)
}
}
if doPairs {
fmt.Printf("rebuilding duplicate pairs (threshold %d)...\n", cfg.DuplicateHashThreshold)
if err := dupSvc.Rescan(ctx, func(done, total int) {
fmt.Printf("\r hashed %d/%d", done, total)
}); err != nil {
fatal("rescan pairs", err)
}
fmt.Println("\n done")
}
}
// backfillHashes computes and stores a perceptual hash for every live image/video
// that lacks one. Failures on individual files are counted and reported, not
// fatal, so one unreadable file doesn't abort the whole run.
func backfillHashes(ctx context.Context, files *postgres.FileRepo, store *storage.DiskStorage) error {
pending, err := files.ListMissingPHash(ctx)
if err != nil {
return err
}
total := len(pending)
fmt.Printf("hashing %d files without a perceptual hash...\n", total)
var hashed, skipped, failed int
for i, f := range pending {
ph, err := hashOne(ctx, store, f.ID, f.MIMEType)
switch {
case err != nil:
failed++
fmt.Fprintf(os.Stderr, "\n %s (%s): %v\n", f.ID, f.MIMEType, err)
case ph == nil:
skipped++ // not decodable; leave phash NULL
default:
if err := files.SetPHash(ctx, f.ID, ph); err != nil {
return fmt.Errorf("set phash for %s: %w", f.ID, err)
}
hashed++
}
if (i+1)%200 == 0 || i+1 == total {
fmt.Printf("\r processed %d/%d", i+1, total)
}
}
fmt.Printf("\n hashed %d, skipped %d, failed %d\n", hashed, skipped, failed)
return nil
}
// hashOne returns the perceptual hash for one file, or nil when it isn't hashable
// (e.g. an image that won't decode). Images are hashed from their bytes; videos
// from a middle frame.
func hashOne(ctx context.Context, store *storage.DiskStorage, id uuid.UUID, mime string) (*int64, error) {
switch {
case strings.HasPrefix(mime, "image/"):
rc, err := store.Read(ctx, id)
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, err
}
if h, ok := imagehash.FromBytes(data); ok {
return &h, nil
}
return nil, nil
case strings.HasPrefix(mime, "video/"):
img, err := store.VideoFrameMiddle(ctx, id)
if err != nil {
return nil, err
}
h := imagehash.FromImage(img)
return &h, nil
default:
return nil, nil
}
}
func fatal(what string, err error) {
fmt.Fprintf(os.Stderr, "dedup: %s: %v\n", what, err)
os.Exit(1)
}
+7 -1
View File
@@ -70,6 +70,8 @@ func main() {
tagRuleRepo := postgres.NewTagRuleRepo(pool) tagRuleRepo := postgres.NewTagRuleRepo(pool)
categoryRepo := postgres.NewCategoryRepo(pool) categoryRepo := postgres.NewCategoryRepo(pool)
poolRepo := postgres.NewPoolRepo(pool) poolRepo := postgres.NewPoolRepo(pool)
duplicatePairRepo := postgres.NewDuplicatePairRepo(pool)
dismissalRepo := postgres.NewDismissalRepo(pool)
transactor := postgres.NewTransactor(pool) transactor := postgres.NewTransactor(pool)
// Services // Services
@@ -86,6 +88,9 @@ func main() {
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc) categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc) poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
duplicateSvc := service.NewDuplicateService(
fileRepo, duplicatePairRepo, dismissalRepo, aclSvc, auditSvc, transactor, cfg.DuplicateHashThreshold,
)
fileSvc := service.NewFileService( fileSvc := service.NewFileService(
fileRepo, fileRepo,
mimeRepo, mimeRepo,
@@ -108,6 +113,7 @@ func main() {
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, cfg.MaxUploadBytes) fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, cfg.MaxUploadBytes)
duplicateHandler := handler.NewDuplicateHandler(duplicateSvc)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc) tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc) categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc) poolHandler := handler.NewPoolHandler(poolSvc)
@@ -117,7 +123,7 @@ func main() {
r, err := handler.NewRouter( r, err := handler.NewRouter(
authMiddleware, authHandler, authMiddleware, authHandler,
fileHandler, tagHandler, categoryHandler, poolHandler, fileHandler, duplicateHandler, tagHandler, categoryHandler, poolHandler,
userHandler, aclHandler, auditHandler, userHandler, aclHandler, auditHandler,
cfg.StaticDir, cfg.StaticDir,
cfg.TrustedProxies, cfg.TrustedProxies,
+8
View File
@@ -63,6 +63,12 @@ type Config struct {
// Import // Import
ImportPath string ImportPath string
// DuplicateHashThreshold is the maximum Hamming distance (out of 64) between
// two perceptual hashes for the files to be treated as duplicate candidates.
// Lower = stricter (fewer, more confident matches); higher = looser. Used only
// by the dedup rescan that (re)builds data.duplicate_pairs.
DuplicateHashThreshold int
// Static SPA. When set, the server serves the built frontend (and falls // Static SPA. When set, the server serves the built frontend (and falls
// back to index.html for client routes) on the same port as the API. Empty // back to index.html for client routes) on the same port as the API. Empty
// in local development, where the Vite dev server serves the UI separately. // in local development, where the Vite dev server serves the UI separately.
@@ -176,6 +182,8 @@ func Load() (*Config, error) {
ImportPath: requireStr("IMPORT_PATH"), ImportPath: requireStr("IMPORT_PATH"),
DuplicateHashThreshold: parseInt("DUPLICATE_HASH_THRESHOLD", 10),
StaticDir: defaultStr("STATIC_DIR", ""), StaticDir: defaultStr("STATIC_DIR", ""),
} }
@@ -0,0 +1,145 @@
package postgres
import (
"bytes"
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// ---------------------------------------------------------------------------
// DuplicatePairRepo
// ---------------------------------------------------------------------------
// DuplicatePairRepo implements port.DuplicatePairRepo using PostgreSQL.
type DuplicatePairRepo struct {
pool *pgxpool.Pool
}
// NewDuplicatePairRepo creates a DuplicatePairRepo backed by pool.
func NewDuplicatePairRepo(pool *pgxpool.Pool) *DuplicatePairRepo {
return &DuplicatePairRepo{pool: pool}
}
var _ port.DuplicatePairRepo = (*DuplicatePairRepo)(nil)
// ReplaceAll atomically replaces the entire pairs table with the given set.
// The rescan recomputes pairs from scratch, so a full DELETE + COPY is both
// correct and the simplest way to drop pairs that no longer qualify.
func (r *DuplicatePairRepo) ReplaceAll(ctx context.Context, pairs []domain.DuplicatePair) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll begin: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after a successful commit
if _, err := tx.Exec(ctx, `DELETE FROM data.duplicate_pairs`); err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll delete: %w", err)
}
if len(pairs) > 0 {
rows := make([][]any, len(pairs))
for i, p := range pairs {
rows[i] = []any{p.FileA, p.FileB, int16(p.Distance)}
}
if _, err := tx.CopyFrom(ctx,
pgx.Identifier{"data", "duplicate_pairs"},
[]string{"file_a", "file_b", "distance"},
pgx.CopyFromRows(rows),
); err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll copy: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("DuplicatePairRepo.ReplaceAll commit: %w", err)
}
return nil
}
type pairRow struct {
FileA uuid.UUID `db:"file_a"`
FileB uuid.UUID `db:"file_b"`
Distance int16 `db:"distance"`
}
// ListVisible returns every stored pair where both files are live (not trashed),
// the pair is not dismissed, and — for non-admins — both files are visible to the
// viewer under the private-by-default model. This is the input to clustering.
func (r *DuplicatePairRepo) ListVisible(ctx context.Context, viewerID int16, isAdmin bool) ([]domain.DuplicatePair, error) {
args := make([]any, 0, 4)
n := 1
aclWhere := ""
if !isAdmin {
var ca, cb string
ca, n, args = aclVisibilityCond("fa", objTypeFile, viewerID, n, args)
cb, n, args = aclVisibilityCond("fb", objTypeFile, viewerID, n, args)
aclWhere = "AND " + ca + " AND " + cb
}
sqlStr := fmt.Sprintf(`
SELECT p.file_a, p.file_b, p.distance
FROM data.duplicate_pairs p
JOIN data.files fa ON fa.id = p.file_a AND fa.is_deleted = false
JOIN data.files fb ON fb.id = p.file_b AND fb.is_deleted = false
WHERE NOT EXISTS (
SELECT 1 FROM data.duplicate_dismissals d
WHERE d.file_a = p.file_a AND d.file_b = p.file_b
)
%s
ORDER BY p.file_a, p.file_b`, aclWhere)
rows, err := r.pool.Query(ctx, sqlStr, args...)
if err != nil {
return nil, fmt.Errorf("DuplicatePairRepo.ListVisible: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[pairRow])
if err != nil {
return nil, fmt.Errorf("DuplicatePairRepo.ListVisible scan: %w", err)
}
out := make([]domain.DuplicatePair, len(collected))
for i, row := range collected {
out[i] = domain.DuplicatePair{FileA: row.FileA, FileB: row.FileB, Distance: int(row.Distance)}
}
return out, nil
}
// ---------------------------------------------------------------------------
// DismissalRepo
// ---------------------------------------------------------------------------
// DismissalRepo implements port.DismissalRepo using PostgreSQL.
type DismissalRepo struct {
pool *pgxpool.Pool
}
// NewDismissalRepo creates a DismissalRepo backed by pool.
func NewDismissalRepo(pool *pgxpool.Pool) *DismissalRepo {
return &DismissalRepo{pool: pool}
}
var _ port.DismissalRepo = (*DismissalRepo)(nil)
// Add records a pair as "not a duplicate". The two ids are stored in canonical
// (file_a < file_b) order to match the table's CHECK and avoid (a,b)/(b,a)
// duplicates; a repeated dismissal is a no-op.
func (r *DismissalRepo) Add(ctx context.Context, a, b uuid.UUID, userID int16) error {
if bytes.Compare(a[:], b[:]) > 0 {
a, b = b, a
}
const sqlStr = `
INSERT INTO data.duplicate_dismissals (file_a, file_b, dismissed_by)
VALUES ($1, $2, $3)
ON CONFLICT (file_a, file_b) DO NOTHING`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, sqlStr, a, b, userID); err != nil {
return fmt.Errorf("DismissalRepo.Add: %w", err)
}
return nil
}
+95
View File
@@ -434,6 +434,101 @@ func (r *FileRepo) SetNeedsReview(ctx context.Context, ids []uuid.UUID, value bo
return nil return nil
} }
// SetPHash sets (or clears, when phash is nil) the perceptual hash of a file.
// Used by the dedup backfill and on content replacement; phash is non-critical,
// recomputable metadata, so callers may treat failures as best-effort.
func (r *FileRepo) SetPHash(ctx context.Context, id uuid.UUID, phash *int64) error {
const sqlStr = `UPDATE data.files SET phash = $2 WHERE id = $1`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, sqlStr, id, phash); err != nil {
return fmt.Errorf("FileRepo.SetPHash: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Perceptual-hash / duplicate support
// ---------------------------------------------------------------------------
// ListMissingPHash returns live image/video files that have no perceptual hash
// yet — the work list for the dedup backfill. Tags are not loaded (the backfill
// only needs the id and MIME type to choose image vs video hashing).
func (r *FileRepo) ListMissingPHash(ctx context.Context) ([]domain.File, error) {
const sqlStr = `
SELECT f.id, f.original_name,
mt.name AS mime_type, mt.extension AS mime_extension,
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
f.creator_id, u.name AS creator_name,
f.is_public, f.is_deleted, f.needs_review
FROM data.files f
JOIN core.mime_types mt ON mt.id = f.mime_id
JOIN core.users u ON u.id = f.creator_id
WHERE f.phash IS NULL AND f.is_deleted = false
AND (mt.name LIKE 'image/%' OR mt.name LIKE 'video/%')
ORDER BY f.id`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr)
if err != nil {
return nil, fmt.Errorf("FileRepo.ListMissingPHash: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileRow])
if err != nil {
return nil, fmt.Errorf("FileRepo.ListMissingPHash scan: %w", err)
}
files := make([]domain.File, len(collected))
for i, row := range collected {
files[i] = toFile(row)
}
return files, nil
}
// phashRow is the minimal projection used to build duplicate clusters.
type phashRow struct {
ID uuid.UUID `db:"id"`
PHash int64 `db:"phash"`
}
// ListAllPHashes returns the id and perceptual hash of every live, hashed file.
// It is the global input to the dedup rescan, so it deliberately ignores ACL —
// the rescan builds the shared pairs table; visibility is enforced on read.
func (r *FileRepo) ListAllPHashes(ctx context.Context) ([]domain.PHashEntry, error) {
const sqlStr = `SELECT id, phash FROM data.files WHERE is_deleted = false AND phash IS NOT NULL`
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, sqlStr)
if err != nil {
return nil, fmt.Errorf("FileRepo.ListAllPHashes: %w", err)
}
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[phashRow])
if err != nil {
return nil, fmt.Errorf("FileRepo.ListAllPHashes scan: %w", err)
}
out := make([]domain.PHashEntry, len(collected))
for i, row := range collected {
out[i] = domain.PHashEntry{ID: row.ID, PHash: row.PHash}
}
return out, nil
}
// CopyPoolMemberships adds targetID to every pool sourceID belongs to (copying
// the source's position), skipping pools the target is already in. Used by the
// duplicate merge to preserve the discarded file's pool memberships on the
// survivor. The merge is authorised at the file level, so pool ACL is not
// re-checked here.
func (r *FileRepo) CopyPoolMemberships(ctx context.Context, targetID, sourceID uuid.UUID) error {
const sqlStr = `
INSERT INTO data.file_pool (file_id, pool_id, position)
SELECT $1, fp.pool_id, fp.position
FROM data.file_pool fp
WHERE fp.file_id = $2
ON CONFLICT (file_id, pool_id) DO NOTHING`
q := connOrTx(ctx, r.pool)
if _, err := q.Exec(ctx, sqlStr, targetID, sourceID); err != nil {
return fmt.Errorf("FileRepo.CopyPoolMemberships: %w", err)
}
return nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SoftDelete / Restore / DeletePermanent // SoftDelete / Restore / DeletePermanent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+18
View File
@@ -0,0 +1,18 @@
package domain
import "github.com/google/uuid"
// PHashEntry is a file's perceptual hash, the input to duplicate clustering.
type PHashEntry struct {
ID uuid.UUID
PHash int64
}
// DuplicatePair is an unordered pair of files whose perceptual hashes are within
// the configured Hamming threshold. FileA < FileB by UUID byte order (canonical),
// so a pair is represented exactly once.
type DuplicatePair struct {
FileA uuid.UUID
FileB uuid.UUID
Distance int
}
@@ -0,0 +1,122 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// DuplicateHandler handles the /files/duplicates endpoints.
type DuplicateHandler struct {
dupSvc *service.DuplicateService
}
// NewDuplicateHandler creates a DuplicateHandler.
func NewDuplicateHandler(dupSvc *service.DuplicateService) *DuplicateHandler {
return &DuplicateHandler{dupSvc: dupSvc}
}
// List handles GET /files/duplicates — an offset-paginated list of duplicate
// clusters, each a group of files within the perceptual-hash threshold.
func (h *DuplicateHandler) List(c *gin.Context) {
limit, offset := 20, 0
if n, err := strconv.Atoi(c.Query("limit")); err == nil {
limit = n
}
if n, err := strconv.Atoi(c.Query("offset")); err == nil {
offset = n
}
if limit < 1 {
limit = 1
}
if limit > 50 {
limit = 50
}
if offset < 0 {
offset = 0
}
clusters, total, err := h.dupSvc.Clusters(c.Request.Context(), limit, offset)
if err != nil {
respondError(c, err)
return
}
items := make([]gin.H, len(clusters))
for i, files := range clusters {
fs := make([]fileJSON, len(files))
for j, f := range files {
fs[j] = toFileJSON(f)
}
items[i] = gin.H{"files": fs}
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// Dismiss handles POST /files/duplicates/dismiss — mark a pair "not a duplicate".
func (h *DuplicateHandler) Dismiss(c *gin.Context) {
var body struct {
FileIDA string `json:"file_id_a" binding:"required"`
FileIDB string `json:"file_id_b" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
ids, err := parseUUIDs([]string{body.FileIDA, body.FileIDB})
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.dupSvc.Dismiss(c.Request.Context(), ids[0], ids[1]); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// Resolve handles POST /files/duplicates/resolve — merge a duplicate pair,
// keeping one file and folding the chosen fields in from the other. Returns the
// updated survivor. delete_discarded defaults to true.
func (h *DuplicateHandler) Resolve(c *gin.Context) {
var body struct {
Keep string `json:"keep" binding:"required"`
Discard string `json:"discard" binding:"required"`
Fields service.MergeFields `json:"fields"`
DeleteDiscarded *bool `json:"delete_discarded"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
ids, err := parseUUIDs([]string{body.Keep, body.Discard})
if err != nil {
respondError(c, domain.ErrValidation)
return
}
del := true
if body.DeleteDiscarded != nil {
del = *body.DeleteDiscarded
}
f, err := h.dupSvc.Resolve(c.Request.Context(), service.MergeSpec{
Keep: ids[0],
Discard: ids[1],
Fields: body.Fields,
DeleteDiscarded: del,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toFileJSON(*f))
}
+6 -1
View File
@@ -26,6 +26,7 @@ func NewRouter(
auth *AuthMiddleware, auth *AuthMiddleware,
authHandler *AuthHandler, authHandler *AuthHandler,
fileHandler *FileHandler, fileHandler *FileHandler,
duplicateHandler *DuplicateHandler,
tagHandler *TagHandler, tagHandler *TagHandler,
categoryHandler *CategoryHandler, categoryHandler *CategoryHandler,
poolHandler *PoolHandler, poolHandler *PoolHandler,
@@ -80,7 +81,11 @@ func NewRouter(
files.GET("", fileHandler.List) files.GET("", fileHandler.List)
files.POST("", fileHandler.Upload) files.POST("", fileHandler.Upload)
// Bulk + import routes registered before /:id to prevent param collision. // Bulk + import + duplicates routes registered before /:id to prevent
// param collision (e.g. "duplicates" being captured as :id).
files.GET("/duplicates", duplicateHandler.List)
files.POST("/duplicates/dismiss", duplicateHandler.Dismiss)
files.POST("/duplicates/resolve", duplicateHandler.Resolve)
files.POST("/bulk/tags", fileHandler.BulkSetTags) files.POST("/bulk/tags", fileHandler.BulkSetTags)
files.POST("/bulk/delete", fileHandler.BulkDelete) files.POST("/bulk/delete", fileHandler.BulkDelete)
files.POST("/bulk/review", fileHandler.BulkReview) files.POST("/bulk/review", fileHandler.BulkReview)
+1 -1
View File
@@ -10,7 +10,7 @@ import "testing"
func TestNewRouterRegisters(t *testing.T) { func TestNewRouterRegisters(t *testing.T) {
r, err := NewRouter( r, err := NewRouter(
(*AuthMiddleware)(nil), (*AuthHandler)(nil), (*AuthMiddleware)(nil), (*AuthHandler)(nil),
(*FileHandler)(nil), (*TagHandler)(nil), (*CategoryHandler)(nil), (*PoolHandler)(nil), (*FileHandler)(nil), (*DuplicateHandler)(nil), (*TagHandler)(nil), (*CategoryHandler)(nil), (*PoolHandler)(nil),
(*UserHandler)(nil), (*ACLHandler)(nil), (*AuditHandler)(nil), (*UserHandler)(nil), (*ACLHandler)(nil), (*AuditHandler)(nil),
"", nil, "", nil,
) )
+70
View File
@@ -0,0 +1,70 @@
// Package imagehash computes a 64-bit perceptual hash (dHash) of an image and
// compares two hashes by Hamming distance. It is used for near-duplicate
// detection: visually similar images (re-encoded, resized, recompressed) produce
// hashes a small distance apart, while unrelated images are far apart.
//
// dHash is chosen for its robustness and simplicity: the image is reduced to a
// 9×8 grayscale and each pixel is compared to its right-hand neighbour, yielding
// 64 gradient-direction bits. It tolerates scaling and brightness/contrast
// changes well, which is exactly what re-encoded duplicates exhibit.
package imagehash
import (
"bytes"
"image"
_ "image/gif" // register GIF decoder
_ "image/jpeg" // register JPEG decoder
_ "image/png" // register PNG decoder
"math/bits"
"github.com/disintegration/imaging"
_ "golang.org/x/image/webp" // register WebP decoder
)
// hashWidth/hashHeight define the reduced grayscale used for dHash. The extra
// column (width = height+1) provides the right-hand neighbour for the 64
// horizontal comparisons that make up the hash.
const (
hashHeight = 8
hashWidth = hashHeight + 1
)
// FromImage reduces img to a 9×8 grayscale and returns its 64-bit dHash. The
// uint64 of gradient bits is returned as int64 (a plain bit reinterpretation) so
// it fits PostgreSQL's bigint; equality and Distance are bitwise, so the signed
// interpretation never matters.
func FromImage(img image.Image) int64 {
small := imaging.Grayscale(imaging.Resize(img, hashWidth, hashHeight, imaging.Lanczos))
var hash uint64
bit := 0
for y := 0; y < hashHeight; y++ {
for x := 0; x < hashHeight; x++ {
// After Grayscale, R == G == B, so the red channel is the luminance.
left := small.Pix[small.PixOffset(x, y)]
right := small.Pix[small.PixOffset(x+1, y)]
if left < right {
hash |= 1 << uint(63-bit)
}
bit++
}
}
return int64(hash)
}
// FromBytes decodes data (JPEG/PNG/GIF/WebP) and returns its dHash. ok is false
// when the bytes are not a decodable image, so callers can simply skip hashing
// (e.g. leave phash NULL) rather than fail.
func FromBytes(data []byte) (hash int64, ok bool) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0, false
}
return FromImage(img), true
}
// Distance returns the Hamming distance (064) between two hashes: the number of
// differing bits. 0 means identical; small values mean near-duplicate.
func Distance(a, b int64) int {
return bits.OnesCount64(uint64(a) ^ uint64(b))
}
@@ -0,0 +1,99 @@
package imagehash
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"math"
"testing"
)
// radial renders a smooth grayscale image whose brightness falls off with
// distance from (cx, cy). Smooth gradients are the realistic case for perceptual
// hashing and survive JPEG re-encoding well, so they make stable test fixtures.
func radial(w, h int, cx, cy float64) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
maxD := math.Hypot(float64(w), float64(h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
d := math.Hypot(float64(x)-cx, float64(y)-cy)
v := uint8(255 * (1 - d/maxD))
img.Set(x, y, color.RGBA{v, v, v, 255})
}
}
return img
}
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png encode: %v", err)
}
return buf.Bytes()
}
func encodeJPEG(t *testing.T, img image.Image, quality int) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
t.Fatalf("jpeg encode: %v", err)
}
return buf.Bytes()
}
// The same image re-encoded as PNG (lossless) and JPEG (lossy) must hash to a
// small Hamming distance — that is the whole point of a perceptual hash.
func TestFromBytes_SameImageAcrossEncodings(t *testing.T) {
img := radial(64, 64, 32, 32)
pngHash, ok := FromBytes(encodePNG(t, img))
if !ok {
t.Fatal("FromBytes(PNG): ok=false")
}
jpgHash, ok := FromBytes(encodeJPEG(t, img, 90))
if !ok {
t.Fatal("FromBytes(JPEG): ok=false")
}
if d := Distance(pngHash, jpgHash); d > 8 {
t.Errorf("same image, different encodings: distance = %d, want <= 8", d)
}
}
// Visually different images must be far apart, and clearly farther than the same
// image across encodings.
func TestDistance_DifferentImagesAreFarApart(t *testing.T) {
a := FromImage(radial(64, 64, 32, 32)) // centred
b := FromImage(radial(64, 64, 0, 0)) // corner
same, _ := FromBytes(encodeJPEG(t, radial(64, 64, 32, 32), 90))
d := Distance(a, b)
if d < 12 {
t.Errorf("different images: distance = %d, want >= 12", d)
}
if d <= Distance(a, same) {
t.Errorf("different images (%d) not farther than re-encoded same image (%d)", d, Distance(a, same))
}
}
func TestDistance_SymmetricAndZeroForEqual(t *testing.T) {
a := FromImage(radial(64, 64, 20, 40))
b := FromImage(radial(64, 64, 40, 20))
if Distance(a, a) != 0 {
t.Errorf("Distance(a, a) = %d, want 0", Distance(a, a))
}
if Distance(a, b) != Distance(b, a) {
t.Errorf("Distance not symmetric: %d vs %d", Distance(a, b), Distance(b, a))
}
}
func TestFromBytes_RejectsNonImage(t *testing.T) {
if _, ok := FromBytes([]byte("definitely not an image")); ok {
t.Error("FromBytes on garbage: ok=true, want false")
}
}
+109 -1
View File
@@ -56,6 +56,9 @@ type harness struct {
client *http.Client client *http.Client
importDir string importDir string
pool *pgxpool.Pool pool *pgxpool.Pool
// dupSvc lets duplicate tests trigger a pairs rescan directly: rebuilding the
// pairs table is a CLI/maintenance action with no HTTP endpoint.
dupSvc *service.DuplicateService
} }
// setupSuite creates an ephemeral database, runs migrations, wires the full // setupSuite creates an ephemeral database, runs migrations, wires the full
@@ -125,6 +128,8 @@ func setupSuite(t *testing.T) *harness {
tagRuleRepo := postgres.NewTagRuleRepo(pool) tagRuleRepo := postgres.NewTagRuleRepo(pool)
categoryRepo := postgres.NewCategoryRepo(pool) categoryRepo := postgres.NewCategoryRepo(pool)
poolRepo := postgres.NewPoolRepo(pool) poolRepo := postgres.NewPoolRepo(pool)
duplicatePairRepo := postgres.NewDuplicatePairRepo(pool)
dismissalRepo := postgres.NewDismissalRepo(pool)
transactor := postgres.NewTransactor(pool) transactor := postgres.NewTransactor(pool)
// --- Services ------------------------------------------------------------ // --- Services ------------------------------------------------------------
@@ -134,6 +139,7 @@ func setupSuite(t *testing.T) *harness {
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc) categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc) poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
duplicateSvc := service.NewDuplicateService(fileRepo, duplicatePairRepo, dismissalRepo, aclSvc, auditSvc, transactor, 10)
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, importDir) fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, importDir)
userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc) userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
@@ -145,6 +151,7 @@ func setupSuite(t *testing.T) *harness {
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, 500<<20) fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, 500<<20)
duplicateHandler := handler.NewDuplicateHandler(duplicateSvc)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc) tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc) categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc) poolHandler := handler.NewPoolHandler(poolSvc)
@@ -154,7 +161,7 @@ func setupSuite(t *testing.T) *harness {
r, err := handler.NewRouter( r, err := handler.NewRouter(
authMiddleware, authHandler, authMiddleware, authHandler,
fileHandler, tagHandler, categoryHandler, poolHandler, fileHandler, duplicateHandler, tagHandler, categoryHandler, poolHandler,
userHandler, aclHandler, auditHandler, userHandler, aclHandler, auditHandler,
"", "",
nil, nil,
@@ -170,6 +177,7 @@ func setupSuite(t *testing.T) *harness {
client: srv.Client(), client: srv.Client(),
importDir: importDir, importDir: importDir,
pool: pool, pool: pool,
dupSvc: duplicateSvc,
} }
} }
@@ -1643,3 +1651,103 @@ var (
_ = freePort _ = freePort
_ = writeFile _ = writeFile
) )
// dupListResponse decodes GET /files/duplicates.
type dupListResponse struct {
Items []struct {
Files []struct {
ID string `json:"id"`
Tags []struct {
ID string `json:"id"`
} `json:"tags"`
} `json:"files"`
} `json:"items"`
Total int `json:"total"`
}
// TestDuplicateDetection exercises the full duplicate lifecycle: perceptual hashes
// are computed on upload, a rescan builds the pairs table, the cluster surfaces,
// a field-by-field merge unions tags and trashes the discarded file, and a
// dismissal hides a pair permanently (surviving a re-rescan).
//
// minimalJPEG() is a 1×1 image, so every upload hashes identically — in a fresh
// database any two uploads form one duplicate pair, which keeps this deterministic.
func TestDuplicateDetection(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
ctx := context.Background()
admin := h.login("admin", "admin")
// --- two uploads => one duplicate pair after a rescan ---------------------
f1 := h.uploadJPEG(admin, "a.jpg")["id"].(string)
f2 := h.uploadJPEG(admin, "b.jpg")["id"].(string)
// Tag f2 so the merge has something to union onto the survivor.
resp := h.doJSON("POST", "/tags", map[string]any{"name": "kept", "is_public": true}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var tag map[string]any
resp.decode(t, &tag)
tagID := tag["id"].(string)
resp = h.doJSON("PUT", "/files/"+f2+"/tags", map[string]any{"tag_ids": []string{tagID}}, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
require.NoError(t, h.dupSvc.Rescan(ctx, nil))
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var list dupListResponse
resp.decode(t, &list)
require.Equal(t, 1, list.Total, "expected one duplicate cluster: %s", resp)
require.Len(t, list.Items, 1)
require.Len(t, list.Items[0].Files, 2)
// --- resolve: keep f1, union tags from f2, trash f2 ----------------------
resp = h.doJSON("POST", "/files/duplicates/resolve", map[string]any{
"keep": f1,
"discard": f2,
"fields": map[string]any{"tags": "both"},
"delete_discarded": true,
}, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var survivor struct {
ID string `json:"id"`
Tags []struct {
ID string `json:"id"`
} `json:"tags"`
}
resp.decode(t, &survivor)
assert.Equal(t, f1, survivor.ID)
require.Len(t, survivor.Tags, 1, "survivor should have inherited the discarded file's tag")
assert.Equal(t, tagID, survivor.Tags[0].ID)
// f2 is now trashed, so the pair drops out of the duplicates view.
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "resolved pair should no longer surface: %s", resp)
// --- dismiss: a new pair, hidden and staying hidden across a rescan -------
f3 := h.uploadJPEG(admin, "c.jpg")["id"].(string)
require.NoError(t, h.dupSvc.Rescan(ctx, nil))
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
require.Equal(t, 1, list.Total, "f1 and f3 should now form a cluster: %s", resp)
resp = h.doJSON("POST", "/files/duplicates/dismiss", map[string]any{
"file_id_a": f1, "file_id_b": f3,
}, admin)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "dismissed pair should be hidden")
// A rescan re-finds the pair but the dismissal still hides it.
require.NoError(t, h.dupSvc.Rescan(ctx, nil))
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
}
+26
View File
@@ -50,6 +50,17 @@ type FileRepo interface {
Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error)
// SetNeedsReview sets the review status on the given (non-trashed) files. // SetNeedsReview sets the review status on the given (non-trashed) files.
SetNeedsReview(ctx context.Context, ids []uuid.UUID, value bool) error SetNeedsReview(ctx context.Context, ids []uuid.UUID, value bool) error
// SetPHash sets (or clears, when nil) the perceptual hash of a file.
SetPHash(ctx context.Context, id uuid.UUID, phash *int64) error
// ListMissingPHash returns live image/video files that have no perceptual
// hash yet (the dedup backfill work list).
ListMissingPHash(ctx context.Context) ([]domain.File, error)
// ListAllPHashes returns the id and perceptual hash of every live, hashed
// file (the global input to the dedup rescan; not ACL-filtered).
ListAllPHashes(ctx context.Context) ([]domain.PHashEntry, error)
// CopyPoolMemberships adds targetID to every pool sourceID belongs to,
// skipping pools target is already in (used by the duplicate merge).
CopyPoolMemberships(ctx context.Context, targetID, sourceID uuid.UUID) error
// SoftDelete moves a file to trash (sets is_deleted = true). // SoftDelete moves a file to trash (sets is_deleted = true).
SoftDelete(ctx context.Context, id uuid.UUID) error SoftDelete(ctx context.Context, id uuid.UUID) error
// Restore moves a file out of trash (sets is_deleted = false). // Restore moves a file out of trash (sets is_deleted = false).
@@ -70,6 +81,21 @@ type FileRepo interface {
RecordTagUses(ctx context.Context, userID int16, filterDSL string) error RecordTagUses(ctx context.Context, userID int16, filterDSL string) error
} }
// DuplicatePairRepo persists the precomputed near-duplicate candidate pairs.
type DuplicatePairRepo interface {
// ReplaceAll atomically replaces the whole pairs table (used by the rescan).
ReplaceAll(ctx context.Context, pairs []domain.DuplicatePair) error
// ListVisible returns pairs whose both files are live, not dismissed, and
// (for non-admins) visible to the viewer.
ListVisible(ctx context.Context, viewerID int16, isAdmin bool) ([]domain.DuplicatePair, error)
}
// DismissalRepo persists "not a duplicate" decisions.
type DismissalRepo interface {
// Add records a pair as dismissed (canonical order, idempotent).
Add(ctx context.Context, a, b uuid.UUID, userID int16) error
}
// TagRepo is the persistence interface for tags. // TagRepo is the persistence interface for tags.
type TagRepo interface { type TagRepo interface {
List(ctx context.Context, params OffsetParams) (*domain.TagOffsetPage, error) List(ctx context.Context, params OffsetParams) (*domain.TagOffsetPage, error)
+148
View File
@@ -0,0 +1,148 @@
package service
import (
"bytes"
"math/bits"
"sort"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
)
// hamming returns the number of differing bits between two perceptual hashes.
func hamming(a, b uint64) int { return bits.OnesCount64(a ^ b) }
// bkNode is a node in a BK-tree over Hamming distance. Files that share the exact
// same hash are collected in ids (a distance-0 collision), so identical images
// don't degenerate the tree into a chain.
type bkNode struct {
hash uint64
ids []uuid.UUID
children map[int]*bkNode
}
// bkTree indexes perceptual hashes for sublinear radius queries. Building one and
// querying every element with a small radius is far cheaper than the O(N²) all-
// pairs comparison at 100k+ files.
type bkTree struct{ root *bkNode }
func (t *bkTree) insert(hash uint64, id uuid.UUID) {
if t.root == nil {
t.root = &bkNode{hash: hash, ids: []uuid.UUID{id}, children: map[int]*bkNode{}}
return
}
node := t.root
for {
d := hamming(hash, node.hash)
if d == 0 {
node.ids = append(node.ids, id)
return
}
child, ok := node.children[d]
if !ok {
node.children[d] = &bkNode{hash: hash, ids: []uuid.UUID{id}, children: map[int]*bkNode{}}
return
}
node = child
}
}
// query visits every node whose hash is within radius of target. The triangle
// inequality bounds which children can hold a match to [d-radius, d+radius].
func (t *bkTree) query(target uint64, radius int, visit func(node *bkNode, dist int)) {
if t.root == nil {
return
}
stack := []*bkNode{t.root}
for len(stack) > 0 {
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
d := hamming(target, node.hash)
if d <= radius {
visit(node, d)
}
lo, hi := d-radius, d+radius
for cd, child := range node.children {
if cd >= lo && cd <= hi {
stack = append(stack, child)
}
}
}
}
// buildPairs returns every unordered pair of files whose hashes are within
// threshold, each emitted exactly once with FileA < FileB (UUID byte order).
// onProgress, if set, is called periodically with (processed, total).
func buildPairs(entries []domain.PHashEntry, threshold int, onProgress func(done, total int)) []domain.DuplicatePair {
tree := &bkTree{}
for _, e := range entries {
tree.insert(uint64(e.PHash), e.ID)
}
var pairs []domain.DuplicatePair
total := len(entries)
for i := range entries {
e := entries[i]
tree.query(uint64(e.PHash), threshold, func(node *bkNode, dist int) {
for _, other := range node.ids {
// Emit each pair once, from the smaller id, which also skips self.
if bytes.Compare(e.ID[:], other[:]) < 0 {
pairs = append(pairs, domain.DuplicatePair{FileA: e.ID, FileB: other, Distance: dist})
}
}
})
if onProgress != nil && (i+1)%1000 == 0 {
onProgress(i+1, total)
}
}
if onProgress != nil {
onProgress(total, total)
}
return pairs
}
// clusterPairs groups pairs into connected components (transitive closure) via
// union-find. Every returned cluster has at least two files; clusters and the ids
// within them are sorted by UUID for stable pagination.
func clusterPairs(pairs []domain.DuplicatePair) [][]uuid.UUID {
parent := map[uuid.UUID]uuid.UUID{}
var find func(uuid.UUID) uuid.UUID
find = func(x uuid.UUID) uuid.UUID {
p, ok := parent[x]
if !ok {
parent[x] = x
return x
}
if p != x {
parent[x] = find(p)
}
return parent[x]
}
union := func(a, b uuid.UUID) {
ra, rb := find(a), find(b)
if ra != rb {
parent[ra] = rb
}
}
for _, p := range pairs {
union(p.FileA, p.FileB)
}
groups := map[uuid.UUID][]uuid.UUID{}
for node := range parent {
root := find(node)
groups[root] = append(groups[root], node)
}
clusters := make([][]uuid.UUID, 0, len(groups))
for _, ids := range groups {
sort.Slice(ids, func(i, j int) bool { return bytes.Compare(ids[i][:], ids[j][:]) < 0 })
clusters = append(clusters, ids)
}
sort.Slice(clusters, func(i, j int) bool {
return bytes.Compare(clusters[i][0][:], clusters[j][0][:]) < 0
})
return clusters
}
@@ -0,0 +1,361 @@
package service
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
// Merge field source values.
const (
mergeKeep = "keep"
mergeDiscard = "discard"
mergeBoth = "both"
mergeMerge = "merge"
)
// MergeFields chooses, per field, which file supplies the survivor's value when
// resolving a duplicate. Scalars accept "keep"/"discard"; metadata also accepts
// "merge" (shallow object merge, survivor wins on key conflicts); relations
// (tags, pools) accept "keep"/"both" (union) — there is deliberately no option
// to drop the survivor's own tags/pools. An empty value defaults to "keep".
type MergeFields struct {
OriginalName string `json:"original_name"`
Notes string `json:"notes"`
ContentDatetime string `json:"content_datetime"`
IsPublic string `json:"is_public"`
Metadata string `json:"metadata"`
Tags string `json:"tags"`
Pools string `json:"pools"`
}
// MergeSpec is the input to a duplicate resolution: keep one file, fold chosen
// fields in from the other, and (usually) trash the other.
type MergeSpec struct {
Keep uuid.UUID
Discard uuid.UUID
Fields MergeFields
DeleteDiscarded bool
}
// normalize fills empty choices with "keep" and rejects unknown values.
func (m *MergeSpec) normalize() error {
scalar := func(v *string) error {
if *v == "" {
*v = mergeKeep
}
if *v != mergeKeep && *v != mergeDiscard {
return domain.ErrValidation
}
return nil
}
relation := func(v *string) error {
if *v == "" {
*v = mergeKeep
}
if *v != mergeKeep && *v != mergeBoth {
return domain.ErrValidation
}
return nil
}
f := &m.Fields
if err := scalar(&f.OriginalName); err != nil {
return err
}
if err := scalar(&f.Notes); err != nil {
return err
}
if err := scalar(&f.ContentDatetime); err != nil {
return err
}
if err := scalar(&f.IsPublic); err != nil {
return err
}
if f.Metadata == "" {
f.Metadata = mergeKeep
}
if f.Metadata != mergeKeep && f.Metadata != mergeDiscard && f.Metadata != mergeMerge {
return domain.ErrValidation
}
if err := relation(&f.Tags); err != nil {
return err
}
if err := relation(&f.Pools); err != nil {
return err
}
return nil
}
// DuplicateService finds near-duplicate clusters and resolves them.
type DuplicateService struct {
files port.FileRepo
pairs port.DuplicatePairRepo
dismissals port.DismissalRepo
acl *ACLService
audit *AuditService
tx port.Transactor
threshold int
}
// NewDuplicateService creates a DuplicateService. threshold is the maximum
// Hamming distance for two files to be treated as duplicate candidates.
func NewDuplicateService(
files port.FileRepo,
pairs port.DuplicatePairRepo,
dismissals port.DismissalRepo,
acl *ACLService,
audit *AuditService,
tx port.Transactor,
threshold int,
) *DuplicateService {
return &DuplicateService{
files: files,
pairs: pairs,
dismissals: dismissals,
acl: acl,
audit: audit,
tx: tx,
threshold: threshold,
}
}
// Clusters returns a page of duplicate clusters visible to the caller. Pairs are
// read from the precomputed table (no all-pairs scan here) and grouped into
// connected components; pagination is over whole clusters.
func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clusters [][]domain.File, total int, err error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pairs, err := s.pairs.ListVisible(ctx, userID, isAdmin)
if err != nil {
return nil, 0, err
}
groups := clusterPairs(pairs)
total = len(groups)
if offset < 0 {
offset = 0
}
if offset >= len(groups) {
return [][]domain.File{}, total, nil
}
end := offset + limit
if end > len(groups) || limit <= 0 {
end = len(groups)
}
out := make([][]domain.File, 0, end-offset)
for _, ids := range groups[offset:end] {
files := make([]domain.File, 0, len(ids))
for _, id := range ids {
f, err := s.files.GetByID(ctx, id)
if err != nil {
// A file deleted between the pair read and now just drops out.
if errors.Is(err, domain.ErrNotFound) {
continue
}
return nil, 0, err
}
files = append(files, *f)
}
if len(files) >= 2 {
out = append(out, files)
}
}
return out, total, nil
}
// Rescan recomputes the entire duplicate_pairs table from the current set of
// perceptual hashes. It is the only thing that populates the table, so the
// duplicates view reflects state as of the last rescan. Called by the dedup CLI.
func (s *DuplicateService) Rescan(ctx context.Context, onProgress func(done, total int)) error {
entries, err := s.files.ListAllPHashes(ctx)
if err != nil {
return err
}
pairs := buildPairs(entries, s.threshold, onProgress)
return s.pairs.ReplaceAll(ctx, pairs)
}
// Dismiss records two files as "not a duplicate" so the pair stops surfacing.
// The caller must be able to view both files.
func (s *DuplicateService) Dismiss(ctx context.Context, a, b uuid.UUID) error {
if a == b {
return domain.ErrValidation
}
userID, isAdmin, _ := domain.UserFromContext(ctx)
for _, id := range []uuid.UUID{a, b} {
f, err := s.files.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
}
if err := s.dismissals.Add(ctx, a, b, userID); err != nil {
return err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "duplicate_dismiss", &objType, &a, map[string]any{"other": b.String()})
return nil
}
// Resolve merges a duplicate pair: the survivor (keep) takes the chosen fields
// from the other (discard), and the other is trashed when DeleteDiscarded is set.
// The caller must be able to edit both files. Returns the updated survivor.
func (s *DuplicateService) Resolve(ctx context.Context, spec MergeSpec) (*domain.File, error) {
if spec.Keep == spec.Discard {
return nil, domain.ErrValidation
}
if err := spec.normalize(); err != nil {
return nil, err
}
keep, err := s.files.GetByID(ctx, spec.Keep)
if err != nil {
return nil, err
}
discard, err := s.files.GetByID(ctx, spec.Discard)
if err != nil {
return nil, err
}
userID, isAdmin, _ := domain.UserFromContext(ctx)
for _, f := range []*domain.File{keep, discard} {
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, f.ID)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
}
// FileRepo.Update rewrites all editable scalar columns, so build the complete
// resolved set (each field from keep or discard) rather than a sparse patch.
patch := &domain.File{
OriginalName: pickPtr(spec.Fields.OriginalName, keep.OriginalName, discard.OriginalName),
Notes: pickPtr(spec.Fields.Notes, keep.Notes, discard.Notes),
ContentDatetime: pickTime(spec.Fields.ContentDatetime, keep.ContentDatetime, discard.ContentDatetime),
IsPublic: pickBool(spec.Fields.IsPublic, keep.IsPublic, discard.IsPublic),
Metadata: pickMetadata(spec.Fields.Metadata, keep.Metadata, discard.Metadata),
}
var result *domain.File
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
updated, err := s.files.Update(ctx, keep.ID, patch)
if err != nil {
return err
}
if spec.Fields.Tags == mergeBoth {
if err := s.files.SetTags(ctx, keep.ID, unionTagIDs(keep.Tags, discard.Tags)); err != nil {
return err
}
tags, err := s.files.ListTags(ctx, keep.ID)
if err != nil {
return err
}
updated.Tags = tags
}
if spec.Fields.Pools == mergeBoth {
if err := s.files.CopyPoolMemberships(ctx, keep.ID, discard.ID); err != nil {
return err
}
}
if spec.DeleteDiscarded {
if err := s.files.SoftDelete(ctx, discard.ID); err != nil {
return err
}
}
result = updated
return nil
})
if txErr != nil {
return nil, txErr
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_merge", &objType, &keep.ID, map[string]any{
"discard": spec.Discard.String(),
"fields": spec.Fields,
"deleted_discarded": spec.DeleteDiscarded,
})
return result, nil
}
// --- field pickers ---------------------------------------------------------
func pickPtr(choice string, keep, discard *string) *string {
if choice == mergeDiscard {
return discard
}
return keep
}
func pickBool(choice string, keep, discard bool) bool {
if choice == mergeDiscard {
return discard
}
return keep
}
func pickTime(choice string, keep, discard time.Time) time.Time {
if choice == mergeDiscard {
return discard
}
return keep
}
func unionTagIDs(a, b []domain.Tag) []uuid.UUID {
seen := make(map[uuid.UUID]bool, len(a)+len(b))
ids := make([]uuid.UUID, 0, len(a)+len(b))
for _, t := range append(append([]domain.Tag{}, a...), b...) {
if !seen[t.ID] {
seen[t.ID] = true
ids = append(ids, t.ID)
}
}
return ids
}
// pickMetadata returns keep's metadata, discard's, or a shallow merge in which
// the survivor's keys win on conflict.
func pickMetadata(choice string, keep, discard json.RawMessage) json.RawMessage {
switch choice {
case mergeDiscard:
return discard
case mergeMerge:
km := map[string]json.RawMessage{}
dm := map[string]json.RawMessage{}
_ = json.Unmarshal(keep, &km)
_ = json.Unmarshal(discard, &dm)
out := make(map[string]json.RawMessage, len(km)+len(dm))
for k, v := range dm {
out[k] = v
}
for k, v := range km { // survivor wins
out[k] = v
}
if len(out) == 0 {
return keep
}
b, err := json.Marshal(out)
if err != nil {
return keep
}
return b
default:
return keep
}
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sort"
"testing"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
)
// id builds a deterministic UUID whose byte order matches n, so tests can reason
// about the canonical (FileA < FileB) ordering buildPairs produces.
func id(n int) uuid.UUID {
return uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", n))
}
func entry(n int, hash uint64) domain.PHashEntry {
return domain.PHashEntry{ID: id(n), PHash: int64(hash)}
}
// pairKey canonicalises a pair for set comparison regardless of emission order.
func pairKey(p domain.DuplicatePair) string {
a, b := p.FileA, p.FileB
if bytes.Compare(a[:], b[:]) > 0 {
a, b = b, a
}
return fmt.Sprintf("%s|%s|%d", a, b, p.Distance)
}
func TestBuildPairs_ThresholdAndCanonicalOrder(t *testing.T) {
entries := []domain.PHashEntry{
entry(1, 0x0000000000000000),
entry(2, 0x0000000000000001), // distance 1 from #1
entry(3, 0x00000000000000FF), // distance 8 from #1, 7 from #2
entry(4, 0xFFFFFFFFFFFFFFFF), // distance 64 from #1
}
// Tight threshold: only the distance-1 pair qualifies.
got := buildPairs(entries, 2, nil)
if len(got) != 1 {
t.Fatalf("threshold 2: got %d pairs, want 1: %+v", len(got), got)
}
if got[0].FileA != id(1) || got[0].FileB != id(2) || got[0].Distance != 1 {
t.Errorf("threshold 2: unexpected pair %+v", got[0])
}
// Canonical order always FileA < FileB.
if bytes.Compare(got[0].FileA[:], got[0].FileB[:]) >= 0 {
t.Error("pair not in canonical FileA < FileB order")
}
// Looser threshold pulls in #3's pairs but never #4.
got8 := buildPairs(entries, 8, nil)
want := map[string]bool{
pairKey(domain.DuplicatePair{FileA: id(1), FileB: id(2), Distance: 1}): true,
pairKey(domain.DuplicatePair{FileA: id(1), FileB: id(3), Distance: 8}): true,
pairKey(domain.DuplicatePair{FileA: id(2), FileB: id(3), Distance: 7}): true,
}
if len(got8) != len(want) {
t.Fatalf("threshold 8: got %d pairs, want %d: %+v", len(got8), len(want), got8)
}
for _, p := range got8 {
if !want[pairKey(p)] {
t.Errorf("threshold 8: unexpected pair %+v", p)
}
}
}
func TestBuildPairs_IdenticalHashesPairAtDistanceZero(t *testing.T) {
entries := []domain.PHashEntry{
entry(1, 0xABCDABCDABCDABCD),
entry(2, 0xABCDABCDABCDABCD),
}
got := buildPairs(entries, 0, nil)
if len(got) != 1 || got[0].Distance != 0 || got[0].FileA != id(1) || got[0].FileB != id(2) {
t.Fatalf("identical hashes: got %+v, want one distance-0 pair (1,2)", got)
}
}
func TestClusterPairs_ConnectedComponents(t *testing.T) {
pairs := []domain.DuplicatePair{
{FileA: id(1), FileB: id(2)},
{FileA: id(2), FileB: id(3)}, // transitively joins 1-2-3
{FileA: id(5), FileB: id(6)},
}
clusters := clusterPairs(pairs)
if len(clusters) != 2 {
t.Fatalf("got %d clusters, want 2: %+v", len(clusters), clusters)
}
// Sorted by smallest id: {1,2,3} then {5,6}.
if len(clusters[0]) != 3 || clusters[0][0] != id(1) || clusters[0][2] != id(3) {
t.Errorf("cluster 0 = %v, want [1 2 3]", clusters[0])
}
if len(clusters[1]) != 2 || clusters[1][0] != id(5) {
t.Errorf("cluster 1 = %v, want [5 6]", clusters[1])
}
// Each cluster's ids are sorted.
for _, c := range clusters {
if !sort.SliceIsSorted(c, func(i, j int) bool { return bytes.Compare(c[i][:], c[j][:]) < 0 }) {
t.Errorf("cluster not sorted: %v", c)
}
}
}
func TestPickMetadata_Merge(t *testing.T) {
keep := json.RawMessage(`{"a":1,"b":2}`)
discard := json.RawMessage(`{"b":9,"c":3}`)
out := pickMetadata(mergeMerge, keep, discard)
var m map[string]int
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("merge result not valid JSON: %v (%s)", err, out)
}
want := map[string]int{"a": 1, "b": 2, "c": 3} // survivor wins on "b"
if fmt.Sprint(m) != fmt.Sprint(want) {
t.Errorf("merge = %v, want %v", m, want)
}
if string(pickMetadata(mergeKeep, keep, discard)) != string(keep) {
t.Error("keep choice should return survivor metadata unchanged")
}
if string(pickMetadata(mergeDiscard, keep, discard)) != string(discard) {
t.Error("discard choice should return the other file's metadata")
}
}
func TestMergeSpec_Normalize(t *testing.T) {
// Empty fields default to "keep".
spec := MergeSpec{Keep: id(1), Discard: id(2)}
if err := spec.normalize(); err != nil {
t.Fatalf("normalize empty: %v", err)
}
if spec.Fields.OriginalName != mergeKeep || spec.Fields.Tags != mergeKeep || spec.Fields.Metadata != mergeKeep {
t.Errorf("empty fields not defaulted to keep: %+v", spec.Fields)
}
// "both" is invalid for a scalar field.
bad := MergeSpec{Keep: id(1), Discard: id(2), Fields: MergeFields{Notes: mergeBoth}}
if err := bad.normalize(); !errors.Is(err, domain.ErrValidation) {
t.Errorf("scalar=both: got %v, want ErrValidation", err)
}
// "discard" is invalid for a relation field.
badRel := MergeSpec{Keep: id(1), Discard: id(2), Fields: MergeFields{Tags: mergeDiscard}}
if err := badRel.normalize(); !errors.Is(err, domain.ErrValidation) {
t.Errorf("relation=discard: got %v, want ErrValidation", err)
}
}
+25
View File
@@ -16,6 +16,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"tanabata/backend/internal/domain" "tanabata/backend/internal/domain"
"tanabata/backend/internal/imagehash"
"tanabata/backend/internal/port" "tanabata/backend/internal/port"
) )
@@ -154,6 +155,17 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
} }
exifData, exifDatetime := extractMetadata(data, origName, p.ContentDatetimeFallback) exifData, exifDatetime := extractMetadata(data, origName, p.ContentDatetimeFallback)
// Compute a perceptual hash for images so duplicate detection can later match
// near-identical files. Best-effort: a decode failure just leaves phash unset
// (the dedup CLI backfills it). Video is hashed by that CLI, not inline, to keep
// ffmpeg off the upload path.
var phash *int64
if strings.HasPrefix(mime.Name, "image/") {
if h, ok := imagehash.FromBytes(data); ok {
phash = &h
}
}
// Resolve content datetime: explicit > metadata date > fallback (e.g. import mtime) > zero. // Resolve content datetime: explicit > metadata date > fallback (e.g. import mtime) > zero.
var contentDatetime time.Time var contentDatetime time.Time
if p.ContentDatetime != nil { if p.ContentDatetime != nil {
@@ -187,6 +199,7 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
Notes: p.Notes, Notes: p.Notes,
Metadata: p.Metadata, Metadata: p.Metadata,
EXIF: exifData, EXIF: exifData,
PHash: phash,
CreatorID: userID, CreatorID: userID,
IsPublic: p.IsPublic, IsPublic: p.IsPublic,
} }
@@ -453,6 +466,18 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
return nil, err return nil, err
} }
// Recompute the perceptual hash from the new content: images inline, anything
// else cleared to NULL so the old content's hash never lingers (the dedup CLI
// recomputes video). Best-effort, like on upload — phash is recomputable.
var phash *int64
if strings.HasPrefix(mime.Name, "image/") {
if h, ok := imagehash.FromBytes(data); ok {
phash = &h
}
}
_ = s.files.SetPHash(ctx, id, phash)
updated.PHash = phash
objType := fileObjectType objType := fileObjectType
_ = s.audit.Log(ctx, "file_replace", &objType, &id, nil) _ = s.audit.Log(ctx, "file_replace", &objType, &id, nil)
return updated, nil return updated, nil
+62 -7
View File
@@ -16,6 +16,8 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings"
"time" "time"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
@@ -155,6 +157,30 @@ func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser,
return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight) return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight)
} }
// VideoFrameMiddle decodes a representative frame from the middle of a video
// (duration/2). The midpoint avoids the shared intros, title cards and black
// lead-in frames that make a fixed early offset collide across unrelated clips,
// so it is the right source for the video's perceptual (duplicate-detection)
// hash. The file must already exist in storage; ffmpeg/ffprobe must be installed.
// This is not part of port.FileStorage — only the dedup CLI needs it, with a
// concrete *DiskStorage — so the interface stays lean and ffmpeg stays out of the
// upload path.
func (s *DiskStorage) VideoFrameMiddle(ctx context.Context, id uuid.UUID) (image.Image, error) {
srcPath := s.originalPath(id)
if _, err := os.Stat(srcPath); err != nil {
if os.IsNotExist(err) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
}
// Fall back to a 1s offset if duration can't be probed — better a frame than none.
at := 1.0
if d, err := videoDurationSeconds(ctx, srcPath); err == nil && d > 0 {
at = d / 2
}
return extractVideoFrameAt(ctx, srcPath, at)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal helpers // Internal helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -342,19 +368,25 @@ func (s *DiskStorage) vipsThumbnail(ctx context.Context, srcPath, cachePath stri
return f, nil return f, nil
} }
// extractVideoFrame uses ffmpeg to extract a single frame from a video file. // extractVideoFrame extracts a single frame ~1 second into the video — a safe
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out // default for thumbnails. See extractVideoFrameAt for the mechanics.
// as PNG. If the video is shorter than 1 s the seek is silently ignored by
// ffmpeg and the first available frame is returned instead.
// Returns an error if ffmpeg is not installed or produces no output. The run is
// bounded by a timeout so a malformed file cannot hang the request indefinitely.
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) { func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
return extractVideoFrameAt(ctx, srcPath, 1)
}
// extractVideoFrameAt uses ffmpeg to extract a single frame at atSec seconds into
// the video, piped out as PNG. The fast input seek (-ss before -i) is keyframe-
// accurate and cheap; if atSec is past the end the seek is silently ignored and
// the first available frame is returned instead. Returns an error if ffmpeg is
// not installed or produces no output. The run is bounded by a timeout so a
// malformed file cannot hang the caller indefinitely.
func extractVideoFrameAt(ctx context.Context, srcPath string, atSec float64) (image.Image, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
var out bytes.Buffer var out bytes.Buffer
cmd := exec.CommandContext(ctx, "ffmpeg", cmd := exec.CommandContext(ctx, "ffmpeg",
"-ss", "1", // fast input seek; ignored gracefully on short files "-ss", strconv.FormatFloat(atSec, 'f', 3, 64), // fast input seek; ignored gracefully past end
"-i", srcPath, "-i", srcPath,
"-vframes", "1", "-vframes", "1",
"-f", "image2", "-f", "image2",
@@ -370,6 +402,29 @@ func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error)
return imaging.Decode(&out) return imaging.Decode(&out)
} }
// videoDurationSeconds returns the container duration in seconds via ffprobe.
// Used to seek to the middle of a clip for perceptual hashing.
func videoDurationSeconds(ctx context.Context, srcPath string) (float64, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
srcPath,
)
out, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("ffprobe duration: %w", err)
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil {
return 0, fmt.Errorf("ffprobe duration parse %q: %w", out, err)
}
return d, nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path helpers // Path helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+29
View File
@@ -92,6 +92,31 @@ CREATE TABLE data.file_pool (
PRIMARY KEY (file_id, pool_id) PRIMARY KEY (file_id, pool_id)
); );
-- Precomputed near-duplicate candidates (phash Hamming distance <= threshold),
-- (re)built in full by the dedup rescan. Stored once per unordered pair with a
-- canonical file_a < file_b ordering so a pair is never duplicated as (a,b)/(b,a).
CREATE TABLE data.duplicate_pairs (
file_a uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
file_b uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
distance smallint NOT NULL,
CONSTRAINT chk__duplicate_pairs__order CHECK (file_a < file_b),
PRIMARY KEY (file_a, file_b)
);
-- "Not a duplicate" decisions: a global overlay that hides a candidate pair from
-- the duplicates view. Survives rescans (the pair may be re-found but stays
-- hidden). Same canonical file_a < file_b ordering as data.duplicate_pairs.
CREATE TABLE data.duplicate_dismissals (
file_a uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
file_b uuid NOT NULL REFERENCES data.files(id) ON UPDATE CASCADE ON DELETE CASCADE,
dismissed_by smallint NOT NULL REFERENCES core.users(id) ON UPDATE CASCADE ON DELETE RESTRICT,
dismissed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT chk__duplicate_dismissals__order CHECK (file_a < file_b),
PRIMARY KEY (file_a, file_b)
);
COMMENT ON TABLE data.categories IS 'Logical grouping of tags'; COMMENT ON TABLE data.categories IS 'Logical grouping of tags';
COMMENT ON TABLE data.tags IS 'File labels/tags'; COMMENT ON TABLE data.tags IS 'File labels/tags';
COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows'; COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows';
@@ -99,6 +124,8 @@ COMMENT ON TABLE data.files IS 'Managed files; actual content stored on di
COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags'; COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags';
COMMENT ON TABLE data.pools IS 'Ordered collections of files'; COMMENT ON TABLE data.pools IS 'Ordered collections of files';
COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering'; COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering';
COMMENT ON TABLE data.duplicate_pairs IS 'Precomputed near-duplicate candidate pairs (perceptual-hash distance)';
COMMENT ON TABLE data.duplicate_dismissals IS 'Pairs marked "not a duplicate"; hidden from the duplicates view';
COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time'; COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time';
COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal'; COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal';
@@ -110,6 +137,8 @@ COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; u
-- +goose Down -- +goose Down
DROP TABLE IF EXISTS data.duplicate_dismissals;
DROP TABLE IF EXISTS data.duplicate_pairs;
DROP TABLE IF EXISTS data.file_pool; DROP TABLE IF EXISTS data.file_pool;
DROP TABLE IF EXISTS data.pools; DROP TABLE IF EXISTS data.pools;
DROP TABLE IF EXISTS data.file_tag; DROP TABLE IF EXISTS data.file_tag;
+8
View File
@@ -26,6 +26,12 @@ CREATE INDEX idx__files__needs_review ON data.files USING btree (id) WHERE
CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id); CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id);
CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id); CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id);
-- data.duplicate_pairs / data.duplicate_dismissals
-- The composite primary keys cover lookups on file_a; these add the file_b side
-- (used by the ON DELETE CASCADE and by the visibility join on the second file).
CREATE INDEX idx__duplicate_pairs__file_b ON data.duplicate_pairs USING hash (file_b);
CREATE INDEX idx__duplicate_dismissals__file_b ON data.duplicate_dismissals USING hash (file_b);
-- data.pools -- data.pools
CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id); CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id);
@@ -70,6 +76,8 @@ DROP INDEX IF EXISTS activity.idx__sessions__token_hash;
DROP INDEX IF EXISTS activity.idx__sessions__user_id; DROP INDEX IF EXISTS activity.idx__sessions__user_id;
DROP INDEX IF EXISTS acl.idx__acl__user; DROP INDEX IF EXISTS acl.idx__acl__user;
DROP INDEX IF EXISTS acl.idx__acl__object; DROP INDEX IF EXISTS acl.idx__acl__object;
DROP INDEX IF EXISTS data.idx__duplicate_dismissals__file_b;
DROP INDEX IF EXISTS data.idx__duplicate_pairs__file_b;
DROP INDEX IF EXISTS data.idx__file_pool__file_id; DROP INDEX IF EXISTS data.idx__file_pool__file_id;
DROP INDEX IF EXISTS data.idx__file_pool__pool_id; DROP INDEX IF EXISTS data.idx__file_pool__pool_id;
DROP INDEX IF EXISTS data.idx__pools__creator_id; DROP INDEX IF EXISTS data.idx__pools__creator_id;
+1
View File
@@ -21,6 +21,7 @@ INSERT INTO activity.action_types (name) VALUES
-- Files -- Files
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'), ('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
('file_permanent_delete'), ('file_replace'), ('file_review'), ('file_permanent_delete'), ('file_replace'), ('file_review'),
('file_merge'), ('duplicate_dismiss'),
-- Tags -- Tags
('tag_create'), ('tag_edit'), ('tag_delete'), ('tag_create'), ('tag_edit'), ('tag_delete'),
-- Categories -- Categories
+34
View File
@@ -114,6 +114,40 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# One-shot maintenance task for duplicate detection: computes missing
# perceptual hashes (images + video) and rebuilds the duplicate-pairs table.
# It is NOT a daemon — the "tools" profile keeps it out of `docker compose up`;
# run it on demand, and it exits when done:
#
# docker compose run --rm dedup # hashes, then rebuild pairs
# docker compose run --rm dedup -pairs # only rebuild pairs (after uploads)
# docker compose run --rm dedup -hashes # only backfill hashes
#
# Reuses the app image, .env, volumes and networks; only the entrypoint differs
# (/app/dedup instead of the server). Connects to the same DB the app uses, so
# the app's DB (bundled or host) must be reachable when it runs.
dedup:
build:
context: .
dockerfile: Dockerfile
profiles: ["tools"]
env_file: .env
networks:
- web
- backend
depends_on:
db:
condition: service_healthy
required: false
extra_hosts:
- "host.docker.internal:host-gateway"
user: "${PUID:-42776}:${PGID:-42776}"
volumes:
- "${FILES_DIR:-app_files}:/data/files"
- "${THUMBS_DIR:-app_thumbs}:/data/thumbs"
entrypoint: ["/app/dedup"]
restart: "no"
networks: networks:
# Public-facing bridge for this app. The explicit bridge name (instead of # Public-facing bridge for this app. The explicit bridge name (instead of
# Docker's random br-<hash>) makes it identifiable on the host for tcpdump and # Docker's random br-<hash>) makes it identifiable on the host for tcpdump and
+52
View File
@@ -0,0 +1,52 @@
import { api } from '$lib/api/client';
import type { File } from '$lib/api/types';
/** A group of mutually similar files. */
export interface DuplicateCluster {
files: File[];
}
export interface DuplicateClusterPage {
items: DuplicateCluster[];
total: number;
offset: number;
limit: number;
}
/** Per-field source for a merge. Scalars choose keep/discard; relations
* (tags, pools) choose keep/both; metadata can also be shallow-merged. */
export type ScalarChoice = 'keep' | 'discard';
export type RelationChoice = 'keep' | 'both';
export type MetadataChoice = 'keep' | 'discard' | 'merge';
export interface MergeFields {
original_name?: ScalarChoice;
notes?: ScalarChoice;
content_datetime?: ScalarChoice;
is_public?: ScalarChoice;
metadata?: MetadataChoice;
tags?: RelationChoice;
pools?: RelationChoice;
}
export interface ResolveRequest {
keep: string;
discard: string;
fields?: MergeFields;
delete_discarded?: boolean;
}
/** Fetch a page of duplicate clusters (server reads a precomputed table). */
export function getDuplicates(limit = 20, offset = 0): Promise<DuplicateClusterPage> {
return api.get<DuplicateClusterPage>(`/files/duplicates?limit=${limit}&offset=${offset}`);
}
/** Mark two files as "not a duplicate" so the pair stops surfacing. */
export function dismissDuplicate(a: string, b: string): Promise<void> {
return api.post<void>('/files/duplicates/dismiss', { file_id_a: a, file_id_b: b });
}
/** Merge a duplicate pair, returning the updated survivor. */
export function resolveDuplicate(req: ResolveRequest): Promise<File> {
return api.post<File>('/files/duplicates/resolve', req);
}
@@ -0,0 +1,402 @@
<script lang="ts">
import type { File } from '$lib/api/types';
import {
resolveDuplicate,
type MergeFields,
type MetadataChoice,
type RelationChoice,
type ScalarChoice
} from '$lib/api/duplicates';
import Thumb from '$lib/components/file/Thumb.svelte';
interface Props {
/** The two files to merge; `keep` is the default survivor (swappable here). */
keep: File;
discard: File;
/** Called with the updated survivor after a successful merge. */
onResolved: (survivor: File) => void;
onClose: () => void;
}
let { keep, discard, onResolved, onClose }: Props = $props();
// Which file survives is swappable; derive the two sides from a single flag so
// the choice stays in sync with the props.
let swapped = $state(false);
let a = $derived<File>(swapped ? discard : keep);
let b = $derived<File>(swapped ? keep : discard);
// Per-field source, all defaulting to the survivor ("keep").
let original_name = $state<ScalarChoice>('keep');
let notes = $state<ScalarChoice>('keep');
let content_datetime = $state<ScalarChoice>('keep');
let is_public = $state<ScalarChoice>('keep');
let metadata = $state<MetadataChoice>('keep');
let tags = $state<RelationChoice>('keep');
let pools = $state<RelationChoice>('keep');
let deleteDiscarded = $state(true);
let busy = $state(false);
let error = $state('');
function swap() {
swapped = !swapped;
}
function fmtDate(s?: string | null): string {
if (!s) return '—';
const d = new Date(s);
return isNaN(d.getTime()) ? s : d.toLocaleString();
}
function metaCount(m: unknown): number {
return m && typeof m === 'object' ? Object.keys(m as object).length : 0;
}
async function submit() {
if (busy) return;
busy = true;
error = '';
const fields: MergeFields = {
original_name,
notes,
content_datetime,
is_public,
metadata,
tags,
pools
};
try {
const survivor = await resolveDuplicate({
keep: a.id,
discard: b.id,
fields,
delete_discarded: deleteDiscarded
});
onResolved(survivor);
onClose();
} catch {
error = 'Failed to merge';
busy = false;
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="backdrop" role="presentation" onclick={onClose}></div>
<div class="sheet" class:busy role="dialog" aria-label="Merge duplicates">
<div class="head">
<span class="title">Merge duplicates</span>
<button class="x" onclick={onClose} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="body">
<!-- Survivor / other headers -->
<div class="files">
<div class="file">
<Thumb id={a.id} size={80} alt={a.original_name ?? ''} />
<span class="badge keep">Keep</span>
<span class="fname" title={a.original_name ?? ''}>{a.original_name ?? '—'}</span>
</div>
<button class="swap" onclick={swap} title="Swap which file is kept" aria-label="Swap">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M5 4h8l-2.5-2.5M13 14H5l2.5 2.5"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<div class="file">
<Thumb id={b.id} size={80} alt={b.original_name ?? ''} />
<span class="badge other">Other</span>
<span class="fname" title={b.original_name ?? ''}>{b.original_name ?? '—'}</span>
</div>
</div>
<!-- Scalar fields: keep vs discard -->
{#snippet scalarRow(label: string, value: ScalarChoice, set: (v: ScalarChoice) => void, keepVal: string, otherVal: string)}
<div class="row">
<span class="label">{label}</span>
<div class="seg">
<button class:on={value === 'keep'} onclick={() => set('keep')} title={keepVal}>
{keepVal || '—'}
</button>
<button class:on={value === 'discard'} onclick={() => set('discard')} title={otherVal}>
{otherVal || '—'}
</button>
</div>
</div>
{/snippet}
{@render scalarRow(
'Name',
original_name,
(v) => (original_name = v),
a.original_name ?? '',
b.original_name ?? ''
)}
{@render scalarRow('Notes', notes, (v) => (notes = v), a.notes ?? '', b.notes ?? '')}
{@render scalarRow(
'Date',
content_datetime,
(v) => (content_datetime = v),
fmtDate(a.content_datetime),
fmtDate(b.content_datetime)
)}
{@render scalarRow(
'Visibility',
is_public,
(v) => (is_public = v),
a.is_public ? 'Public' : 'Private',
b.is_public ? 'Public' : 'Private'
)}
<!-- Metadata: keep / other / merge -->
<div class="row">
<span class="label">Metadata</span>
<div class="seg">
<button class:on={metadata === 'keep'} onclick={() => (metadata = 'keep')}>
Keep ({metaCount(a.metadata)})
</button>
<button class:on={metadata === 'discard'} onclick={() => (metadata = 'discard')}>
Other ({metaCount(b.metadata)})
</button>
<button class:on={metadata === 'merge'} onclick={() => (metadata = 'merge')}>Merge</button>
</div>
</div>
<!-- Relations: keep vs union both -->
<div class="row">
<span class="label">Tags</span>
<div class="seg">
<button class:on={tags === 'keep'} onclick={() => (tags = 'keep')}>
Keep ({a.tags?.length ?? 0})
</button>
<button class:on={tags === 'both'} onclick={() => (tags = 'both')}>Union both</button>
</div>
</div>
<div class="row">
<span class="label">Pools</span>
<div class="seg">
<button class:on={pools === 'keep'} onclick={() => (pools = 'keep')}>Keep</button>
<button class:on={pools === 'both'} onclick={() => (pools = 'both')}>Union both</button>
</div>
</div>
<label class="del">
<input type="checkbox" bind:checked={deleteDiscarded} />
Move the “Other” file to trash after merging
</label>
{#if error}<p class="error">{error}</p>{/if}
</div>
<div class="foot">
<button class="btn ghost" onclick={onClose}>Cancel</button>
<button class="btn primary" onclick={submit} disabled={busy}>Merge</button>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 120;
background: rgba(0, 0, 0, 0.5);
}
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 121;
background-color: var(--color-bg-secondary);
border-radius: 14px 14px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
max-height: 88dvh;
display: flex;
flex-direction: column;
animation: slide-up 0.18s ease-out;
}
.sheet.busy {
opacity: 0.6;
pointer-events: none;
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.head {
display: flex;
align-items: center;
padding: 14px 16px 10px;
gap: 8px;
}
.title {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
}
.x {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
display: flex;
}
.x:hover {
color: var(--color-text-primary);
}
.body {
overflow-y: auto;
padding: 0 14px 8px;
}
.files {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.file {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 40%;
}
.badge {
font-size: 0.7rem;
font-weight: 600;
padding: 1px 7px;
border-radius: 6px;
}
.badge.keep {
background-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
color: var(--color-accent);
}
.badge.other {
background-color: var(--color-bg-elevated);
color: var(--color-text-muted);
}
.fname {
font-size: 0.78rem;
color: var(--color-text-muted);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swap {
background-color: var(--color-bg-elevated);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
color: var(--color-text-muted);
border-radius: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.swap:hover {
color: var(--color-accent);
border-color: var(--color-accent);
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-top: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.label {
font-size: 0.82rem;
color: var(--color-text-muted);
width: 74px;
flex-shrink: 0;
}
.seg {
display: flex;
flex: 1;
gap: 4px;
min-width: 0;
}
.seg button {
flex: 1;
min-width: 0;
padding: 6px 8px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-muted);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.seg button.on {
background-color: color-mix(in srgb, var(--color-accent) 22%, var(--color-bg-elevated));
color: var(--color-accent);
border-color: var(--color-accent);
}
.del {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 12px 0 4px;
}
.error {
color: var(--color-danger);
font-size: 0.85rem;
text-align: center;
}
.foot {
display: flex;
gap: 8px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.btn {
flex: 1;
height: 38px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
}
.btn.ghost {
background-color: var(--color-bg-elevated);
border-color: color-mix(in srgb, var(--color-accent) 25%, transparent);
color: var(--color-text-muted);
}
.btn.primary {
background-color: var(--color-accent);
color: var(--color-bg-primary);
font-weight: 600;
}
.btn.primary:disabled {
opacity: 0.6;
cursor: default;
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
import { get } from 'svelte/store';
import { authStore } from '$lib/stores/auth';
interface Props {
/** File id whose thumbnail to load. */
id: string;
alt?: string;
/** Square edge length in px. */
size?: number;
}
let { id, alt = '', size = 96 }: Props = $props();
let imgSrc = $state<string | null>(null);
let failed = $state(false);
// Thumbnails are auth-gated, so fetch with the bearer token and render the blob
// (mirrors FileCard's loader). Re-runs whenever the id changes.
$effect(() => {
const token = get(authStore).accessToken;
let objectUrl: string | null = null;
let cancelled = false;
imgSrc = null;
failed = false;
fetch(`/api/v1/files/${id}/thumbnail`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
.then((res) => (res.ok ? res.blob() : null))
.then((blob) => {
if (cancelled || !blob) {
if (!cancelled) failed = true;
return;
}
objectUrl = URL.createObjectURL(blob);
imgSrc = objectUrl;
})
.catch(() => {
if (!cancelled) failed = true;
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
});
</script>
<div class="thumb" style="width:{size}px;height:{size}px">
{#if imgSrc}
<img src={imgSrc} {alt} draggable="false" />
{:else if failed}
<div class="ph failed" aria-label="Failed to load"></div>
{:else}
<div class="ph loading" aria-label="Loading"></div>
{/if}
</div>
<style>
.thumb {
overflow: hidden;
border-radius: 8px;
background-color: var(--color-bg-elevated);
flex-shrink: 0;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.ph {
width: 100%;
height: 100%;
}
.ph.loading {
background: linear-gradient(
90deg,
var(--color-bg-elevated) 25%,
color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-elevated)) 50%,
var(--color-bg-elevated) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
.ph.failed {
background-color: color-mix(in srgb, var(--color-danger) 15%, var(--color-bg-elevated));
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
@@ -12,6 +12,7 @@
onFilterToggle: () => void; onFilterToggle: () => void;
onUpload?: () => void; onUpload?: () => void;
onTrash?: () => void; onTrash?: () => void;
onDuplicates?: () => void;
} }
let { let {
@@ -23,7 +24,8 @@
onOrderToggle, onOrderToggle,
onFilterToggle, onFilterToggle,
onUpload, onUpload,
onTrash onTrash,
onDuplicates
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -51,6 +53,20 @@
</button> </button>
{/if} {/if}
{#if onDuplicates}
<button class="icon-btn dup-btn" onclick={onDuplicates} title="Duplicates">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<rect x="2" y="2" width="8" height="8" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path
d="M5 12.5h6A1.5 1.5 0 0 0 12.5 11V5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
{/if}
{#if onTrash} {#if onTrash}
<button class="icon-btn trash-btn" onclick={onTrash} title="Trash"> <button class="icon-btn trash-btn" onclick={onTrash} title="Trash">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
+1
View File
@@ -722,6 +722,7 @@
onFilterToggle={() => (filterOpen = !filterOpen)} onFilterToggle={() => (filterOpen = !filterOpen)}
onUpload={() => uploader?.open()} onUpload={() => uploader?.open()}
onTrash={() => goto('/files/trash')} onTrash={() => goto('/files/trash')}
onDuplicates={() => goto('/files/duplicates')}
/> />
{#if filterOpen} {#if filterOpen}
@@ -0,0 +1,377 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { getDuplicates, dismissDuplicate, type DuplicateCluster } from '$lib/api/duplicates';
import Thumb from '$lib/components/file/Thumb.svelte';
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
import type { File } from '$lib/api/types';
const LIMIT = 20;
let clusters = $state<DuplicateCluster[]>([]);
let total = $state(0);
let loading = $state(false);
let initialLoaded = $state(false);
let error = $state('');
let busyKey = $state(''); // cluster currently performing an action
// Which file is the survivor for a given cluster (keyed by its file-id set).
let keepers = $state<Record<string, string>>({});
// Merge dialog state.
let mergeKeep = $state<File | null>(null);
let mergeDiscard = $state<File | null>(null);
$effect(() => {
if (!initialLoaded && !loading) void load();
});
function clusterKey(c: DuplicateCluster): string {
return c.files.map((f) => f.id).join(',');
}
function keeperId(c: DuplicateCluster): string {
return keepers[clusterKey(c)] ?? c.files[0]?.id ?? '';
}
async function load() {
if (loading) return;
loading = true;
error = '';
try {
const res = await getDuplicates(LIMIT, clusters.length);
clusters = [...clusters, ...(res.items ?? [])];
total = res.total ?? clusters.length;
} catch {
error = 'Failed to load duplicates';
} finally {
loading = false;
initialLoaded = true;
}
}
async function reload() {
clusters = [];
keepers = {};
total = 0;
initialLoaded = false;
await load();
}
function setKeeper(c: DuplicateCluster, id: string) {
keepers = { ...keepers, [clusterKey(c)]: id };
}
function openMerge(c: DuplicateCluster, other: File) {
const keep = c.files.find((f) => f.id === keeperId(c));
if (!keep) return;
mergeKeep = keep;
mergeDiscard = other;
}
async function deleteFile(c: DuplicateCluster, id: string) {
if (busyKey) return;
busyKey = clusterKey(c);
try {
await api.post('/files/bulk/delete', { file_ids: [id] });
await reload();
} catch {
error = 'Failed to delete file';
} finally {
busyKey = '';
}
}
async function notDuplicate(c: DuplicateCluster, other: File) {
if (busyKey) return;
busyKey = clusterKey(c);
try {
await dismissDuplicate(keeperId(c), other.id);
await reload();
} catch {
error = 'Failed to dismiss pair';
} finally {
busyKey = '';
}
}
function onMerged() {
mergeKeep = null;
mergeDiscard = null;
void reload();
}
let hasMore = $derived(clusters.length < total);
</script>
<svelte:head>
<title>Duplicates | Tanabata</title>
</svelte:head>
<div class="page">
<header>
<button class="back" onclick={() => goto('/files')} aria-label="Back to files">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M11 4l-5 5 5 5"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<span class="htitle">Duplicates{total ? ` (${total})` : ''}</span>
<button class="refresh" onclick={reload} title="Refresh" aria-label="Refresh">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</header>
<main>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if initialLoaded && clusters.length === 0 && !error}
<div class="empty">
<p>No duplicates found.</p>
<p class="hint">
The list reflects the last <code>dedup</code> run. New uploads appear after the next rescan.
</p>
</div>
{/if}
{#each clusters as c (clusterKey(c))}
{@const keep = keeperId(c)}
<section class="cluster" class:busy={busyKey === clusterKey(c)}>
<div class="files">
{#each c.files as f (f.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="file"
class:keep={f.id === keep}
role="button"
tabindex="0"
onclick={() => setKeeper(c, f.id)}
title="Click to keep this one"
>
<Thumb id={f.id} size={96} alt={f.original_name ?? ''} />
{#if f.id === keep}<span class="kbadge">Keep</span>{/if}
<span class="fname" title={f.original_name ?? ''}>{f.original_name ?? '—'}</span>
<span class="fmeta">{f.mime_type} · {f.tags?.length ?? 0} tags</span>
</div>
{/each}
</div>
<div class="actions">
{#each c.files.filter((f) => f.id !== keep) as other (other.id)}
<div class="actrow">
<span class="aname" title={other.original_name ?? ''}>{other.original_name ?? '—'}</span>
<button class="abtn" onclick={() => openMerge(c, other)}>Merge</button>
<button class="abtn" onclick={() => deleteFile(c, other.id)}>Delete</button>
<button class="abtn ghost" onclick={() => notDuplicate(c, other)}>Not a dup</button>
</div>
{/each}
</div>
</section>
{/each}
{#if hasMore}
<button class="more" onclick={load} disabled={loading}>
{loading ? 'Loading…' : 'Load more'}
</button>
{:else if loading && !initialLoaded}
<p class="loadingp">Loading…</p>
{/if}
</main>
</div>
{#if mergeKeep && mergeDiscard}
<DuplicateMergeDialog
keep={mergeKeep}
discard={mergeDiscard}
onResolved={onMerged}
onClose={() => {
mergeKeep = null;
mergeDiscard = null;
}}
/>
{/if}
<style>
.page {
display: flex;
flex-direction: column;
height: 100%;
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
}
.back,
.refresh {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-muted);
cursor: pointer;
}
.back:hover,
.refresh:hover {
color: var(--color-text-primary);
border-color: var(--color-accent);
}
.htitle {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
}
main {
flex: 1;
overflow-y: auto;
padding: 10px 12px calc(72px + env(safe-area-inset-bottom, 0px));
}
.error {
color: var(--color-danger);
font-size: 0.9rem;
text-align: center;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 40px 16px;
}
.empty .hint {
font-size: 0.82rem;
opacity: 0.8;
}
code {
font-family: monospace;
background-color: var(--color-bg-elevated);
padding: 0 4px;
border-radius: 4px;
}
.cluster {
background-color: var(--color-bg-secondary);
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
}
.cluster.busy {
opacity: 0.55;
pointer-events: none;
}
.files {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.file {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 96px;
cursor: pointer;
border-radius: 10px;
padding: 4px;
border: 2px solid transparent;
}
.file.keep {
border-color: var(--color-accent);
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.kbadge {
font-size: 0.68rem;
font-weight: 600;
color: var(--color-accent);
}
.fname {
font-size: 0.74rem;
max-width: 96px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-primary);
}
.fmeta {
font-size: 0.68rem;
color: var(--color-text-muted);
max-width: 96px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.actrow {
display: flex;
align-items: center;
gap: 6px;
}
.aname {
flex: 1;
min-width: 0;
font-size: 0.78rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abtn {
padding: 5px 10px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
flex-shrink: 0;
}
.abtn:hover {
border-color: var(--color-accent);
}
.abtn.ghost {
color: var(--color-text-muted);
}
.more {
display: block;
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.loadingp {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}
</style>
+178
View File
@@ -709,6 +709,89 @@ paths:
type: string type: string
format: uuid format: uuid
# --- Duplicate detection ---
/files/duplicates:
get:
tags: [Files]
summary: List duplicate clusters
description: >-
Groups of perceptually similar files (within the server's hash-distance
threshold), read from a precomputed pairs table — this never compares all
files on each call. Pairs are (re)built offline by the dedup tool, so the
result reflects state as of the last rescan. Only files the caller may view
are included; dismissed and trashed pairs are excluded.
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 50
description: Maximum number of clusters to return
- name: offset
in: query
schema:
type: integer
default: 0
minimum: 0
responses:
'200':
description: A page of duplicate clusters
content:
application/json:
schema:
$ref: '#/components/schemas/DuplicateClusterPage'
/files/duplicates/dismiss:
post:
tags: [Files]
summary: Mark two files as not duplicates
description: >-
Records a global "not a duplicate" decision so the pair stops appearing in
the duplicates view (it survives future rescans). The caller must be able
to view both files.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_id_a, file_id_b]
properties:
file_id_a:
type: string
format: uuid
file_id_b:
type: string
format: uuid
responses:
'204':
description: Pair dismissed
/files/duplicates/resolve:
post:
tags: [Files]
summary: Resolve a duplicate by merging two files
description: >-
Keeps one file and folds the chosen fields in from the other, then (by
default) trashes the other. The caller must be able to edit both files. To
simply delete one/both or to keep both, use the bulk-delete and dismiss
endpoints instead. Returns the updated survivor.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DuplicateResolve'
responses:
'200':
description: The updated surviving file
content:
application/json:
schema:
$ref: '#/components/schemas/File'
# --- File import --- # --- File import ---
/files/import: /files/import:
post: post:
@@ -1766,6 +1849,19 @@ components:
# --- File --- # --- File ---
File: File:
type: object type: object
required:
- id
- mime_type
- mime_extension
- content_datetime
- exif
- creator_id
- creator_name
- is_public
- is_deleted
- needs_review
- created_at
- tags
properties: properties:
id: id:
type: string type: string
@@ -1814,6 +1910,11 @@ components:
type: string type: string
format: date-time format: date-time
description: Extracted from UUID v7 description: Extracted from UUID v7
tags:
type: array
description: Tags assigned to the file
items:
$ref: '#/components/schemas/Tag'
FileUpdate: FileUpdate:
type: object type: object
@@ -1846,6 +1947,83 @@ components:
nullable: true nullable: true
description: Cursor for loading previous (backward) page; null if at the beginning description: Cursor for loading previous (backward) page; null if at the beginning
# --- Duplicates ---
DuplicateCluster:
type: object
properties:
files:
type: array
description: Two or more mutually similar files
items:
$ref: '#/components/schemas/File'
DuplicateClusterPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/DuplicateCluster'
total:
type: integer
description: Total number of clusters (not files)
offset:
type: integer
limit:
type: integer
MergeScalarChoice:
type: string
enum: [keep, discard]
default: keep
description: Take this field's value from the kept file or the discarded one
MergeRelationChoice:
type: string
enum: [keep, both]
default: keep
description: Keep only the survivor's relations, or union both files' relations
DuplicateResolve:
type: object
required: [keep, discard]
properties:
keep:
type: string
format: uuid
description: The file to keep (the survivor)
discard:
type: string
format: uuid
description: The other file in the pair
delete_discarded:
type: boolean
default: true
description: Move the discarded file to trash after merging
fields:
type: object
description: Per-field source for the merge; omitted fields default to "keep"
properties:
original_name:
$ref: '#/components/schemas/MergeScalarChoice'
notes:
$ref: '#/components/schemas/MergeScalarChoice'
content_datetime:
$ref: '#/components/schemas/MergeScalarChoice'
is_public:
$ref: '#/components/schemas/MergeScalarChoice'
metadata:
type: string
enum: [keep, discard, merge]
default: keep
description: >-
Keep or take the discarded file's metadata object, or shallow-merge
them with the survivor winning on key conflicts
tags:
$ref: '#/components/schemas/MergeRelationChoice'
pools:
$ref: '#/components/schemas/MergeRelationChoice'
# --- Tag --- # --- Tag ---
Tag: Tag:
type: object type: object