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>
Opening an original by URL (?access_token=) baked in the 15-minute access
token, so a long video opened in a new tab stopped streaming once that token
expired mid-playback: the access token can't be refreshed in an already-opened
tab, and its next Range request 401'd.
Add a content token: a signed, single-file capability (typ=content, fid claim)
with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no
session id, so it survives refresh rotation and outlives the short access TTL.
POST /files/:id/content-token mints one after the same view-ACL check content
serving does; GET /files/:id/content now runs under content-aware auth that
accepts either a normal access token or a content token scoped to that file.
View permission is still enforced against the token's user, so the token only
changes when a file may be read by URL, never which files. It's a bearer
capability for that one file until expiry, hence the bounded, configurable TTL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>