Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
/**
* Extract a JAV product code from a filename.
* Handles common patterns: "SSIS-001.jpg", "[ABC] DEF-123.png", "ssis001 cover.jpg",
* "abp-456_cover.jpeg", and pre-fixes like "FHD-MIDV-123.jpg".
*
* Returns the canonical "PREFIX-NUMBER" form (uppercased), or null if no match.
*/
// Prefix must start with a letter and may contain digits (e.g. "T28", "ABP",
// "MIAA"). Total prefix length 2-6 alphanumerics. The numeric tail is 3-5
// digits, with an optional single-letter suffix for variant releases —
// "IBW-610z" (z-version / re-cut), "ABP-456a" (part-a), etc. Suffix is
// preserved in the canonical form so e.g. IBW-610 and IBW-610Z stay
// distinct (they're different versions of the same release).
const CODE_RE = /\b([A-Za-z][A-Za-z0-9]{1,5})[-_ ]?(\d{3,5})([a-zA-Z])?\b/;
const QUALITY_PREFIXES = new Set(["fhd", "hd", "sd", "uhd", "4k", "1080p", "720p", "480p", "hevc", "x265", "x264"]);
/**
* Canonicalise an already-known code to "PREFIX-NUMBER" with the number
* left-padded to a minimum of 3 digits and the prefix uppercased. Inputs
* coming from NFO `<id>` tags can arrive in any of "MIAA-291", "miaa291",
* "MIAA-00291", etc.; we want them all to compare equal so the collision
* detector isn't fooled by formatting drift.
*/
export function normalizeCode(raw: string | null | undefined): string | null {
if (!raw) return null;
const m = raw.trim().match(CODE_RE);
if (!m) return null;
const suffix = (m[3] ?? "").toUpperCase();
return `${m[1].toUpperCase()}-${m[2].padStart(3, "0")}${suffix}`;
}
export function extractCode(filename: string): string | null {
const base = filename.replace(/\.[^.]+$/, "");
// Walk left-to-right, skipping quality prefixes that look like codes (e.g. "FHD-").
let cursor = 0;
while (cursor < base.length) {
const m = base.slice(cursor).match(CODE_RE);
if (!m) return null;
const prefix = m[1];
const num = m[2];
const suffix = (m[3] ?? "").toUpperCase();
if (QUALITY_PREFIXES.has(prefix.toLowerCase())) {
cursor += (m.index ?? 0) + m[0].length;
continue;
}
return `${prefix.toUpperCase()}-${num.padStart(3, "0")}${suffix}`;
}
return null;
}
+43
View File
@@ -0,0 +1,43 @@
import { extractCode } from "./codeParser";
export interface ParsedDropFilename {
/** Original filename with extension. */
original: string;
/** Canonical "PREFIX-NUMBER" code, or null. */
code: string | null;
/** Actress display names parsed from filename (after the first " - "). */
actresses: string[];
/** Suggested filename to save as (CODE.ext) when code is known; otherwise the original. */
targetFilename: string;
}
/**
* Parse a dropped image filename of the form:
* "DDK-134.jpg"
* "DDK-134 - Shuri Atomi.jpg"
* "DDK-134 - Shuri Atomi, Reika Aiba.jpg"
*
* The first " - " (space-hyphen-space) after the code separates the code/title from
* the actress list. Multiple actresses are comma-separated.
*/
export function parseDroppedFilename(name: string): ParsedDropFilename {
const extMatch = name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1] : "";
const stem = ext ? name.slice(0, -(ext.length + 1)) : name;
const code = extractCode(name);
let actresses: string[] = [];
const sepIdx = stem.indexOf(" - ");
if (sepIdx !== -1) {
const tail = stem.slice(sepIdx + 3).trim();
if (tail) {
actresses = tail
.split(/\s*,\s*/)
.map((s) => s.trim())
.filter(Boolean);
}
}
const targetFilename = code ? (ext ? `${code}.${ext}` : code) : name;
return { original: name, code, actresses, targetFilename };
}
+93
View File
@@ -0,0 +1,93 @@
import { parseNfo, type NfoMetadata } from "./nfoParser";
/**
* Lenient JSON metadata parser. Accepts a wide range of common keys produced by
* scrapers (Kodi/Jellyfin/Stash/JavSP/etc.) and normalizes them into the same
* shape as `NfoMetadata` so the cover editor can pre-fill from a file or a
* pasted blob.
*/
export function parseMetaJson(raw: string): NfoMetadata | null {
let obj: unknown;
try {
obj = JSON.parse(raw);
} catch {
return null;
}
if (!obj || typeof obj !== "object") return null;
// Some scrapers wrap in {"data": {...}} or arrays.
const root = (obj as Record<string, unknown>);
const data = (root.data && typeof root.data === "object" ? root.data : root) as Record<string, unknown>;
const pick = (...keys: string[]): string | undefined => {
for (const k of keys) {
const v = data[k];
if (typeof v === "string" && v.trim()) return v.trim();
if (typeof v === "number" && Number.isFinite(v)) return String(v);
}
return undefined;
};
const pickArray = (...keys: string[]): string[] => {
for (const k of keys) {
const v = data[k];
if (Array.isArray(v)) {
return v
.map((x) => {
if (typeof x === "string") return x.trim();
if (x && typeof x === "object") {
const name = (x as Record<string, unknown>).name ?? (x as Record<string, unknown>).Name;
return typeof name === "string" ? name.trim() : "";
}
return "";
})
.filter(Boolean);
}
if (typeof v === "string" && v.trim()) {
return v.split(/[,、,]/).map((s) => s.trim()).filter(Boolean);
}
}
return [];
};
const runtimeRaw = pick("runtime", "runtimeMin", "runtime_min", "duration");
const runtimeMin = runtimeRaw ? parseInt(runtimeRaw, 10) : NaN;
const out: NfoMetadata = {
title: pick("title", "originaltitle", "originalTitle", "name"),
code: pick("code", "id", "num", "number", "javId", "jav_id"),
releaseDate: pick("releaseDate", "release_date", "premiered", "releasedate", "date", "year"),
runtimeMin: Number.isFinite(runtimeMin) ? runtimeMin : undefined,
director: pick("director"),
studio: pick("studio", "maker", "manufacturer"),
series: pick("series", "set"),
actresses: pickArray("actresses", "actors", "actor", "performers", "cast", "stars"),
genres: pickArray("genres", "genre", "tags", "categories"),
notes: pick("plot", "outline", "summary", "description", "notes"),
};
return strip(out);
}
/**
* Try JSON first, fall back to XML. Returns null if neither matches.
*/
export function parseMetaAny(raw: string): NfoMetadata | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return parseMetaJson(trimmed);
}
return parseNfo(trimmed);
}
function strip(o: NfoMetadata): NfoMetadata {
const out: NfoMetadata = {};
for (const [k, v] of Object.entries(o)) {
if (v == null) continue;
if (Array.isArray(v) && v.length === 0) continue;
if (typeof v === "string" && v === "") continue;
(out as Record<string, unknown>)[k] = v;
}
return out;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Reverse the word order of a name. "Aiba Reika" → "Reika Aiba".
* For single-token names returns null (no useful reverse).
*/
export function reverseName(name: string): string | null {
const tokens = name.trim().split(/\s+/).filter(Boolean);
if (tokens.length < 2) return null;
return tokens.slice().reverse().join(" ");
}
/**
* Build the displayed alt-name list for an actress: always lead with the
* word-order-reversed name (auto-generated, user can't remove), then any
* user-typed alt names from the comma-separated `altNames` field, deduped
* and excluding the actress's own canonical name.
*/
export function buildAltNameChips(name: string, altNamesField: string | null): Array<{ value: string; auto: boolean }> {
const out: Array<{ value: string; auto: boolean }> = [];
const seen = new Set<string>([name.trim().toLowerCase()]);
const reversed = reverseName(name);
if (reversed && !seen.has(reversed.toLowerCase())) {
out.push({ value: reversed, auto: true });
seen.add(reversed.toLowerCase());
}
if (altNamesField) {
for (const raw of altNamesField.split(/[,、,]/)) {
const v = raw.trim();
if (!v) continue;
const k = v.toLowerCase();
if (seen.has(k)) continue;
seen.add(k);
out.push({ value: v, auto: false });
}
}
return out;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Minimal Kodi/Jellyfin-style .nfo parser. Operates on a string that contains
* an XML document and extracts the fields we map onto cover metadata.
*
* No XML library dependency — small, regex-based, deliberately lenient.
* Returns null if the buffer doesn't look like a movie .nfo.
*/
export interface NfoMetadata {
title?: string;
code?: string;
releaseDate?: string;
runtimeMin?: number;
director?: string;
studio?: string;
series?: string;
actresses?: string[];
genres?: string[];
notes?: string;
}
const TAG_RE = (tag: string) =>
new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "gi");
function firstMatch(xml: string, tag: string): string | undefined {
const re = TAG_RE(tag);
const m = re.exec(xml);
if (!m) return undefined;
return decodeEntities(m[1].trim());
}
function allMatches(xml: string, tag: string): string[] {
const re = TAG_RE(tag);
const out: string[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
const text = decodeEntities(m[1].trim());
if (text) out.push(text);
}
return out;
}
function decodeEntities(s: string): string {
return s
.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, "$1")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
export function parseNfo(xml: string): NfoMetadata | null {
if (!/<movie\b|<id\b|<title\b/i.test(xml)) return null;
const runtimeRaw = firstMatch(xml, "runtime");
const runtime = runtimeRaw ? parseInt(runtimeRaw, 10) : NaN;
return strip({
title: firstMatch(xml, "title") ?? firstMatch(xml, "originaltitle"),
code: firstMatch(xml, "id") ?? firstMatch(xml, "num"),
releaseDate: firstMatch(xml, "premiered") ?? firstMatch(xml, "releasedate") ?? firstMatch(xml, "year"),
runtimeMin: Number.isFinite(runtime) ? runtime : undefined,
director: firstMatch(xml, "director"),
studio: firstMatch(xml, "studio") ?? firstMatch(xml, "maker"),
series: firstMatch(xml, "set") ?? firstMatch(xml, "series"),
actresses: allMatches(xml, "actor").map((block) => {
const name = block.match(/<name>([\s\S]*?)<\/name>/i);
return decodeEntities(name ? name[1].trim() : block.trim());
}).filter(Boolean),
genres: [...allMatches(xml, "genre"), ...allMatches(xml, "tag")],
notes: firstMatch(xml, "plot") ?? firstMatch(xml, "outline"),
});
}
function strip(o: NfoMetadata): NfoMetadata {
const out: NfoMetadata = {};
for (const [k, v] of Object.entries(o)) {
if (v == null) continue;
if (Array.isArray(v) && v.length === 0) continue;
if (typeof v === "string" && v === "") continue;
(out as Record<string, unknown>)[k] = v;
}
return out;
}
+55
View File
@@ -0,0 +1,55 @@
import sharp from "sharp";
/**
* Difference-hash (dHash) implementation. Resizes the input to 9×8 grayscale
* and emits one bit per adjacent-pixel comparison: if the left pixel is
* brighter than the right, bit = 1. Result is 64 bits, encoded as a 16-char
* lowercase hex string.
*
* dHash is robust to scaling, mild JPEG/WebP recompression, brightness
* tweaks, and small crops — exactly the cases the SHA-256 dedup misses
* (same picture, different bytes). Hamming-distance ≤ ~10 between two
* hashes is a strong "same image, different encode" signal in practice.
*/
export async function computeDHash(input: Buffer): Promise<string> {
// 9 wide × 8 tall, grayscale, raw pixel bytes.
const buf = await sharp(input, { failOn: "none" })
.rotate()
.grayscale()
.resize(9, 8, { fit: "fill" })
.raw()
.toBuffer();
let hex = "";
for (let row = 0; row < 8; row++) {
let byte = 0;
for (let col = 0; col < 8; col++) {
const left = buf[row * 9 + col];
const right = buf[row * 9 + col + 1];
if (left > right) byte |= 1 << (7 - col);
}
hex += byte.toString(16).padStart(2, "0");
}
return hex;
}
/**
* Hamming distance between two 16-char hex strings. Returns Infinity if
* the inputs aren't both well-formed 64-bit hashes.
*/
export function hammingDistance(a: string, b: string): number {
if (a.length !== 16 || b.length !== 16) return Infinity;
let dist = 0;
for (let i = 0; i < 16; i += 2) {
const byteA = parseInt(a.slice(i, i + 2), 16);
const byteB = parseInt(b.slice(i, i + 2), 16);
if (!Number.isFinite(byteA) || !Number.isFinite(byteB)) return Infinity;
let xor = byteA ^ byteB;
// popcount on a single byte
while (xor) {
dist += xor & 1;
xor >>>= 1;
}
}
return dist;
}
+18
View File
@@ -0,0 +1,18 @@
import "server-only";
import { rawDb, uniqueSlug } from "@/lib/db/client";
function upsert(table: "studios" | "labels" | "series" | "actresses" | "genres", name: string): number {
const trimmed = name.trim();
if (!trimmed) throw new Error(`empty ${table.slice(0, -1)} name`);
const existing = rawDb.prepare(`SELECT id FROM ${table} WHERE name = ?`).get(trimmed) as { id: number } | undefined;
if (existing) return existing.id;
const slug = uniqueSlug(rawDb, table, trimmed);
const row = rawDb.prepare(`INSERT INTO ${table} (name, slug) VALUES (?, ?) RETURNING id`).get(trimmed, slug) as { id: number };
return row.id;
}
export const upsertStudio = (name: string) => upsert("studios", name);
export const upsertLabel = (name: string) => upsert("labels", name);
export const upsertSeries = (name: string) => upsert("series", name);
export const upsertActress = (name: string) => upsert("actresses", name);
export const upsertGenre = (name: string) => upsert("genres", name);