// ---------- 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 => ``) .join(""); const resChips = resOrder .filter(({ key }) => resCounts[key]) .map(({ key, label }) => ``) .join(""); const totalGroups = groups.length; parts.push(`
Format: ${fmtChips} ${resChips.length ? `Resolution:${resChips}` : ""} Status: ${vipCount ? `` : ""} ${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("")}
`); } 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)} #${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();