Add an ephemeral similarity check page

- /similar (nav: Similar): drop a file to get the exact MD5 match, the
  perceptual matches against the library, and e621 IQDB candidates
  (auto-run for images when credentials are configured). Read-only —
  nothing enters the library.
- SimilarityCheck model + /api/similarity/ (create/list/retrieve/delete)
  with signed preview URLs and an expires_at timestamp.
- Temp files are wiped on startup (AppConfig.ready, file-only so no
  database access during initialization), lazily past
  SIMILARITY_TTL_MINUTES (default 30, env-overridable), on delete, and
  by manage.py cleanup_similarity.
- uploadFile() takes a target path; .env.example documents the TTL.
This commit is contained in:
2026-09-17 18:22:11 -05:00
parent cea34422ec
commit 1c2cb8d468
15 changed files with 875 additions and 5 deletions
+9
View File
@@ -22,6 +22,7 @@ import Md5Redirect from "@/features/library/Md5Redirect";
import OnlinePage from "@/features/online/OnlinePage";
import PoolDetailPage from "@/features/pools/PoolDetailPage";
import PoolsPage from "@/features/pools/PoolsPage";
import SimilarPage from "@/features/similar/SimilarPage";
import UploadPage from "@/features/upload/UploadPage";
import UsersPage from "@/features/users/UsersPage";
import { isAgeVerified, markAgeVerified } from "@/lib/age";
@@ -107,6 +108,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/similar"
element={
<RequireAuth>
<SimilarPage />
</RequireAuth>
}
/>
<Route
path="/upload"
element={
+1
View File
@@ -62,6 +62,7 @@ export function AppShell() {
{ to: "/online", label: "Online" },
{ to: "/pools", label: "Pools" },
...(user ? [{ to: "/followed", label: "Followed" }] : []),
...(user ? [{ to: "/similar", label: "Similar" }] : []),
...(user?.can_upload
? [
{ to: "/upload", label: "Upload" },
@@ -0,0 +1,492 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, ImagePlus, Trash2, X } from "lucide-react";
import {
useRef,
useState,
type ChangeEvent,
type DragEvent,
} from "react";
import { Link } from "react-router-dom";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
effectiveCredentials,
iqdbSearch,
RATING_LABELS,
type E621IqdbResult,
} from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format";
import type { SimilarityCheck } from "@/lib/types";
import { useE621 } from "@/store/e621";
const VIDEO_RE = /\.(mp4|webm)$/i;
const ratingPill: Record<string, string> = {
s: "bg-ctp-green text-ctp-crust",
q: "bg-ctp-peach text-ctp-crust",
e: "bg-ctp-red text-ctp-crust",
};
export default function SimilarPage() {
const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials);
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [check, setCheck] = useState<SimilarityCheck | null>(null);
const [uploading, setUploading] = useState(false);
const [percent, setPercent] = useState(0);
const [error, setError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [iqdb, setIqdb] = useState<E621IqdbResult[] | null>(null);
const [iqdbBusy, setIqdbBusy] = useState(false);
const [iqdbError, setIqdbError] = useState<string | null>(null);
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
const configured = Boolean(credentials?.configured);
const deleteMutation = useMutation({
mutationFn: () =>
api(`/api/similarity/${check?.check_id}/`, { method: "DELETE" }),
onSuccess: () => {
setCheck(null);
setFile(null);
setIqdb(null);
setSelected(null);
setError(null);
setIqdbError(null);
if (inputRef.current) inputRef.current.value = "";
},
});
async function startCheck(selectedFile: File) {
setFile(selectedFile);
setCheck(null);
setIqdb(null);
setSelected(null);
setError(null);
setIqdbError(null);
setPercent(0);
setUploading(true);
try {
const data = await uploadFile<SimilarityCheck>(
selectedFile,
{},
setPercent,
"/api/similarity/",
);
setCheck(data);
void queryClient.invalidateQueries({ queryKey: ["files"] });
} catch (err) {
setError(errorMessage(err));
} finally {
setUploading(false);
}
if (VIDEO_RE.test(selectedFile.name)) return;
if (!configured) return;
setIqdbBusy(true);
try {
const results = await iqdbSearch(
effectiveCredentials(credentials),
selectedFile,
selectedFile.name,
);
setIqdb(results);
} catch (err) {
setIqdbError(e621ErrorMessage(err));
} finally {
setIqdbBusy(false);
}
}
function handleInput(event: ChangeEvent<HTMLInputElement>) {
const chosen = event.target.files?.[0];
if (chosen) void startCheck(chosen);
}
function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
setDragging(false);
const dropped = event.dataTransfer.files?.[0];
if (dropped) void startCheck(dropped);
}
const isVideo = file ? VIDEO_RE.test(file.name) : false;
const exact = check?.results.exact ?? null;
const matches = check?.results.matches ?? [];
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Similarity check</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Drop a file to see whether it is already in the library and what it
looks like on e621. Nothing is added to the library — the temporary
copy is deleted automatically after a while and on the next server
start.
</p>
</header>
<div
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-10 text-center transition",
dragging
? "border-ctp-mauve bg-ctp-mauve/10"
: "border-ctp-surface1 bg-ctp-base",
)}
>
<ImagePlus className="h-6 w-6 text-ctp-overlay0" />
<p className="text-sm text-ctp-subtext0">
Drag a file here, or{" "}
<button
type="button"
className="text-ctp-blue hover:underline"
onClick={() => inputRef.current?.click()}
>
choose one
</button>
</p>
<p className="text-[11px] text-ctp-overlay0">
Images get the full check (MD5, visual hashes, e621 IQDB); videos
only get the exact MD5 match.
</p>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={handleInput}
/>
</div>
{uploading ? (
<div className="flex items-center gap-3">
<div className="h-1.5 w-48 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-all"
style={{ width: `${percent}%` }}
/>
</div>
<span className="font-mono text-xs text-ctp-overlay0">
uploading… {percent}%
</span>
</div>
) : null}
{error ? <p className="text-sm text-ctp-red">{error}</p> : null}
{check ? (
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="flex flex-col gap-4">
<section className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
<div className="flex max-h-72 items-center justify-center overflow-hidden bg-ctp-mantle">
{check.file_url ? (
isVideo ? (
<video
src={check.file_url}
controls
loop
muted
className="max-h-72 w-full object-contain"
/>
) : (
<img
src={check.file_url}
alt={check.original_filename}
className="max-h-72 w-full object-contain"
/>
)
) : null}
</div>
<div className="p-3">
<p
className="truncate font-mono text-xs text-ctp-subtext1"
title={check.original_filename}
>
{check.original_filename}
</p>
<p className="mt-1 truncate font-mono text-[10px] text-ctp-overlay0">
{check.md5}
</p>
<p className="mt-1 text-[11px] text-ctp-overlay0">
{formatBytes(check.size)} · expires{" "}
{formatDate(check.expires_at)}
</p>
<div className="mt-3 flex items-center gap-2">
<Button
variant="danger"
className="px-2 py-1 text-xs"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
<Trash2 className="h-3.5 w-3.5" /> Delete now
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setCheck(null);
setFile(null);
setIqdb(null);
setSelected(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
<X className="h-3.5 w-3.5" /> Clear
</Button>
</div>
</div>
</section>
</aside>
<div className="flex min-w-0 flex-col gap-6">
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">
In the library (exact MD5)
</h2>
{exact ? (
<Link
to={`/detail/${exact.j_id}`}
className="mt-3 flex items-center gap-3 rounded-md border border-ctp-green/40 bg-ctp-green/10 p-2 transition hover:bg-ctp-green/15"
>
{exact.thumbnail_url ? (
<img
src={exact.thumbnail_url}
alt={exact.j_id}
className="h-14 w-14 rounded object-cover"
/>
) : null}
<span className="min-w-0">
<span className="block font-mono text-sm text-ctp-text">
{exact.j_id}
</span>
<span className="block truncate text-xs text-ctp-overlay0">
{exact.filename} · {formatBytes(exact.size)} ·{" "}
{exact.location_count} cop
{exact.location_count === 1 ? "y" : "ies"}
{exact.e621_post_id
? ` · e621 #${exact.e621_post_id}`
: ""}
</span>
</span>
</Link>
) : (
<p className="mt-3 text-xs text-ctp-overlay0">
Not in the library — no item has this MD5.
</p>
)}
</section>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Visually similar in the library{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{matches.length}
</span>
</h2>
{isVideo ? (
<p className="mt-3 text-xs text-ctp-overlay0">
Visual hashing works on images only.
</p>
) : matches.length === 0 ? (
<p className="mt-3 text-xs text-ctp-overlay0">
Nothing above the similarity threshold.
</p>
) : (
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{matches.map((match) => (
<Link
key={match.j_id}
to={`/detail/${match.j_id}`}
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
>
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
{match.thumbnail_url ? (
<img
src={match.thumbnail_url}
alt={match.j_id}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
/>
) : null}
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] text-ctp-subtext0">
{match.similarity}%
</span>
</span>
<span className="block px-2 py-1.5 font-mono text-[11px] text-ctp-subtext0">
{match.j_id}
</span>
</Link>
))}
</div>
)}
</section>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
e621 IQDB
</h2>
{iqdbBusy ? (
<span className="flex items-center gap-1.5 text-[11px] text-ctp-overlay0">
<Spinner className="h-3 w-3" /> searching…
</span>
) : iqdb ? (
<span className="font-mono text-[11px] text-ctp-overlay0">
{iqdb.length} candidate(s)
</span>
) : null}
</div>
{isVideo ? (
<p className="mt-3 text-xs text-ctp-overlay0">
IQDB works on images only.
</p>
) : !configured ? (
<p className="mt-3 text-xs text-ctp-overlay0">
<Link to="/account" className="text-ctp-blue hover:underline">
Add e621 credentials
</Link>{" "}
to reverse-search this image on e621.
</p>
) : iqdbError ? (
<p className="mt-3 text-xs text-ctp-red">{iqdbError}</p>
) : iqdbBusy && !iqdb ? (
<div className="mt-3 flex justify-center py-4">
<Spinner className="h-4 w-4" />
</div>
) : iqdb && iqdb.length === 0 ? (
<p className="mt-3 text-xs text-ctp-overlay0">
No similar posts found on e621.
</p>
) : iqdb ? (
<>
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 xl:grid-cols-6">
{iqdb.map((candidate) => {
const isExact =
candidate.md5 !== null &&
check.md5 === candidate.md5;
return (
<button
key={`${candidate.post_id}-${candidate.md5}`}
type="button"
disabled={candidate.post_id === null}
onClick={() => setSelected(candidate)}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition",
selected?.post_id === candidate.post_id
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<span className="relative block aspect-square overflow-hidden">
{candidate.preview_url ? (
<img
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{candidate.rating ? (
<span
className={cn(
"absolute left-1 top-1 rounded-full px-1 py-0.5 font-mono text-[9px] font-semibold uppercase",
ratingPill[candidate.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{candidate.rating}
</span>
) : null}
{isExact ? (
<span className="absolute right-1 top-1 rounded-full bg-ctp-green/90 px-1 py-0.5 font-mono text-[9px] font-semibold uppercase text-ctp-crust">
MD5
</span>
) : null}
</span>
<span className="block px-1 py-0.5 font-mono text-[10px] text-ctp-overlay0">
#{candidate.post_id}
{candidate.score !== null
? ` · ${Math.round(candidate.score)}%`
: ""}
</span>
</button>
);
})}
</div>
{selected ? (
<div className="mt-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<p className="font-mono text-xs text-ctp-subtext1">
Post #{selected.post_id}
</p>
<p className="mt-1 text-xs text-ctp-overlay0">
{selected.rating
? (RATING_LABELS[selected.rating] ??
selected.rating)
: "Unknown rating"}
{selected.score_total !== null
? ` · ▲ ${selected.score_total}`
: ""}
{selected.fav_count !== null
? ` · ${selected.fav_count} favs`
: ""}
{selected.width && selected.height
? ` · ${selected.width}×${selected.height}`
: ""}
</p>
{selected.tags_preview.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{selected.tags_preview.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-surface1 px-1 py-0.5 font-mono text-[10px] text-ctp-subtext0"
>
{tag}
</span>
))}
</div>
) : null}
<div className="mt-3 flex items-center gap-3">
<Link
to={`/detail/${selected.post_id}`}
className="inline-flex items-center gap-1.5 text-xs text-ctp-blue hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" /> View post
</Link>
</div>
</div>
) : (
<p className="mt-2 text-[11px] text-ctp-overlay0">
Select a candidate for its rating, score, favorites and
tags.
</p>
)}
</>
) : (
<p className="mt-3 text-xs text-ctp-overlay0">
Waiting for the library check…
</p>
)}
</section>
</div>
</div>
) : null}
</div>
);
}
+5 -4
View File
@@ -85,11 +85,12 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
return (await response.json()) as T;
}
export function uploadFile(
export function uploadFile<T = TempUpload>(
file: File,
fields: Record<string, string> = {},
onProgress?: (percent: number) => void,
): Promise<TempUpload> {
path = "/api/uploads/",
): Promise<T> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("file", file);
@@ -98,7 +99,7 @@ export function uploadFile(
}
const request = new XMLHttpRequest();
request.open("POST", "/api/uploads/");
request.open("POST", path);
const token = getToken();
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
request.upload.addEventListener("progress", (event) => {
@@ -114,7 +115,7 @@ export function uploadFile(
data = null;
}
if (request.status >= 200 && request.status < 300) {
resolve(data as TempUpload);
resolve(data as T);
} else {
reject(new ApiError(request.status, data));
}
+14
View File
@@ -265,6 +265,20 @@ export interface FollowFeed {
results: FollowFeedEntry[];
}
export interface SimilarityCheck {
check_id: string;
original_filename: string;
md5: string;
size: number;
file_url: string | null;
results: {
exact: DuplicateEntry | null;
matches: DuplicateEntry[];
};
expires_at: string;
created_at: string;
}
export interface FollowCloud {
status: "ready" | "building";
stale: boolean;