// ---------- duplicate review ----------
let lastDupeReview = null;
function dupePath(row) {
return row?.full_path || row?.path || row?.jav_id || "?";
}
function _groupFmtKey(keep, deletions) {
const all = [keep, ...deletions];
const exts = new Set(all.map(f => (f.path || f.full_path || "").split(".").pop().toLowerCase()).filter(e => e && e.length <= 4 && /^[a-z]+$/.test(e)));
if (exts.has("mkv") && exts.has("mp4") && !exts.has("wmv") && !exts.has("avi")) return "MKV/MP4";
if (exts.has("wmv") && exts.has("mp4") && !exts.has("mkv")) return "WMV/MP4";
if (exts.has("avi") && exts.has("mp4") && !exts.has("mkv")) return "AVI/MP4";
if (exts.size === 1) return "Same format";
return null; // mixed/unusual — visible under All, no chip
}
function _pathRes(path) {
if (/\[2160p\]/i.test(path) || /\b4[kK]\b/.test(path)) return 2160;
if (/\[1080p\]/i.test(path)) return 1080;
if (/\[720p\]/i.test(path)) return 720;
if (/\[480p\]/i.test(path)) return 480;
return 0;
}
function _groupResKey(keep, deletions) {
const keepRes = _pathRes(dupePath(keep));
const maxDelRes = deletions.reduce((m, d) => Math.max(m, _pathRes(dupePath(d))), 0);
if (keepRes === 0 && maxDelRes === 0) return "unknown";
if (keepRes === maxDelRes) return "same";
return keepRes > maxDelRes ? "upgrade" : "downgrade";
}
let _drActiveFmt = "all";
let _drActiveRes = "all";
let _drActiveStatus = "all";
let _drActiveParts = "all";
let _drActiveVip = "all";
let _drActiveSearch = "";
function _drPromoteToKeep(row) {
row.classList.remove("del", "confirmed", "unconfirmed", "queued");
row.classList.add("keep");
const tag = row.querySelector(".dr-tag");
tag.textContent = "KEEP";
tag.className = "dr-tag keep";
}
function _drDemoteToDelete(row) {
row.classList.remove("keep", "queued");
row.classList.add("del", "confirmed");
const tag = row.querySelector(".dr-tag");
tag.textContent = "DELETE?";
tag.className = "dr-tag del";
}
function _drApplyFilters() {
const wraps = document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap");
for (const wrap of wraps) {
const fmtMatch = _drActiveFmt === "all" || wrap.dataset.fmt === _drActiveFmt;
const resMatch = _drActiveRes === "all" || wrap.dataset.res === _drActiveRes;
let statusMatch = true;
if (_drActiveStatus !== "all") {
const skipped = wrap.classList.contains("skipped");
const delRows = wrap.querySelectorAll(".dr-row.del");
const doneRows = wrap.querySelectorAll(".dr-row.del.done");
const allDone = delRows.length > 0 && doneRows.length === delRows.length;
if (_drActiveStatus === "skipped") statusMatch = skipped;
else if (_drActiveStatus === "done") statusMatch = !skipped && allDone;
else statusMatch = !skipped && !allDone; // pending
}
const partsMatch = _drActiveParts === "all" || wrap.dataset.parts === "1";
const vipMatch = _drActiveVip === "all" || wrap.dataset.vip === "1";
const q = _drActiveSearch;
const searchMatch = !q
|| (wrap.querySelector(".dr-card-id")?.textContent.toLowerCase().includes(q))
|| ([...wrap.querySelectorAll(".dr-path")].some(p => p.textContent.toLowerCase().includes(q)));
wrap.classList.toggle("dr-hidden", !(fmtMatch && resMatch && statusMatch && partsMatch && vipMatch && searchMatch));
}
}
function _drBadges(keep, deletions, catalogs) {
const all = [keep, ...deletions, ...catalogs];
const paths = all.map((f) => dupePath(f));
const out = [];
if (paths.some((p) => /\[2160p\]/i.test(p) || /\b4k\b/i.test(p))) {
out.push(`4K `);
} else if (paths.some((p) => /\[1080p\]/i.test(p))) {
out.push(`1080p `);
}
if (paths.some((p) => /clearjav/i.test(p))) {
out.push(`CLEARJAV `);
}
const exts = new Set(
all.map((f) => (f.path || f.full_path || "").split(".").pop().toLowerCase()).filter((e) => e && e.length <= 4)
);
if (exts.size > 1) {
out.push(`${escapeHtml([...exts].join("/").toUpperCase())} `);
} else if (exts.has("mkv")) {
out.push(`MKV `);
}
return out.join("");
}
function renderDupeReview(r) {
const out = document.getElementById("dupe-review-modal-body");
const summary = document.getElementById("dupe-review-results");
const exportBtn = document.getElementById("dupe-review-export");
if (!r || !r.ok) {
lastDupeReview = null;
exportBtn.disabled = true;
out.innerHTML = `
Error: ${escapeHtml(r?.error || "no response")}
`;
summary.innerHTML = out.innerHTML;
openModal("dupe-review-modal");
return;
}
lastDupeReview = r;
exportBtn.disabled = false;
_drActiveFmt = "all";
_drActiveRes = "all";
_drActiveStatus = "all";
_drActiveParts = "all";
_drActiveVip = "all";
_drActiveSearch = "";
const groups = Object.entries(r.groups || {});
const totalCandidates = groups.reduce((n, [, g]) => n + (g.delete_candidates?.length || 0), 0);
const roots = [
...(r.roots?.source || []).map((root) => `source: ${root}`),
...(r.roots?.target || []).map((root) => `target: ${root}`),
];
// Compute per-group fmt/res keys and counts for filter bar
const fmtCounts = {};
const resCounts = {};
let partsCount = 0;
let vipCount = 0;
let riskCount = 0;
for (const [javId, g] of groups) {
const fk = _groupFmtKey(g.keep || {}, g.delete_candidates || []);
const rk = _groupResKey(g.keep || {}, g.delete_candidates || []);
fmtCounts[fk] = (fmtCounts[fk] || 0) + 1;
resCounts[rk] = (resCounts[rk] || 0) + 1;
if (javId.includes("#part")) partsCount++;
if ([g.keep, ...(g.delete_candidates || [])].some((row) => /(?:^|[\\/])clearjav(?:[\\/]|$)/i.test(dupePath(row)))) vipCount++;
if ((g.risks || []).length) riskCount++;
}
const parts = [];
// Filter bar (sticky top)
if (groups.length) {
const fmtOrder = ["MKV/MP4", "WMV/MP4", "AVI/MP4", "Same format"];
const resOrder = [
{ key: "same", label: "Same res" },
{ key: "upgrade", label: "Upgrade" },
];
const fmtChips = fmtOrder
.filter(k => fmtCounts[k])
.map(k => `${escapeHtml(k)} (${fmtCounts[k]}) `)
.join("");
const resChips = resOrder
.filter(({ key }) => resCounts[key])
.map(({ key, label }) => `${escapeHtml(label)} (${resCounts[key]}) `)
.join("");
const totalGroups = groups.length;
parts.push(`
Format:
All
${fmtChips}
${resChips.length ? `Resolution: All ${resChips}` : ""}
Status:
All
Pending (${totalGroups - riskCount})
Done (0)
Skipped (${riskCount})
${vipCount ? `All ClearJAV (${vipCount}) ` : ""}
${partsCount ? `All Parts (${partsCount}) ` : ""}
`);
}
// Stats bar
parts.push(`
${escapeHtml(r.potential_reclaim_human || "0 B")}
Recoverable
${escapeHtml(String(r.group_count || 0))}
Duplicate Groups
${escapeHtml(String(totalCandidates))}
Delete Candidates
`);
if (riskCount) {
parts.push(`${escapeHtml(String(riskCount))} risky group${riskCount !== 1 ? "s" : ""} are skipped by default. Review part-like filenames before adding them back to the delete queue.
`);
}
// Roots hint
if (roots.length) {
parts.push(`${escapeHtml(roots.join(" · "))}
`);
}
// Group cards
if (!groups.length) {
parts.push(`No cached duplicate groups found.
`);
} else {
const cards = [];
for (const [javId, group] of groups) {
const keep = group.keep || {};
const deletions = group.delete_candidates || [];
const catalogs = group.catalog || [];
const reclaim = deletions.reduce((s, e) => s + (e.size || 0), 0);
const reclaimHuman = deletions.length && deletions[0].size_human
? deletions.map((d) => d.size_human).join(" + ")
: "";
const reclaimLabel = reclaimHuman
? `−${escapeHtml(reclaimHuman)} `
: "";
const fmtKey = _groupFmtKey(keep, deletions);
const resKey = _groupResKey(keep, deletions);
const risks = group.risks || [];
const keepReason = group.keep_reason?.summary || "";
const rows = [];
if (risks.length) {
rows.push(`Review before deleting: ${risks.map((risk) => escapeHtml(risk.summary || "multipart risk")).join(" ")}
`);
}
rows.push(`
KEEP
${escapeHtml(dupePath(keep))}
${keep.size_human ? `${escapeHtml(keep.size_human)} ` : ""}
`);
if (keepReason) {
rows.push(`Suggested KEEP reason: ${escapeHtml(keepReason)}
`);
}
for (const d of deletions) {
rows.push(`
DELETE?
${escapeHtml(dupePath(d))}
${d.size_human ? `${escapeHtml(d.size_human)} ` : ""}
`);
}
for (const c of catalogs) {
rows.push(`
CATALOG
${escapeHtml(dupePath(c))}
${c.size_human ? `${escapeHtml(c.size_human)} ` : ""}
`);
}
const hasClearJav = [keep, ...deletions].some((row) => /(?:^|[\\/])clearjav(?:[\\/]|$)/i.test(dupePath(row)));
cards.push(`
${escapeHtml(javId)}
${_drBadges(keep, deletions, catalogs)}
${reclaimLabel}
${rows.join("")}
${risks.length ? "Review" : "Skip"}
`);
}
parts.push(`${cards.join("")}
`);
}
// Variant alerts — bare ID + variant coexist (e.g. IBW-902 and IBW-902z both present)
const variantAlerts = r.variant_alerts || [];
if (variantAlerts.length) {
const alertCards = variantAlerts.map((alert) => {
const rows = (alert.files || []).map((f) => {
const detectedId = f.detected_id || f.jav_id || "";
const isVariant = detectedId !== alert.bare_id;
const tag = isVariant
? `${escapeHtml(detectedId)} `
: `BARE `;
return `
${tag}
${escapeHtml(dupePath(f))}
${f.size_human ? `${escapeHtml(f.size_human)} ` : ""}
`;
}).join("");
return `
${escapeHtml(alert.bare_id)}
⚠ variant — manual review
${rows}
`;
}).join("");
parts.push(`
⚠ ${variantAlerts.length} Variant Alert${variantAlerts.length !== 1 ? "s" : ""} — Same base ID, different product designator
${alertCards}
`);
}
if ((r.skipped || []).length) {
const samples = (r.skipped || []).slice(0, 5)
.map((s) => `${escapeHtml(s.name || s.path || "?")} · ${escapeHtml(s.reason || "unparsed ID")}
`)
.join("");
parts.push(`Skipped ${escapeHtml(String(r.skipped.length))} path(s) with no parseable ID${samples ? ":" : "."}
${samples}`);
}
out.innerHTML = parts.join("");
summary.textContent = `${r.group_count || 0} cached duplicate group(s) reviewed. Results are open in the review window.`;
openModal("dupe-review-modal");
}
function _drUpdateExecuteBtn() {
const confirmed = document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap:not(.skipped):not(.dr-hidden) .dr-row.del.confirmed:not(.done)");
const btn = document.getElementById("dupe-review-execute");
const status = document.getElementById("dupe-review-confirm-status");
const n = confirmed.length;
btn.textContent = `Execute Deletions (${n})`;
btn.disabled = n === 0;
status.textContent = n > 0 ? `${n} file${n !== 1 ? "s" : ""} queued for deletion — click to execute` : "";
}
// Search input
document.getElementById("dupe-review-modal-body").addEventListener("input", (e) => {
if (e.target.id !== "dr-search") return;
_drActiveSearch = e.target.value.trim().toLowerCase();
_drApplyFilters();
_drUpdateExecuteBtn();
});
// Filter chips + toggle DELETE? rows on click
document.getElementById("dupe-review-modal-body").addEventListener("click", (e) => {
// Filter chip
const chip = e.target.closest(".dr-chip");
if (chip) {
const ftype = chip.dataset.ftype;
const fval = chip.dataset.fval;
if (ftype === "fmt") {
_drActiveFmt = fval;
} else if (ftype === "res") {
_drActiveRes = fval;
} else if (ftype === "status") {
_drActiveStatus = fval;
} else if (ftype === "parts") {
_drActiveParts = fval;
} else if (ftype === "vip") {
_drActiveVip = fval;
}
document.querySelectorAll(`#dupe-review-modal-body .dr-chip[data-ftype='${ftype}']`).forEach(c => {
c.classList.toggle("active", c.dataset.fval === fval);
});
_drApplyFilters();
_drUpdateExecuteBtn();
return;
}
// Search input
if (e.target.id === "dr-search") return; // handled via input event below
// Skip ear — toggle skipped on the wrap
const ear = e.target.closest(".dr-skip-ear");
if (ear) {
ear.closest(".dr-card-wrap").classList.toggle("skipped");
_drUpdateExecuteBtn();
return;
}
// Click KEEP row → full swap: this becomes DELETE?, pick a replacement KEEP
const keepRow = e.target.closest(".dr-row.keep");
if (keepRow && !keepRow.classList.contains("done")) {
const card = keepRow.closest(".dr-card");
// Prefer an unconfirmed del row as new KEEP (least disruptive), else first any del row
const newKeep = card.querySelector(".dr-row.del.unconfirmed:not(.done)")
|| card.querySelector(".dr-row.del:not(.done)");
if (!newKeep) return;
_drPromoteToKeep(newKeep);
_drDemoteToDelete(keepRow);
_drUpdateExecuteBtn();
return;
}
// DELETE? rows are not clickable — click the KEEP row to swap
});
document.getElementById("dupe-review-execute").addEventListener("click", async () => {
const rows = [...document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap:not(.skipped):not(.dr-hidden) .dr-row.del.confirmed:not(.done)")];
if (!rows.length) return;
const deleteRows = rows.filter((row) => row.dataset.fullPath);
if (!deleteRows.length) return;
const btn = document.getElementById("dupe-review-execute");
const status = document.getElementById("dupe-review-confirm-status");
const total = deleteRows.length;
btn.disabled = true;
let done = 0, failed = 0;
for (const [index, row] of deleteRows.entries()) {
const path = row.dataset.fullPath;
status.textContent = `Deleting ${index + 1}/${total}...`;
const res = await chrome.runtime.sendMessage({ type: "delete_batch", paths: [path] });
if (!res?.ok && res?.error === "deletion is disabled in options") {
status.textContent = "Deletion is disabled - enable it in the Deletion tab first.";
btn.disabled = false;
return;
}
const r = (res?.results || [])[0] || { ok: false, error: res?.error || "delete failed" };
const tag = row.querySelector(".dr-tag");
if (r.ok) {
row.classList.remove("confirmed");
row.classList.add("done");
tag.textContent = "DELETED";
done++;
} else {
row.classList.add("error");
tag.textContent = "ERROR";
row.title = r.error || "delete failed";
failed++;
}
}
const parts = [];
if (done) parts.push(`${done} deleted`);
if (failed) parts.push(`${failed} failed`);
status.textContent = parts.join(" · ") || "Nothing processed.";
_drUpdateExecuteBtn();
});
document.getElementById("dupe-review-run").addEventListener("click", async () => {
const out = document.getElementById("dupe-review-modal-body");
const executeBtn = document.getElementById("dupe-review-execute");
out.textContent = "reviewing cached duplicate groups...";
executeBtn.disabled = true;
executeBtn.textContent = "Execute Deletions (0)";
document.getElementById("dupe-review-confirm-status").textContent = "";
openModal("dupe-review-modal");
renderDupeReview(await chrome.runtime.sendMessage({ type: "dupe-review" }));
_drUpdateExecuteBtn();
});
document.getElementById("dupe-review-export").addEventListener("click", () => {
if (!lastDupeReview) return;
const blob = new Blob([JSON.stringify(lastDupeReview, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
a.href = url;
a.download = `rclone-jav-dupe-review-${stamp}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
});
for (const id of ["dupe-review-modal-close", "dupe-review-modal-done"]) {
document.getElementById(id).addEventListener("click", () => closeModal("dupe-review-modal"));
}
document.getElementById("dupe-review-modal").addEventListener("click", (event) => {
if (event.target.id === "dupe-review-modal") closeModal("dupe-review-modal");
});
// ---- Keep Ranking ----
const KR_DEFAULT_FMTS = ["mkv", "mp4", "wmv", "avi"];
const KR_DEFAULT_VIP_FOLDERS = ["ClearJAV"];
function _krWireDraggableList(list) {
if (!list) return;
let dragSrc = null;
for (const item of list.querySelectorAll(".kr-fmt-item")) {
item.addEventListener("dragstart", (e) => {
dragSrc = item;
item.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
item.addEventListener("dragend", () => {
item.classList.remove("dragging");
list.querySelectorAll(".kr-fmt-item").forEach(i => i.classList.remove("drag-over"));
list.querySelectorAll(".kr-fmt-item").forEach((el, idx) => {
const pr = el.querySelector(".kr-fmt-priority");
if (pr) pr.textContent = `#${idx + 1}`;
});
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (item !== dragSrc) item.classList.add("drag-over");
});
item.addEventListener("dragleave", () => item.classList.remove("drag-over"));
item.addEventListener("drop", (e) => {
e.preventDefault();
item.classList.remove("drag-over");
if (dragSrc && dragSrc !== item) {
const items = [...list.querySelectorAll(".kr-fmt-item")];
const srcIdx = items.indexOf(dragSrc);
const dstIdx = items.indexOf(item);
if (srcIdx < dstIdx) list.insertBefore(dragSrc, item.nextSibling);
else list.insertBefore(dragSrc, item);
}
});
}
}
function _krRenderFmtList(fmts) {
const list = document.getElementById("kr-fmt-list");
if (!list) return;
list.innerHTML = fmts.map((fmt, i) =>
`
⠿
${escapeHtml(fmt)}
#${i + 1}
`
).join("");
_krWireDraggableList(list);
}
function _krGetCurrentFmts() {
return [...document.querySelectorAll("#kr-fmt-list .kr-fmt-item")]
.map(el => el.dataset.fmt);
}
function _krRenderVipList(folders) {
const list = document.getElementById("kr-vip-list");
if (!list) return;
list.innerHTML = (folders || []).map((folder, i) =>
`
⠿
${escapeHtml(folder)}
x
#${i + 1}
`
).join("");
for (const btn of list.querySelectorAll(".kr-vip-remove")) {
btn.addEventListener("click", () => {
btn.closest(".kr-fmt-item")?.remove();
list.querySelectorAll(".kr-fmt-item").forEach((el, idx) => {
const pr = el.querySelector(".kr-fmt-priority");
if (pr) pr.textContent = `#${idx + 1}`;
});
});
}
_krWireDraggableList(list);
}
function _krGetVipFolders() {
return [...document.querySelectorAll("#kr-vip-list .kr-fmt-item")]
.map((el) => el.dataset.folder)
.filter(Boolean);
}
function _krAddVipFolder() {
const input = document.getElementById("kr-vip-add");
const folder = input?.value.trim();
if (!folder) return;
const current = _krGetVipFolders();
if (!current.some((item) => item.toLowerCase() === folder.toLowerCase())) {
_krRenderVipList([...current, folder]);
}
input.value = "";
}
document.getElementById("kr-vip-add-btn")?.addEventListener("click", _krAddVipFolder);
document.getElementById("kr-vip-add")?.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
_krAddVipFolder();
}
});
async function loadKeepRanking() {
try {
const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" });
if (!r || !r.ok) return;
const ranking = r.keep_ranking || {};
const toleranceEl = document.getElementById("kr-tolerance");
const resTagEl = document.getElementById("kr-res-tag");
const longerNameEl = document.getElementById("kr-longer-name");
if (toleranceEl) toleranceEl.value = ranking.size_tolerance_mib ?? 0;
if (resTagEl) resTagEl.checked = ranking.tiebreak_res_tag !== false;
if (longerNameEl) longerNameEl.checked = ranking.tiebreak_longer_name !== false;
_krRenderVipList(ranking.priority_folders || KR_DEFAULT_VIP_FOLDERS);
_krRenderFmtList(ranking.format_preference || KR_DEFAULT_FMTS);
} catch (e) {
// non-fatal — panel just shows defaults
_krRenderVipList(KR_DEFAULT_VIP_FOLDERS);
_krRenderFmtList(KR_DEFAULT_FMTS);
}
}
document.getElementById("kr-save")?.addEventListener("click", async () => {
const status = document.getElementById("kr-save-status");
const toleranceEl = document.getElementById("kr-tolerance");
const resTagEl = document.getElementById("kr-res-tag");
const longerNameEl = document.getElementById("kr-longer-name");
const tolerance = parseFloat(toleranceEl?.value ?? "0");
if (isNaN(tolerance) || tolerance < 0) {
status.textContent = "Size tolerance must be 0 or a positive number.";
status.className = "kr-save-status err";
return;
}
const ranking = {
priority_folders: _krGetVipFolders(),
size_tolerance_mib: tolerance,
format_preference: _krGetCurrentFmts(),
tiebreak_res_tag: resTagEl?.checked !== false,
tiebreak_longer_name: longerNameEl?.checked !== false,
};
status.textContent = "Saving…";
status.className = "kr-save-status";
try {
const r = await chrome.runtime.sendMessage({ type: "save-keep-ranking", keep_ranking: ranking });
if (r?.ok) {
status.textContent = "Saved — next dupe review will use the updated ranking.";
status.className = "kr-save-status ok";
} else {
status.textContent = "Error: " + (r?.error || "unknown");
status.className = "kr-save-status err";
}
} catch (e) {
status.textContent = "Error: " + e.message;
status.className = "kr-save-status err";
}
});
// Load on page open
loadKeepRanking();