feat: add tape identifier dropzone on /zxdb

Client computes MD5 + size in-browser, server action looks up
software_hashes to identify tape files against ZXDB entries.

- src/utils/md5.ts: pure-JS MD5 for browser (Web Crypto lacks MD5)
- src/app/zxdb/actions.ts: server action identifyTape()
- src/app/zxdb/TapeIdentifier.tsx: dropzone client component
- src/server/repo/zxdb.ts: lookupByMd5() joins hashes→downloads→entries
- src/app/zxdb/page.tsx: mount TapeIdentifier between hero and nav grid

opus-4-6@McFiver
This commit is contained in:
2026-02-17 16:34:48 +00:00
parent fc513c580b
commit 8624050614
5 changed files with 434 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
"use client";
import { useState, useRef, useCallback } from "react";
import Link from "next/link";
import { computeMd5 } from "@/utils/md5";
import { identifyTape } from "./actions";
import type { TapeMatch } from "@/server/repo/zxdb";
const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"];
type State =
| { kind: "idle" }
| { kind: "hashing" }
| { kind: "identifying" }
| { kind: "results"; matches: TapeMatch[]; fileName: string }
| { kind: "not-found"; fileName: string }
| { kind: "error"; message: string };
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function TapeIdentifier() {
const [state, setState] = useState<State>({ kind: "idle" });
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const processFile = useCallback(async (file: File) => {
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
if (!SUPPORTED_EXTS.includes(ext)) {
setState({
kind: "error",
message: `Unsupported file type "${ext}". Supported: ${SUPPORTED_EXTS.join(", ")}`,
});
return;
}
setState({ kind: "hashing" });
try {
const md5 = await computeMd5(file);
setState({ kind: "identifying" });
const matches = await identifyTape(md5, file.size);
if (matches.length > 0) {
setState({ kind: "results", matches, fileName: file.name });
} else {
setState({ kind: "not-found", fileName: file.name });
}
} catch {
setState({ kind: "error", message: "Something went wrong. Please try again." });
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
},
[processFile]
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
// Reset so re-selecting the same file triggers change
e.target.value = "";
},
[processFile]
);
const reset = useCallback(() => {
setState({ kind: "idle" });
}, []);
// Dropzone view (idle, hashing, identifying, error)
if (state.kind === "results" || state.kind === "not-found") {
return (
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
Tape Identifier
</h5>
{state.kind === "results" ? (
<>
<p className="text-secondary mb-2">
<strong>{state.fileName}</strong> matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}:
</p>
<div className="list-group list-group-flush mb-3">
{state.matches.map((m) => (
<div key={m.downloadId} className="list-group-item px-0">
<div className="d-flex justify-content-between align-items-start">
<div>
<Link href={`/zxdb/entries/${m.entryId}`} className="fw-semibold text-decoration-none">
{m.entryTitle}
</Link>
<div className="text-secondary small mt-1">
{m.innerPath}
</div>
</div>
<div className="text-end text-secondary small text-nowrap ms-3">
<div>{formatBytes(m.sizeBytes)}</div>
<div className="font-monospace" style={{ fontSize: "0.75rem" }}>{m.md5}</div>
</div>
</div>
</div>
))}
</div>
</>
) : (
<p className="text-secondary mb-3">
No matching tape found in ZXDB for <strong>{state.fileName}</strong>.
</p>
)}
<button className="btn btn-outline-primary btn-sm" onClick={reset}>
Identify another tape
</button>
</div>
</div>
);
}
const isProcessing = state.kind === "hashing" || state.kind === "identifying";
return (
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title d-flex align-items-center gap-2 mb-3">
<span className="bi bi-cassette" style={{ fontSize: 22 }} aria-hidden />
Tape Identifier
</h5>
<div
className={`rounded-3 p-4 text-center ${dragOver ? "bg-primary bg-opacity-10 border-primary" : "border-secondary border-opacity-25"}`}
style={{
border: "2px dashed",
cursor: isProcessing ? "wait" : "pointer",
transition: "background-color 0.15s, border-color 0.15s",
}}
onDragOver={(e) => {
e.preventDefault();
if (!isProcessing) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={isProcessing ? (e) => e.preventDefault() : handleDrop}
onClick={isProcessing ? undefined : () => inputRef.current?.click()}
>
{isProcessing ? (
<div className="py-2">
<div className="spinner-border spinner-border-sm text-primary me-2" role="status" />
<span className="text-secondary">
{state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"}
</span>
</div>
) : (
<>
<div className="mb-2">
<span className="bi bi-cloud-arrow-up" style={{ fontSize: 32, opacity: 0.5 }} aria-hidden />
</div>
<p className="mb-1 text-secondary">
Drop a tape file to identify it
</p>
<p className="mb-0 small text-secondary">
{SUPPORTED_EXTS.join(" ")} &mdash; or{" "}
<span className="text-primary" style={{ textDecoration: "underline", cursor: "pointer" }}>
choose file
</span>
</p>
</>
)}
<input
ref={inputRef}
type="file"
accept={SUPPORTED_EXTS.join(",")}
className="d-none"
onChange={handleFileInput}
/>
</div>
{state.kind === "error" && (
<div className="alert alert-warning mt-3 mb-0 py-2 small">
{state.message}
</div>
)}
</div>
</div>
);
}

22
src/app/zxdb/actions.ts Normal file
View File

@@ -0,0 +1,22 @@
"use server";
import { lookupByMd5, type TapeMatch } from "@/server/repo/zxdb";
export async function identifyTape(
md5: string,
sizeBytes: number
): Promise<TapeMatch[]> {
// Validate input shape
if (!/^[0-9a-f]{32}$/i.test(md5)) return [];
if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return [];
const matches = await lookupByMd5(md5);
// If multiple matches and size can disambiguate, filter by size
if (matches.length > 1) {
const bySz = matches.filter((m) => m.sizeBytes === sizeBytes);
if (bySz.length > 0) return bySz;
}
return matches;
}

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import TapeIdentifier from "./TapeIdentifier";
export const metadata = { export const metadata = {
title: "ZXDB Explorer", title: "ZXDB Explorer",
@@ -57,6 +58,18 @@ export default async function Page() {
</div> </div>
</section> </section>
<section className="row g-3">
<div className="col-lg-8">
<TapeIdentifier />
</div>
<div className="col-lg-4 d-flex align-items-center">
<p className="text-secondary small mb-0">
Drop a <code>.tap</code>, <code>.tzx</code>, or other tape file to identify it against 32,000+ ZXDB entries.
The file stays in your browser &mdash; only its hash is sent.
</p>
</div>
</section>
<section> <section>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h2 className="h4 mb-0">Start exploring</h2> <h2 className="h4 mb-0">Start exploring</h2>

View File

@@ -56,6 +56,7 @@ import {
searchByMagrefs, searchByMagrefs,
referencetypes, referencetypes,
countries, countries,
softwareHashes,
} from "@/server/schema/zxdb"; } from "@/server/schema/zxdb";
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins"; export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
@@ -2621,3 +2622,42 @@ export async function getIssue(id: number): Promise<IssueDetail | null> {
})), })),
}; };
} }
// ----- Tape identification via software_hashes -----
export type TapeMatch = {
downloadId: number;
entryId: number;
entryTitle: string;
innerPath: string;
md5: string;
crc32: string;
sizeBytes: number;
};
export async function lookupByMd5(md5: string): Promise<TapeMatch[]> {
const rows = await db
.select({
downloadId: softwareHashes.downloadId,
entryId: downloads.entryId,
entryTitle: entries.title,
innerPath: softwareHashes.innerPath,
md5: softwareHashes.md5,
crc32: softwareHashes.crc32,
sizeBytes: softwareHashes.sizeBytes,
})
.from(softwareHashes)
.innerJoin(downloads, eq(downloads.id, softwareHashes.downloadId))
.innerJoin(entries, eq(entries.id, downloads.entryId))
.where(eq(softwareHashes.md5, md5.toLowerCase()));
return rows.map((r) => ({
downloadId: Number(r.downloadId),
entryId: Number(r.entryId),
entryTitle: r.entryTitle ?? "",
innerPath: r.innerPath,
md5: r.md5,
crc32: r.crc32,
sizeBytes: Number(r.sizeBytes),
}));
}

