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:
195
src/app/zxdb/TapeIdentifier.tsx
Normal file
195
src/app/zxdb/TapeIdentifier.tsx
Normal 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(" ")} — 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
22
src/app/zxdb/actions.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import TapeIdentifier from "./TapeIdentifier";
|
||||
|
||||
export const metadata = {
|
||||
title: "ZXDB Explorer",
|
||||
@@ -57,6 +58,18 @@ export default async function Page() {
|
||||
</div>
|
||||
</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 — only its hash is sent.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<h2 className="h4 mb-0">Start exploring</h2>
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
searchByMagrefs,
|
||||
referencetypes,
|
||||
countries,
|
||||
softwareHashes,
|
||||
} from "@/server/schema/zxdb";
|
||||
|
||||
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
164
src/utils/md5.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user