Enrich IQDB candidates; link downloads the e621 original

- IQDB responses carry no preview/file data, so candidates only showed an
  ID; the SPA now enriches them with one batched posts lookup (preview,
  rating, score, favourites, dimensions, tag preview)
- Candidate tiles are selectable instead of instantly resolving: picking
  one shows its info and an explicit 'Link selected post' button
- Linking a post now fetches the e621 original into the library and
  drops the staged upload; when the staged file's MD5 already equals the
  post's file, the staged copy is moved instead (identical bytes)
- Keep the file URL in stored e621 metadata; sanitize the new candidate
  fields server-side
This commit is contained in:
2026-09-17 11:24:01 -05:00
parent 7deb6084b6
commit b71ec729e0
5 changed files with 234 additions and 23 deletions
+96 -10
View File
@@ -17,6 +17,7 @@ import {
fetchPost,
fetchPostsByMd5,
iqdbSearch,
RATING_LABELS,
} from "@/lib/e621";
import { formatBytes } from "@/lib/format";
import type {
@@ -45,6 +46,12 @@ const RESOLUTION_LABELS: Record<string, string> = {
custom: "custom metadata",
};
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",
};
const ratingChoices: { value: Rating; label: string; active: string }[] = [
{
value: "",
@@ -205,6 +212,7 @@ function MetadataModal({
const credentials = useE621((state) => state.credentials);
const [tab, setTab] = useState<"link" | "custom">("link");
const [postId, setPostId] = useState("");
const [selected, setSelected] = useState<E621IqdbCandidate | null>(null);
const [rating, setRating] = useState<Rating>(temp.custom_rating);
const [tags, setTags] = useState(temp.custom_tags.join(", "));
const [notes, setNotes] = useState(temp.custom_notes);
@@ -354,17 +362,39 @@ function MetadataModal({
key={`${candidate.post_id}-${candidate.md5}`}
type="button"
disabled={busy || !candidate.post_id}
onClick={() => void linkCandidate(candidate)}
className="overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle text-left transition hover:border-ctp-surface2 disabled:opacity-50"
onClick={() => setSelected(candidate)}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50",
selected?.post_id === candidate.post_id
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
{candidate.preview_url ? (
<img
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="aspect-square w-full object-cover"
/>
) : null}
<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}
</span>
<span className="block px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
#{candidate.post_id}
{candidate.score !== null
@@ -374,6 +404,62 @@ function MetadataModal({
</button>
))}
</div>
{selected ? (
<div className="mt-2 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 !== undefined
? ` · ▲ ${selected.score_total}`
: ""}
{selected.fav_count !== null &&
selected.fav_count !== undefined
? ` · ${selected.fav_count} favs`
: ""}
{selected.width && selected.height
? ` · ${selected.width}×${selected.height}`
: ""}
</p>
{selected.tags_preview &&
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
onClick={() => void linkCandidate(selected)}
disabled={busy || !selected.post_id}
>
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">
Linking downloads the post's file into the library and
drops this staged upload.
</p>
</div>
) : null}
</div>
) : (
<p className="text-xs text-ctp-overlay0">
+58 -1
View File
@@ -322,6 +322,26 @@ export interface E621IqdbResult {
preview_url: string | null;
rating: string | null;
md5: string | null;
score_total: number | null;
fav_count: number | null;
width: number | null;
height: number | null;
tags_preview: string[];
}
function flattenTagPreview(tags: unknown, limit = 8): string[] {
if (!tags || typeof tags !== "object") return [];
const out: string[] = [];
for (const value of Object.values(tags as Record<string, unknown>)) {
if (!Array.isArray(value)) continue;
for (const tag of value) {
if (typeof tag === "string" && !out.includes(tag)) {
out.push(tag);
if (out.length >= limit) return out;
}
}
}
return out;
}
function normalizeIqdbEntry(entry: unknown): E621IqdbResult | null {
@@ -344,12 +364,18 @@ function normalizeIqdbEntry(entry: unknown): E621IqdbResult | null {
: typeof post.id === "number"
? post.id
: null;
const scoreTotal = post.score;
return {
post_id: postId,
score: typeof record.score === "number" ? record.score : null,
preview_url: typeof preview.url === "string" ? preview.url : null,
rating: typeof post.rating === "string" ? post.rating : null,
md5: typeof file.md5 === "string" ? file.md5 : null,
score_total: typeof scoreTotal === "number" ? scoreTotal : null,
fav_count: typeof post.fav_count === "number" ? post.fav_count : null,
width: typeof file.width === "number" ? file.width : null,
height: typeof file.height === "number" ? file.height : null,
tags_preview: flattenTagPreview(post.tags),
};
}
@@ -371,13 +397,44 @@ export async function iqdbSearch(
: Array.isArray((data as { matches?: unknown[] })?.matches)
? (data as { matches: unknown[] }).matches
: [];
return list
const candidates = list
.map(normalizeIqdbEntry)
.filter(
(entry): entry is E621IqdbResult =>
entry !== null && (entry.post_id !== null || entry.preview_url !== null),
)
.slice(0, 10);
// The IQDB payload carries no preview/file data; enrich with one batched
// post lookup so candidates can show what they actually are.
const ids = candidates
.map((candidate) => candidate.post_id)
.filter((id): id is number => typeof id === "number");
if (ids.length > 0) {
try {
const posts = await fetchPostsByIds(credentials, ids, signal);
const byId = new Map(posts.map((post) => [post.id, post]));
return candidates.map((candidate) => {
const post =
candidate.post_id !== null ? byId.get(candidate.post_id) : undefined;
if (!post) return candidate;
return {
...candidate,
preview_url: post.preview.url ?? candidate.preview_url,
rating: post.rating ?? candidate.rating,
md5: post.file.md5 ?? candidate.md5,
score_total: post.score.total,
fav_count: post.fav_count,
width: post.file.width,
height: post.file.height,
tags_preview: flattenTagPreview(post.tags),
};
});
} catch {
// Keep the base candidates when the enrichment request fails.
}
}
return candidates;
}
export function postIsBlacklisted(
+5
View File
@@ -113,6 +113,11 @@ export interface E621IqdbCandidate {
preview_url: string | null;
rating: string | null;
md5: string | null;
score_total?: number | null;
fav_count?: number | null;
width?: number | null;
height?: number | null;
tags_preview?: string[];
}
export interface TempUpload {