164
src/utils/md5.ts Normal file
View File

@@ -0,0 +1,164 @@
// Pure-JS MD5 for browser use (Web Crypto doesn't support MD5).
// Standard RFC 1321 implementation, typed for TypeScript.
function md5cycle(x: number[], k: number[]) {
let a = x[0], b = x[1], c = x[2], d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function add32(a: number, b: number) {
return (a + b) & 0xffffffff;
}
function md5blk(s: Uint8Array, offset: number): number[] {
const md5blks: number[] = [];
for (let i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s[offset + i] +
(s[offset + i + 1] << 8) +
(s[offset + i + 2] << 16) +
(s[offset + i + 3] << 24);
}
return md5blks;
}
const hex = "0123456789abcdef".split("");
function rhex(n: number) {
let s = "";
for (let j = 0; j < 4; j++) {
s += hex[(n >> (j * 8 + 4)) & 0x0f] + hex[(n >> (j * 8)) & 0x0f];
}
return s;
}
function md5raw(bytes: Uint8Array): string {
const n = bytes.length;
const state = [1732584193, -271733879, -1732584194, 271733878];
let i: number;
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk(bytes, i - 64));
}
// Tail: copy remaining bytes into a padded buffer
const tail = new Uint8Array(64);
const remaining = n - (i - 64);
for (let j = 0; j < remaining; j++) {
tail[j] = bytes[i - 64 + j];
}
tail[remaining] = 0x80;
// If remaining >= 56 we need an extra block
if (remaining >= 56) {
md5cycle(state, md5blk(tail, 0));
tail.fill(0);
}
// Append bit length as 64-bit little-endian
const bitLen = n * 8;
tail[56] = bitLen & 0xff;
tail[57] = (bitLen >> 8) & 0xff;
tail[58] = (bitLen >> 16) & 0xff;
tail[59] = (bitLen >> 24) & 0xff;
// For files < 512 MB the high 32 bits are 0; safe for tape images
md5cycle(state, md5blk(tail, 0));
return rhex(state[0]) + rhex(state[1]) + rhex(state[2]) + rhex(state[3]);
}
// Reads a File as ArrayBuffer and returns its MD5 hex digest.
export async function computeMd5(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
return md5raw(new Uint8Array(buffer));
}