IQDB reverse search from the library detail
- New IQDB card on local item pages: fetches the signed raw file, POSTs it to e621's /iqdb_queries.json with the user's credentials, and lists candidates as tiles (thumbnail, rating, score%) with exact-MD5 markers. - Selecting a candidate opens a detail panel (rating, score, favs, size, tag preview) and linking is an explicit confirm that reuses the e621 match endpoint; videos show a note since IQDB is image-only. - Guests don't see the card; linking follows the uploader/staff rule.
This commit is contained in:
+5
-3
@@ -39,9 +39,11 @@ they land.
|
|||||||
`manage.py match_e621`
|
`manage.py match_e621`
|
||||||
- [x] Show match status + e621 metadata in the library: status badge and
|
- [x] Show match status + e621 metadata in the library: status badge and
|
||||||
match card on the detail, and `not_found` / `deleted` status filters
|
match card on the detail, and `not_found` / `deleted` status filters
|
||||||
- [ ] **IQDB reverse search**
|
- [x] **IQDB reverse search**
|
||||||
- [ ] Search from a local file (`POST /iqdb_queries.json`)
|
- [x] Search from a local file (`POST /iqdb_queries.json` from the library detail,
|
||||||
- [ ] Show candidate posts to link
|
using the signed raw file)
|
||||||
|
- [x] Show candidate posts to link (thumbnail, rating, score, favs, tags;
|
||||||
|
select then confirm — same flow as uploads, exact MD5 marked)
|
||||||
- [ ] **Follows**
|
- [ ] **Follows**
|
||||||
- [ ] Follow tags/pools, Followed screens with unseen badges
|
- [ ] Follow tags/pools, Followed screens with unseen badges
|
||||||
- [ ] "Followed" nav entry
|
- [ ] "Followed" nav entry
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ScanSearch } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button, Spinner } from "@/components/ui";
|
||||||
|
import { api, errorMessage } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import {
|
||||||
|
e621ErrorMessage,
|
||||||
|
effectiveCredentials,
|
||||||
|
iqdbSearch,
|
||||||
|
RATING_LABELS,
|
||||||
|
type E621IqdbResult,
|
||||||
|
} from "@/lib/e621";
|
||||||
|
import type { MediaItem } from "@/lib/types";
|
||||||
|
import { useE621 } from "@/store/e621";
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Notice = { kind: "ok" | "warn" | "error"; text: string } | null;
|
||||||
|
|
||||||
|
export function IqdbCard({
|
||||||
|
item,
|
||||||
|
canLink,
|
||||||
|
}: {
|
||||||
|
item: MediaItem;
|
||||||
|
canLink: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const credentials = useE621((state) => state.credentials);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [candidates, setCandidates] = useState<E621IqdbResult[] | null>(null);
|
||||||
|
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
|
||||||
|
const [notice, setNotice] = useState<Notice>(null);
|
||||||
|
|
||||||
|
const isImage = item.kind === "image";
|
||||||
|
const configured = Boolean(credentials?.configured);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
setSelected(null);
|
||||||
|
setCandidates(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(item.raw_url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Could not load the file (HTTP ${response.status}).`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const results = await iqdbSearch(
|
||||||
|
effectiveCredentials(credentials),
|
||||||
|
blob,
|
||||||
|
item.filename,
|
||||||
|
);
|
||||||
|
setCandidates(results);
|
||||||
|
if (results.length === 0) {
|
||||||
|
setNotice({ kind: "warn", text: "No similar posts found on e621." });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({ kind: "error", text: e621ErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkMutation = useMutation({
|
||||||
|
mutationFn: (postId: number) =>
|
||||||
|
api<MediaItem & { md5_match?: boolean }>(
|
||||||
|
`/api/files/${item.j_id}/match/`,
|
||||||
|
{ method: "POST", json: { post_id: postId } },
|
||||||
|
),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] });
|
||||||
|
setSelected(null);
|
||||||
|
setNotice(
|
||||||
|
data.md5_match
|
||||||
|
? {
|
||||||
|
kind: "ok",
|
||||||
|
text: `Linked to post #${data.e621_post_id} — MD5 matches.`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
kind: "warn",
|
||||||
|
text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
setNotice({ kind: "error", text: errorMessage(error) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">IQDB search</h2>
|
||||||
|
{candidates && candidates.length > 0 ? (
|
||||||
|
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||||||
|
{candidates.length} candidates
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-ctp-overlay0">
|
||||||
|
Reverse-search e621 with this file to find its original post.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="mt-3 w-full"
|
||||||
|
disabled={busy || !isImage || !configured}
|
||||||
|
onClick={() => void search()}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Spinner className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ScanSearch className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{busy ? "Searching…" : "Search e621"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!configured ? (
|
||||||
|
<p className="mt-2 text-[11px] text-ctp-overlay0">
|
||||||
|
Configure e621 credentials in Account to use IQDB.
|
||||||
|
</p>
|
||||||
|
) : !isImage ? (
|
||||||
|
<p className="mt-2 text-[11px] text-ctp-overlay0">
|
||||||
|
IQDB works on images only.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-2 text-xs",
|
||||||
|
notice.kind === "ok"
|
||||||
|
? "text-ctp-green"
|
||||||
|
: notice.kind === "warn"
|
||||||
|
? "text-ctp-yellow"
|
||||||
|
: "text-ctp-red",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{candidates && candidates.length > 0 ? (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||||
|
{candidates.map((candidate) => {
|
||||||
|
const exact = candidate.md5 !== null && candidate.md5 === item.md5;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${candidate.post_id}-${candidate.md5}`}
|
||||||
|
type="button"
|
||||||
|
disabled={linkMutation.isPending || 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}
|
||||||
|
{exact ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="mt-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-mono text-xs text-ctp-subtext1">
|
||||||
|
Post #{selected.post_id}
|
||||||
|
</p>
|
||||||
|
{selected.md5 === item.md5 ? (
|
||||||
|
<span className="rounded-full bg-ctp-green/15 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase text-ctp-green">
|
||||||
|
MD5 match
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!canLink || linkMutation.isPending || selected.post_id === null
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
selected.post_id !== null &&
|
||||||
|
linkMutation.mutate(selected.post_id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{linkMutation.isPending ? "Linking…" : "Link selected post"}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
to={`/detail/${selected.post_id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="text-xs text-ctp-blue hover:underline"
|
||||||
|
>
|
||||||
|
View post
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-ctp-overlay0">
|
||||||
|
{canLink
|
||||||
|
? "Linking stores this post's metadata on the item."
|
||||||
|
: "Only the uploader or staff can link posts."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{candidates && candidates.length > 0 && !selected ? (
|
||||||
|
<p className="mt-2 text-[11px] text-ctp-overlay0">
|
||||||
|
Select a candidate first, then confirm the link.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/components/ui";
|
import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/components/ui";
|
||||||
import { TagChip } from "@/components/TagChip";
|
import { TagChip } from "@/components/TagChip";
|
||||||
import { DetailShell } from "@/features/detail/DetailShell";
|
import { DetailShell } from "@/features/detail/DetailShell";
|
||||||
|
import { IqdbCard } from "@/features/library/IqdbCard";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { isTypingTarget } from "@/lib/dom";
|
import { isTypingTarget } from "@/lib/dom";
|
||||||
@@ -697,6 +698,10 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
|||||||
|
|
||||||
<MatchCard key={item.j_id} item={item} canEdit={canEdit} />
|
<MatchCard key={item.j_id} item={item} canEdit={canEdit} />
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<IqdbCard key={item.j_id} item={item} canLink={canEdit} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
Locations ({item.locations.length})
|
Locations ({item.locations.length})
|
||||||
|
|||||||
Reference in New Issue
Block a user