/**
* SmartResolve — Scene Duplicate Checker helper.
* Smart Select: rule-based checks on Stash’s native row checkboxes.
* Sync Data: mergeless sceneUpdate merge from sibling duplicates.
*/
(function () {
"use strict";
var ROUTE = "/sceneDuplicateChecker";
var ROOT_ID = "scene-duplicate-checker";
var PLUGIN_ID = "SmartResolve";
function defaultRuleToggles() {
return {
step_01_total_pixels: true,
step_02_framerate: true,
step_03_codec: true,
step_upgrade_token: true,
step_04_duration: true,
step_05_smaller_size: true,
step_06_older_date: true,
step_07_more_groups: true,
step_08_has_stashid: true,
step_09_more_performers: true,
step_10_more_markers: true,
step_11_more_tags: true,
step_12_less_associated_files: true,
step_13_more_metadata_cardinality: true,
};
}
function defaultProtectionToggles() {
return {
protect_o_count: true,
protect_group_association: true,
protect_performer_mismatch: true,
protect_tag_loss_gt_1_non_stashed: true,
protect_older_date: true,
protect_ignore_smart_resolve_tag: true,
};
}
var state = {
groups: null,
lastPlan: null,
loading: false,
autoCheckDefault: true,
applyingDomEnhancements: false,
lastBadgePageKey: "",
ruleToggles: defaultRuleToggles(),
protectionToggles: defaultProtectionToggles(),
/** True after user runs Select Smart Resolve — sync/other refreshes preserve UI when set. */
smartResolveUiActive: false,
observer: null,
attachedRoot: null,
retryTimer: null,
};
function parseParams() {
var q = new URLSearchParams(window.location.search);
return {
page: Math.max(1, parseInt(q.get("page") || "1", 10) || 1),
size: Math.max(1, parseInt(q.get("size") || "20", 10) || 20),
distance: parseInt(q.get("distance") || "0", 10) || 0,
durationDiff: parseFloat(q.get("durationDiff") || "1"),
};
}
/** Same green/red banners Stash uses for “Updated scene” etc. (see hooks/Toast.tsx). */
var stashInlineNotifyRef = null;
var stashInlineNotifyBridgeInstalled = false;
/** Must be a stable function identity — defining inside patch.after() remounts every App render and breaks the UI. */
function DuplicateResolverStashNotifyMount() {
var P = window.PluginApi;
var R = P.React;
var t = P.hooks.useToast();
R.useEffect(
function () {
stashInlineNotifyRef = t;
return function () {
stashInlineNotifyRef = null;
};
},
[t]
);
return null;
}
function installStashInlineNotifyBridge() {
if (stashInlineNotifyBridgeInstalled || typeof window.PluginApi === "undefined") return;
var P = window.PluginApi;
if (!P.patch || !P.patch.after || !P.React || !P.hooks || !P.hooks.useToast) return;
stashInlineNotifyBridgeInstalled = true;
P.patch.after("App", function () {
var R = P.React;
/** Patch passes afterFn(...originalArgs, renderedTree). Last arg is always App output; arity can be 1 if a before() cleared args. */
var prevTree = arguments[arguments.length - 1];
return R.createElement(
R.Fragment,
null,
R.createElement(DuplicateResolverStashNotifyMount, null),
prevTree
);
});
}
function notifyStashSuccess(message) {
if (stashInlineNotifyRef) stashInlineNotifyRef.success(message);
else window.alert(message);
}
function notifyStashError(err) {
if (stashInlineNotifyRef) stashInlineNotifyRef.error(err);
else
window.alert(
err && err.message ? err.message : typeof err === "string" ? err : String(err)
);
}
function notifyStashWarning(message) {
if (stashInlineNotifyRef && stashInlineNotifyRef.toast)
stashInlineNotifyRef.toast({ content: message, variant: "warning" });
else window.alert(message);
}
async function gql(query, variables) {
var res = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query, variables: variables || {} }),
});
var j = await res.json();
if (j.errors && j.errors.length)
throw new Error(j.errors.map(function (e) { return e.message; }).join("; "));
return j.data;
}
var DUPLICATE_QUERY =
"query FindDuplicateScenesDr($distance: Int, $duration_diff: Float) {" +
" findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) {" +
" id title code details director urls date rating100" +
" o_counter" +
" paths { screenshot }" +
" files { id path size width height video_codec bit_rate duration }" +
" scene_markers { id }" +
" studio { id name }" +
" tags { id name }" +
" performers { id name }" +
" groups { group { id name } scene_index }" +
" galleries { id title }" +
" stash_ids { endpoint stash_id }" +
" }" +
"}";
function groupTotalSize(group) {
return group.reduce(function (acc, s) {
return (
acc +
(s.files || []).reduce(function (a, f) {
return a + (f.size || 0);
}, 0)
);
}, 0);
}
function sortGroupsLikeStash(groups) {
return groups.slice().sort(function (a, b) {
return groupTotalSize(b) - groupTotalSize(a);
});
}
function codecRank(codec) {
var c = (codec || "").toLowerCase();
if (c.indexOf("av01") !== -1 || c.indexOf("av1") !== -1) return 5;
if (c.indexOf("hevc") !== -1 || c.indexOf("h265") !== -1) return 4;
if (c.indexOf("vp9") !== -1) return 3;
if (c.indexOf("h264") !== -1 || c.indexOf("avc") !== -1) return 2;
return 1;
}
function primaryFile(scene) {
return (scene.files && scene.files[0]) || {};
}
function countSignals(scene) {
return {
tags: (scene.tags || []).length,
performers: (scene.performers || []).length,
groups: (scene.groups || []).length,
markers: (scene.scene_markers || []).length,
oCount: scene.o_counter || 0,
};
}
function isNearlySameDuration(a, b) {
var da = Math.max(0, a || 0);
var db = Math.max(0, b || 0);
if (!da || !db) return false;
var diff = Math.abs(da - db);
var max = Math.max(da, db);
return diff <= 2 || diff / max <= 0.02;
}
function efficiencyWinner(a, b) {
var fa = primaryFile(a);
var fb = primaryFile(b);
var aPixels = (fa.width || 0) * (fa.height || 0);
var bPixels = (fb.width || 0) * (fb.height || 0);
if (!aPixels || !bPixels || aPixels !== bPixels) return null;
if (!isNearlySameDuration(fa.duration || 0, fb.duration || 0)) return null;
var aCodec = codecRank(fa.video_codec);
var bCodec = codecRank(fb.video_codec);
if (aCodec === bCodec) return null;
var aSize = fa.size || 0;
var bSize = fb.size || 0;
var aRate = fa.bit_rate || 0;
var bRate = fb.bit_rate || 0;
var aMuchSmaller = !!(aSize && bSize && aSize <= bSize * 0.75);
var bMuchSmaller = !!(aSize && bSize && bSize <= aSize * 0.75);
var aLowerRate = !!(aRate && bRate && aRate <= bRate * 0.8);
var bLowerRate = !!(aRate && bRate && bRate <= aRate * 0.8);
if (aCodec > bCodec && (aMuchSmaller || aLowerRate)) return "a";
if (bCodec > aCodec && (bMuchSmaller || bLowerRate)) return "b";
return null;
}
// Returns "a" or "b" when one side is >= across all categories and > in at least one.
function unanimousCategoryWinner(a, b) {
var ka = countSignals(a);
var kb = countSignals(b);
// Tags are noisy/drift-prone; keep them out of decisive unanimity.
var keys = ["performers", "groups", "markers", "oCount"];
var aGeAll = true;
var bGeAll = true;
var aGtAny = false;
var bGtAny = false;
keys.forEach(function (k) {
if (ka[k] < kb[k]) aGeAll = false;
if (kb[k] < ka[k]) bGeAll = false;
if (ka[k] > kb[k]) aGtAny = true;
if (kb[k] > ka[k]) bGtAny = true;
});
if (aGeAll && aGtAny) return "a";
if (bGeAll && bGtAny) return "b";
return null;
}
function compareKeeper(a, b) {
// O-count is a hard guard: never prefer deleting an O-count scene.
var oa = a.o_counter || 0;
var ob = b.o_counter || 0;
if (!!oa !== !!ob) return ob - oa;
// Prefer the best source file first; metadata can be synced.
var af = primaryFile(a);
var bf = primaryFile(b);
var aPixels = (af.width || 0) * (af.height || 0);
var bPixels = (bf.width || 0) * (bf.height || 0);
if (aPixels !== bPixels) return bPixels - aPixels;
// If one side clearly wins category-by-category, keep it.
var unanimous = unanimousCategoryWinner(a, b);
if (unanimous === "a") return -1;
if (unanimous === "b") return 1;
var ga = (a.groups && a.groups.length) || 0;
var gb = (b.groups && b.groups.length) || 0;
if (ga !== gb) return gb - ga;
var sa = (a.stash_ids && a.stash_ids.length) || 0;
var sb = (b.stash_ids && b.stash_ids.length) || 0;
if (sa !== sb) return sb - sa;
// Prefer clearly better encoding efficiency at equivalent visual profile.
var eff = efficiencyWinner(a, b);
if (eff === "a") return -1;
if (eff === "b") return 1;
var aCounts = countSignals(a);
var bCounts = countSignals(b);
if (aCounts.groups !== bCounts.groups) return bCounts.groups - aCounts.groups;
if (aCounts.performers !== bCounts.performers) return bCounts.performers - aCounts.performers;
if (aCounts.markers !== bCounts.markers) return bCounts.markers - aCounts.markers;
if (aCounts.tags !== bCounts.tags) return bCounts.tags - aCounts.tags;
var fa = primaryFile(a);
var fb = primaryFile(b);
var aCodec = codecRank(fa.video_codec);
var bCodec = codecRank(fb.video_codec);
if (aCodec !== bCodec) return bCodec - aCodec;
var pa = (fa.width || 0) * (fa.height || 0);
var pb = (fb.width || 0) * (fb.height || 0);
if (pa !== pb) return pb - pa;
var za = fa.size || 0;
var zb = fb.size || 0;
if (za !== zb) return zb - za;
return String(a.id).localeCompare(String(b.id));
}
function pickKeeper(group) {
return group.slice().sort(compareKeeper)[0];
}
function reasonAgainst(keeper, other) {
if (!keeper || !other) return "deterministic fallback";
function dataSignals(scene) {
return {
hasTitle: !!(scene.title && String(scene.title).trim()),
hasCode: !!(scene.code && String(scene.code).trim()),
hasDetails: !!(scene.details && String(scene.details).trim()),
hasDirector: !!(scene.director && String(scene.director).trim()),
hasDate: !!(scene.date && String(scene.date).trim()),
tagCount: (scene.tags || []).length,
performerCount: (scene.performers || []).length,
groupCount: (scene.groups || []).length,
stashIdCount: (scene.stash_ids || []).length,
urlCount: (scene.urls || []).length,
galleryCount: (scene.galleries || []).length,
hasStudio: !!(scene.studio && scene.studio.id),
};
}
function isSparse(sig) {
return (
!sig.hasTitle &&
!sig.hasCode &&
!sig.hasDetails &&
!sig.hasDirector &&
!sig.hasDate &&
sig.tagCount === 0 &&
sig.performerCount === 0 &&
sig.groupCount === 0 &&
sig.stashIdCount === 0 &&
sig.urlCount === 0 &&
sig.galleryCount === 0 &&
!sig.hasStudio
);
}
function groupIdSafe(g) {
return g && g.group && g.group.id != null ? String(g.group.id) : null;
}
function groupSummary(scene) {
var groups = scene.groups || [];
var ids = groups
.map(function (g) { return groupIdSafe(g); })
.filter(function (id) { return !!id; });
var idxMap = new Map();
groups.forEach(function (g) {
var id = groupIdSafe(g);
if (id) idxMap.set(id, g.scene_index);
});
return { ids: ids, idxMap: idxMap };
}
function haveSameGroupSet(aSummary, bSummary) {
if (aSummary.ids.length !== bSummary.ids.length) return false;
var setB = new Set(bSummary.ids);
for (var i = 0; i < aSummary.ids.length; i++) {
if (!setB.has(aSummary.ids[i])) return false;
}
return true;
}
function groupReasonPrefix() {
var kg = groupSummary(keeper);
var og = groupSummary(other);
if (!kg.ids.length && !og.ids.length) return "";
if (!!kg.ids.length !== !!og.ids.length) {
return "Group presence differs";
}
var overlap = kg.ids.filter(function (id) { return og.ids.indexOf(id) !== -1; });
if (!overlap.length) return "Different groups";
var details = overlap.map(function (id) {
var kIdx = kg.idxMap.get(id);
var oIdx = og.idxMap.get(id);
return "(" + id + "," + String(kIdx) + "/" + String(oIdx) + ")";
});
return "Same group(s) " + details.join(", ");
}
function withGroupContext(msg) {
var ctx = groupReasonPrefix();
return ctx ? ctx + ", " + msg : msg;
}
var kSig = dataSignals(keeper);
var oSig = dataSignals(other);
var kCounts = countSignals(keeper);
var oCounts = countSignals(other);
var kg = groupSummary(keeper);
var og = groupSummary(other);
var kf = primaryFile(keeper);
var of = primaryFile(other);
var kPixels = (kf.width || 0) * (kf.height || 0);
var oPixels = (of.width || 0) * (of.height || 0);
// Never auto-resolve by deleting a better source file.
if (oPixels > kPixels) {
return withGroupContext(
"duplicate has higher resolution (" +
(of.width || 0) +
"x" +
(of.height || 0) +
" vs " +
(kf.width || 0) +
"x" +
(kf.height || 0) +
"). Recommend synch data from duplicate."
);
}
// Keeper has substantive metadata while duplicate is sparse -> keep keeper.
if (!isSparse(kSig) && isSparse(oSig)) {
return (
"Keep scene with data."
);
}
// Different group sets are a hard sync case to avoid losing group associations.
if (!haveSameGroupSet(kg, og)) {
return withGroupContext(
"different group associations. Recommend synch data from duplicate."
);
}
var kGroups = (keeper.groups || []).length;
var oGroups = (other.groups || []).length;
// Group cardinality differs -> keep group-richer scene, but sync to avoid loss.
if (kGroups !== oGroups) {
if (kGroups > oGroups) {
return "Keep Scene with Group Association.";
}
return "Duplicate has additional Group Association. Recommend synch data from duplicate.";
}
var kStash = (keeper.stash_ids && keeper.stash_ids.length) || 0;
var oStash = (other.stash_ids && other.stash_ids.length) || 0;
// External IDs differ -> prefer scene with more confirmed IDs.
if (kStash !== oStash) {
return withGroupContext(
"keep scene with confirmed IDs while duplicate has fewer/none."
);
}
// One side has O-count and the other does not -> keep O-count anchor, sync remaining deltas.
if (!!kCounts.oCount !== !!oCounts.oCount) {
return withGroupContext(
"keep scene with O-count signal. Recommend synch data from duplicate."
);
}
// Both scenes are stashed.
// No discernable difference in metadata beyond tags
// Chosing scene with more tags.
var stashLinked = ((keeper.stash_ids || []).length + (other.stash_ids || []).length) > 0;
var highValueEqual =
kCounts.performers === oCounts.performers &&
kCounts.groups === oCounts.groups &&
kCounts.markers === oCounts.markers &&
kCounts.oCount === oCounts.oCount;
// Both scenes are stashed and only tags differ -> de-prioritize tags noise, keep tag-richer scene.
if (stashLinked && highValueEqual && kCounts.tags !== oCounts.tags) {
return withGroupContext(
"All scenes stashed, tag-only difference; keep scene with more tags."
);
}
// Non-stashed variant of tag-only delta -> keep tag-richer scene.
if (highValueEqual && kCounts.tags !== oCounts.tags) {
return withGroupContext(
"tag-only difference; keep scene with more tags."
);
}
// No metadata signal separates them -> keeper came from deterministic ordering.
if (
highValueEqual &&
kCounts.tags === oCounts.tags
) {
return withGroupContext(
"no meaningful metadata delta; deterministic keeper tie-break."
);
}
var effWinner = efficiencyWinner(keeper, other);
// Same visual profile but keeper is clearly more efficient codec/bitrate/size -> sync then delete duplicate.
if (effWinner === "a") {
return withGroupContext(
"codec/efficiency winner at equivalent resolution/duration. Recommend synch data from duplicate."
);
}
// Duplicate is more efficient while current keeper remained selected by ordering -> sync recommended.
if (effWinner === "b") {
return withGroupContext(
"duplicate is codec/efficiency winner at equivalent resolution/duration. Recommend synch data from duplicate."
);
}
var unanimous = unanimousCategoryWinner(keeper, other);
// Keeper is >= duplicate across decisive categories -> safe keep decision.
if (unanimous === "a") {
return withGroupContext(
"unanimous category winner (tags/performers/groups/markers/o-count)."
);
}
// Duplicate is unanimous winner, but keeper was chosen by upstream ordering -> sync before cleanup.
if (unanimous === "b") {
return withGroupContext(
"duplicate is unanimous category winner; keeper chosen by deterministic fallback. Recommend synch data from duplicate."
);
}
// Category split means potential data loss either way -> force sync recommendation.
return withGroupContext(
"category split (tags/performers/groups/markers/o-count). Recommend synch data from duplicate."
);
}
async function loadDuplicateGroups() {
var p = parseParams();
var data = await gql(DUPLICATE_QUERY, {
distance: p.distance,
duration_diff: p.durationDiff,
});
state.groups = sortGroupsLikeStash(data.findDuplicateScenes || []);
return state.groups;
}
async function refreshPlanAndDecorations() {
var scrollY = typeof window !== "undefined" ? window.scrollY : 0;
await loadDuplicateGroups();
state.lastPlan = buildPlan();
renderInlineReasons(state.lastPlan);
renderSyncRecommendations(state.lastPlan);
if (
state.smartResolveUiActive &&
state.lastPlan &&
state.lastPlan.entries &&
state.lastPlan.entries.length
) {
ensureMatchSetAnchors();
renderPlanDetailsIntoDrawer(state.lastPlan);
updateUnresolvedButton(state.lastPlan, true);
setSmartResolveDetailsVisible(true, false);
}
if (typeof window !== "undefined") {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
window.scrollTo(0, scrollY);
});
});
}
}
function shouldRefreshAfterSync() {
var p = parseParams();
var distance = Number(p && p.distance) || 0;
var durationDiff = Number(p && p.durationDiff);
if (!Number.isFinite(durationDiff)) durationDiff = 1;
// Near-dupe mode can make duplicate query expensive; let user refresh manually.
return !(distance > 0 && durationDiff > 1);
}
function visibleGroups(groups) {
var p = parseParams();
var start = (p.page - 1) * p.size;
return groups.slice(start, start + p.size);
}
function parseDateForComparison(v) {
if (v == null) return new Date("2999-12-31T00:00:00Z").getTime();
var s = String(v).trim();
if (!s) return new Date("2999-12-31T00:00:00Z").getTime();
if (/^\d{4}$/.test(s)) s = s + "-12-31";
else if (/^\d{4}-\d{2}$/.test(s)) {
var y = parseInt(s.slice(0, 4), 10);
var m = parseInt(s.slice(5, 7), 10);
var lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate();
s = s + "-" + String(lastDay).padStart(2, "0");
}
var t = Date.parse(s);
if (Number.isNaN(t)) return new Date("2999-12-31T00:00:00Z").getTime();
return t;
}
var EARLIER_DATE_BUFFER_MS = 36 * 60 * 60 * 1000; // 1.5 days
function roundedDurationSeconds(v) {
var n = Number(v || 0) || 0;
return Math.round(n);
}
function fileHasUpgradeToken(scene) {
var f = primaryFile(scene);
var p = String(f.path || "").toUpperCase();
return p.indexOf("UPGRADE") !== -1;
}
function metadataCardinality(scene) {
var score = 0;
function hasText(v) {
return !!(v != null && String(v).trim());
}
if (hasText(scene.title)) score += 1;
if (hasText(scene.code)) score += 1;
if ((scene.urls || []).length) score += (scene.urls || []).length;
if (hasText(scene.date)) score += 1;
if (hasText(scene.director)) score += 1;
if ((scene.galleries || []).length) score += (scene.galleries || []).length;
if (scene.studio && scene.studio.id != null) score += 1;
if ((scene.performers || []).length) score += (scene.performers || []).length;
if ((scene.groups || []).length) score += (scene.groups || []).length;
if ((scene.tags || []).length) score += (scene.tags || []).length;
if (hasText(scene.details)) score += 1;
return score;
}
function eliminateByMetric(candidates, metricFn, mode) {
if (!candidates.length) return candidates;
var vals = candidates.map(metricFn);
var target = mode === "min" ? Math.min.apply(null, vals) : Math.max.apply(null, vals);
return candidates.filter(function (s) {
return metricFn(s) === target;
});
}
function eliminateByMaxWithinPercent(candidates, metricFn, tolerancePercent) {
if (!candidates.length) return candidates;
var vals = candidates.map(metricFn);
var maxVal = Math.max.apply(null, vals);
if (maxVal <= 0) return candidates.slice();
var tolerance = Math.max(0, Number(tolerancePercent || 0)) / 100;
var minAllowed = maxVal * (1 - tolerance);
return candidates.filter(function (s) {
return metricFn(s) >= minAllowed;
});
}
function eliminateByEarliestDateWithBuffer(candidates) {
if (!candidates.length) return candidates;
var vals = candidates.map(function (s) {
return parseDateForComparison(s.date);
});
var minVal = Math.min.apply(null, vals);
return candidates.filter(function (s) {
return parseDateForComparison(s.date) <= minVal + EARLIER_DATE_BUFFER_MS;
});
}
function chooseKeeperBySpec(group) {
var candidates = group.slice();
var decision = "step_14_scene_id";
var toggles = state.ruleToggles || defaultRuleToggles();
function enabled(key) {
return toggles[key] !== false;
}
function step(code, reducer) {
if (candidates.length <= 1) return;
var next = reducer(candidates);
if (next.length < candidates.length) {
decision = code;
}
candidates = next;
}
if (enabled("step_01_total_pixels")) step("step_01_total_pixels", function (arr) {
return eliminateByMaxWithinPercent(
arr,
function (s) {
var f = primaryFile(s);
return (f.width || 0) * (f.height || 0);
},
1
);
});
if (enabled("step_02_framerate")) step("step_02_framerate", function (arr) {
return eliminateByMetric(
arr,
function (s) {
var f = primaryFile(s);
return Number(f.frame_rate || f.framerate || 0) || 0;
},
"max"
);
});
if (enabled("step_03_codec")) step("step_03_codec", function (arr) {
return eliminateByMetric(
arr,
function (s) {
return codecRank(primaryFile(s).video_codec);
},
"max"
);
});
if (enabled("step_upgrade_token")) step("step_upgrade_token", function (arr) {
return eliminateByMetric(
arr,
function (s) { return fileHasUpgradeToken(s) ? 1 : 0; },
"max"
);
});
if (enabled("step_04_duration")) step("step_04_duration", function (arr) {
return eliminateByMetric(
arr,
function (s) {
return roundedDurationSeconds(primaryFile(s).duration || 0);
},
"max"
);
});
if (enabled("step_05_smaller_size")) step("step_05_smaller_size", function (arr) {
var minSize = Math.min.apply(
null,
arr.map(function (s) { return Number(primaryFile(s).size || 0) || 0; })
);
var sizeTolerance = Math.max(1024 * 1024, minSize * 0.01);
return arr.filter(function (s) {
var size = Number(primaryFile(s).size || 0) || 0;
return size <= minSize + sizeTolerance || fileHasUpgradeToken(s);
});
});
if (enabled("step_06_older_date")) step("step_06_older_date", function (arr) {
return eliminateByEarliestDateWithBuffer(arr);
});
if (enabled("step_07_more_groups")) step("step_07_more_groups", function (arr) {
return eliminateByMetric(arr, function (s) { return (s.groups || []).length; }, "max");
});
if (enabled("step_08_has_stashid")) step("step_08_has_stashid", function (arr) {
return eliminateByMetric(
arr,
function (s) { return (s.stash_ids || []).length > 0 ? 1 : 0; },
"max"
);
});
if (enabled("step_09_more_performers")) step("step_09_more_performers", function (arr) {
return eliminateByMetric(arr, function (s) { return (s.performers || []).length; }, "max");
});
if (enabled("step_10_more_markers")) step("step_10_more_markers", function (arr) {
return eliminateByMetric(arr, function (s) { return (s.scene_markers || []).length; }, "max");
});
if (enabled("step_11_more_tags")) step("step_11_more_tags", function (arr) {
return eliminateByMetric(arr, function (s) { return (s.tags || []).length; }, "max");
});
if (enabled("step_12_less_associated_files")) step("step_12_less_associated_files", function (arr) {
return eliminateByMetric(arr, function (s) { return (s.files || []).length; }, "min");
});
if (enabled("step_13_more_metadata_cardinality")) step("step_13_more_metadata_cardinality", function (arr) {
return eliminateByMetric(arr, metadataCardinality, "max");
});
// Step 14 is intentionally always on as deterministic fallback.
step("step_14_scene_id", function (arr) {
return eliminateByMetric(
arr,
function (s) { return parseInt(String(s.id), 10) || 0; },
"min"
);
});
var keeper = candidates[0] || group[0];
return { keeper: keeper, decisionCode: decision };
}
function groupEntries(scene) {
return (scene.groups || [])
.map(function (g) {
if (!g || !g.group || g.group.id == null) return null;
return {
id: String(g.group.id),
index: g.scene_index == null ? null : Number(g.scene_index),
};
})
.filter(function (x) { return !!x; });
}
function containsAllGroupEntries(keeper, other) {
var k = groupEntries(keeper);
var n = groupEntries(other);
return n.every(function (ne) {
return k.some(function (ke) {
return ke.id === ne.id && ke.index === ne.index;
});
});
}
function missingGroupEntries(keeper, other) {
var k = groupEntries(keeper);
var n = groupEntries(other);
return n.filter(function (ne) {
return !k.some(function (ke) {
return ke.id === ne.id && ke.index === ne.index;
});
});
}
function performerIds(scene) {
var seen = {};
return (scene.performers || [])
.map(function (p) {
if (!p || p.id == null) return null;
return String(p.id);
})
.filter(function (id) {
if (!id || seen[id]) return false;
seen[id] = true;
return true;
});
}
function missingPerformerIds(keeper, other) {
var kIds = performerIds(keeper);
var nIds = performerIds(other);
return nIds.filter(function (id) {
return kIds.indexOf(id) === -1;
});
}
function decisionReasonFromCode(code) {
var map = {
step_01_total_pixels: "keeper selected by highest total pixel resolution.",
step_02_framerate: "keeper selected by highest framerate.",
step_03_codec: "keeper selected by best codec tier.",
step_upgrade_token: "keeper selected by upgrade token preference.",
step_04_duration: "keeper selected by greater duration.",
step_05_smaller_size: "keeper selected by smaller file size.",
step_06_older_date: "keeper selected by older scene date.",
step_07_more_groups: "keeper selected by greater group count.",
step_08_has_stashid: "keeper selected by stash ID presence.",
step_09_more_performers: "keeper selected by greater performer count.",
step_10_more_markers: "keeper selected by greater marker count.",
step_11_more_tags: "keeper selected by greater tag count.",
step_12_less_associated_files: "keeper selected by fewer associated files.",
step_13_more_metadata_cardinality: "keeper selected by richer metadata cardinality.",
step_14_scene_id: "keeper selected by deterministic scene ID tie-break.",
};
return map[code] || "keeper selected by deterministic rule ordering.";
}
function evaluateNonKeeperProtection(keeper, nonKeeper) {
var res = {
markForDeletion: true,
markParentForSync: false,
exceptions: [],
};
var toggles = state.protectionToggles || defaultProtectionToggles();
function enabled(key) {
return toggles[key] !== false;
}
if (enabled("protect_o_count") && (nonKeeper.o_counter || 0) > 0) {
res.markForDeletion = false;
res.exceptions.push("protect_o_count");
}
var hasIgnoreSmartResolveTag = (nonKeeper.tags || []).some(function (t) {
return (
t &&
t.name != null &&
String(t.name).trim().toLowerCase() === "ignore:smart resolve"
);
});
if (enabled("protect_ignore_smart_resolve_tag") && hasIgnoreSmartResolveTag) {
res.markForDeletion = false;
res.exceptions.push("protect_ignore_smart_resolve_tag");
}
if (enabled("protect_group_association") && !containsAllGroupEntries(keeper, nonKeeper)) {
res.markForDeletion = false;
res.markParentForSync = true;
res.exceptions.push("protect_group_association");
}
var missingPerfs = missingPerformerIds(keeper, nonKeeper);
if (enabled("protect_performer_mismatch") && missingPerfs.length > 0) {
res.markForDeletion = false;
res.markParentForSync = true;
res.exceptions.push("protect_performer_mismatch");
}
var nStashed = (nonKeeper.stash_ids || []).length > 0;
var kTags = (keeper.tags || []).length;
var nTags = (nonKeeper.tags || []).length;
if (enabled("protect_tag_loss_gt_1_non_stashed") && !nStashed && nTags - kTags > 1) {
res.markForDeletion = false;
res.markParentForSync = true;
res.exceptions.push("protect_tag_loss_gt_1_non_stashed");
}
var kd = parseDateForComparison(keeper.date);
var nd = parseDateForComparison(nonKeeper.date);
var keeperRaw = keeper.date;
var nonRaw = nonKeeper.date;
if (
enabled("protect_older_date") &&
((keeperRaw == null && nonRaw != null) || kd - nd > EARLIER_DATE_BUFFER_MS)
) {
res.markForDeletion = false;
res.markParentForSync = true;
res.exceptions.push("protect_older_date");
}
return res;
}
function formatExceptionMessages(keeper, nonKeeper, exceptions) {
if (!exceptions || !exceptions.length) return [];
return exceptions.map(function (code) {
if (code === "protect_o_count") {
return "Non-keeper has O-count and is protected from deletion.";
}
if (code === "protect_ignore_smart_resolve_tag") {
return 'Target is tagged "Ignore:Smart Resolve" and is protected from deletion.';
}
if (code === "protect_group_association") {
var missing = missingGroupEntries(keeper, nonKeeper);
var details = missing.length
? missing
.map(function (m) {
return m.id + ":" + (m.index == null ? "null" : String(m.index));
})
.join(", ")
: "unknown";
return "Target has unmatched group associations (" + details + ").";
}
if (code === "protect_performer_mismatch") {
var missingPerfIds = missingPerformerIds(keeper, nonKeeper);
return missingPerfIds.length
? "Target has unmatched performer IDs (" + missingPerfIds.join(", ") + ")."
: "Target has unmatched performer IDs.";
}
if (code === "protect_tag_loss_gt_1_non_stashed") {
var kTags = (keeper.tags || []).length;
var nTags = (nonKeeper.tags || []).length;
return (
"Target has more than 1 additional tag than keeper (" +
nTags +
" vs " +
kTags +
")."
);
}
if (code === "protect_older_date") {
return "Target has an older date than keeper.";
}
return code;
});
}
function buildPlan() {
var groups = state.groups;
if (!groups || !groups.length)
return { entries: [], checks: {}, reasonsBySceneId: {}, syncRecommendedTargets: {} };
var vis = visibleGroups(groups);
var entries = [];
var checks = {};
var reasonsBySceneId = {};
var syncRecommendedTargets = {};
var unresolvedHighlightSceneIds = {};
vis.forEach(function (group, gi) {
if (!group || group.length < 2) return;
var keeperDecision = chooseKeeperBySpec(group);
var keeper = keeperDecision.keeper;
var baseReason = decisionReasonFromCode(keeperDecision.decisionCode);
var nonKeepers = group.filter(function (s) { return s.id !== keeper.id; });
var deleteIds = [];
var keeperNeedsSync = false;
nonKeepers.forEach(function (loser) {
var pr = evaluateNonKeeperProtection(keeper, loser);
if (pr.markForDeletion) deleteIds.push(loser.id);
else checks[loser.id] = false;
if (pr.markParentForSync || pr.exceptions.length) keeperNeedsSync = true;
var loserReason = baseReason;
if (pr.exceptions.length) {
var pretty = formatExceptionMessages(keeper, loser, pr.exceptions);
loserReason +=
" Exceptions: " +
pretty.join(" ") +
". Recommend synch data from duplicate.";
}
reasonsBySceneId[String(loser.id)] = loserReason;
});
if (keeperNeedsSync) {
syncRecommendedTargets[String(keeper.id)] = true;
group.forEach(function (s) {
unresolvedHighlightSceneIds[String(s.id)] = true;
});
}
entries.push({
setNumber: gi + 1,
keeperId: keeper.id,
deleteIds: deleteIds,
reason:
baseReason +
(keeperNeedsSync ? " Recommend synch data from duplicate." : ""),
});
group.forEach(function (s) {
if (s.id === keeper.id) {
checks[s.id] = false;
return;
}
if (!Object.prototype.hasOwnProperty.call(checks, s.id)) {
checks[s.id] = deleteIds.indexOf(s.id) !== -1;
}
});
});
return {
entries: entries,
checks: checks,
reasonsBySceneId: reasonsBySceneId,
syncRecommendedTargets: syncRecommendedTargets,
unresolvedHighlightSceneIds: unresolvedHighlightSceneIds,
};
}
function ensureMatchSetAnchors() {
var root = document.getElementById(ROOT_ID);
if (!root) return;
var rows = root.querySelectorAll("table.duplicate-checker-table tbody tr");
var setNum = 0;
Array.prototype.forEach.call(rows, function (tr) {
if (tr.classList.contains("duplicate-group")) {
setNum += 1;
tr.id = "dr-match-set-" + setNum;
}
});
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/\"/g, """)
.replace(/'/g, "'");
}
function renderPreviewHtml(plan) {
if (!plan || !plan.entries || !plan.entries.length) {
return "No duplicate pairs on this results page.";
}
return plan.entries
.map(function (e) {
return (
'' +
"Match Set " +
e.setNumber +
"" +
": KEEP " +
escapeHtml(e.keeperId) +
" | Select for DELETE " +
escapeHtml(e.deleteIds.join(", ")) +
" | Reason: " +
escapeHtml(e.reason)
);
})
.join("
");
}
function bindPreviewLinks(previewEl) {
var links = previewEl.querySelectorAll(".dr-match-link");
Array.prototype.forEach.call(links, function (a) {
a.addEventListener("click", function (ev) {
ev.preventDefault();
var id = a.getAttribute("data-target");
if (!id) return;
var target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
});
});
}
function sceneIdFromRow(tr) {
var a = tr.querySelector('td a[href^="/scenes/"]');
if (!a || !a.getAttribute("href")) return null;
var m = a.getAttribute("href").match(/\/scenes\/(\d+)/);
return m ? m[1] : null;
}
function setCheckboxForRow(tr, wantChecked) {
var inp = tr.querySelector("input[type=checkbox]");
if (!inp) return;
var cur = !!inp.checked;
if (cur !== wantChecked) {
inp.click();
}
}
function applyChecks(checkMap) {
var root = document.getElementById(ROOT_ID);
if (!root) return;
var rows = root.querySelectorAll("table.duplicate-checker-table tbody tr");
rows.forEach(function (tr) {
if (tr.classList.contains("separator")) return;
var sid = sceneIdFromRow(tr);
if (!sid || !Object.prototype.hasOwnProperty.call(checkMap, sid)) return;
setCheckboxForRow(tr, checkMap[sid]);
});
}
function clearInlineReasons() {
var root = document.getElementById(ROOT_ID);
if (!root) return;
root.querySelectorAll(".dr-inline-reason").forEach(function (el) {
el.remove();
});
root
.querySelectorAll("table.duplicate-checker-table tbody tr.dr-unresolved-highlight")
.forEach(function (tr) {
tr.classList.remove("dr-unresolved-highlight");
});
}
function renderInlineReasons(plan) {
var root = document.getElementById(ROOT_ID);
if (!root) return;
clearInlineReasons();
if (!plan || !plan.reasonsBySceneId) return;
var highlightMap = (plan && plan.unresolvedHighlightSceneIds) || {};
var rows = root.querySelectorAll("table.duplicate-checker-table tbody tr");
rows.forEach(function (tr) {
if (tr.classList.contains("separator")) return;
var sid = sceneIdFromRow(tr);
if (!sid) return;
if (highlightMap[String(sid)]) {
tr.classList.add("dr-unresolved-highlight");
}
var reason = plan.reasonsBySceneId[String(sid)];
if (!reason) return;
var titleCell = tr.querySelector("td.text-left");
if (!titleCell) return;
var p = document.createElement("p");
p.className = "scene-path dr-inline-reason";
p.textContent = "Smart Resolve: " + reason;
titleCell.appendChild(p);
});
}
function renderSyncRecommendations(plan) {
var root = document.getElementById(ROOT_ID);
if (!root) return;
var targets = (plan && plan.syncRecommendedTargets) || {};
root.querySelectorAll(".duplicate-resolver-sync-btn").forEach(function (btn) {
var sid = String(btn.getAttribute("data-scene-id") || "");
var recommend = !!targets[sid];
var desiredLabel = recommend ? "Sync rec." : "Sync data";
var desiredTitle = recommend
? "Recommended: sync data from duplicate into this scene."
: "";
var hasWarning = btn.classList.contains("btn-warning");
var hasSecondary = btn.classList.contains("btn-secondary");
var classMismatch = recommend
? !hasWarning || hasSecondary
: hasWarning || !hasSecondary;
// Only mutate DOM if state actually changed (prevents observer churn loops).
if (classMismatch) {
btn.classList.remove("btn-secondary", "btn-warning");
btn.classList.add(recommend ? "btn-warning" : "btn-secondary");
}
if (btn.textContent !== desiredLabel) btn.textContent = desiredLabel;
if ((btn.getAttribute("title") || "") !== desiredTitle) btn.setAttribute("title", desiredTitle);
});
}
function buildSmartResolveChecks(plan) {
var checks = {};
if (!plan || !plan.entries || !plan.entries.length) return checks;
var syncTargets = (plan && plan.syncRecommendedTargets) || {};
plan.entries.forEach(function (entry) {
var keeperId = String(entry.keeperId);
if (syncTargets[keeperId]) return;
(entry.deleteIds || []).forEach(function (id) {
checks[String(id)] = true;
});
});
return checks;
}
function unresolvedInfo(plan) {
var info = { count: 0, firstSetNumber: null };
if (!plan || !plan.entries || !plan.entries.length) return info;
var syncTargets = (plan && plan.syncRecommendedTargets) || {};
plan.entries.forEach(function (entry) {
if (!syncTargets[String(entry.keeperId)]) return;
info.count += 1;
if (info.firstSetNumber == null) info.firstSetNumber = entry.setNumber;
});
return info;
}
function updateUnresolvedButton(plan, show) {
var btn = document.getElementById("dr-btn-unresolved");
if (!btn) return;
if (!show) {
btn.hidden = true;
btn.disabled = true;
btn.removeAttribute("data-target-set");
return;
}
var info = unresolvedInfo(plan);
btn.hidden = false;
btn.textContent = info.count + " Unresolved";
btn.disabled = info.count === 0;
if (info.firstSetNumber == null) {
btn.removeAttribute("data-target-set");
btn.setAttribute("title", "No unresolved sync recommendations on this page.");
} else {
btn.setAttribute("data-target-set", String(info.firstSetNumber));
btn.setAttribute("title", "Jump to first unresolved match set.");
}
}
function setProcessingIndicator(mode) {
var el = document.getElementById("dr-processing-indicator");
if (!el) return;
var spinner = el.querySelector(".dr-processing-spinner");
var bar = el.querySelector(".dr-processing-bar");
var label = el.querySelector(".dr-processing-label");
var normalized = mode === "bar" || mode === "spinner" ? mode : "none";
if (normalized === "none") {
el.hidden = true;
return;
}
el.hidden = false;
if (spinner) spinner.hidden = normalized !== "spinner";
if (bar) bar.hidden = normalized !== "bar";
if (label) label.textContent = "Processing…";
}
function goToFirstUnresolved(plan) {
ensureMatchSetAnchors();
var info = unresolvedInfo(plan);
if (info.firstSetNumber == null) return;
var target = document.getElementById("dr-match-set-" + info.firstSetNumber);
if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
}
function renderPlanDetailsIntoDrawer(plan) {
var prev = document.getElementById("dr-preview-out");
if (!prev) return;
prev.innerHTML = renderPreviewHtml(plan);
bindPreviewLinks(prev);
}
function ensureCoreSelectSmartResolveOption() {
var root = document.getElementById(ROOT_ID);
if (!root) return;
var menuItems = root.querySelectorAll(".dropdown-menu .dropdown-item");
if (!menuItems || !menuItems.length) return;
var anchor = null;
menuItems.forEach(function (item) {
if ((item.textContent || "").trim() === "Select None") anchor = item;
});
if (!anchor) return;
var menu = anchor.closest(".dropdown-menu");
if (!menu || menu.querySelector("#dr-smart-resolve-option")) return;
var btn = document.createElement("button");
btn.type = "button";
btn.id = "dr-smart-resolve-option";
btn.className = "dropdown-item";
btn.textContent = "Select Smart Resolve";
btn.onclick = async function () {
setProcessingIndicator("spinner");
try {
// Always refresh to avoid stale state after SPA table changes (pagination/deletes).
await loadDuplicateGroups();
// Use URL page-size for indicator mode. If absent/unparseable, assume 20.
var pageSize = parseParams().size || 20;
if (pageSize > 20) {
setProcessingIndicator("bar");
}
state.smartResolveUiActive = true;
// Let the processing indicator paint before running heavier rule evaluation.
await new Promise(function (resolve) {
requestAnimationFrame(resolve);
});
state.lastPlan = buildPlan();
ensureMatchSetAnchors();
renderPlanDetailsIntoDrawer(state.lastPlan);
renderInlineReasons(state.lastPlan);
renderSyncRecommendations(state.lastPlan);
applyChecks(buildSmartResolveChecks(state.lastPlan));
updateUnresolvedButton(state.lastPlan, true);
setSmartResolveDetailsVisible(true, false);
} catch (e) {
notifyStashError(e);
} finally {
setProcessingIndicator("none");
}
};
anchor.parentNode.insertBefore(btn, anchor.nextSibling);
}
function placeToolbarButtonsInCoreRow() {
var root = document.getElementById(ROOT_ID);
if (!root) return;
var bar = document.getElementById("duplicate-resolver-toolbar");
if (!bar) return;
var unresolvedBtn = bar.querySelector("#dr-btn-unresolved");
var processingIndicator = bar.querySelector("#dr-processing-indicator");
var autoBtn = bar.querySelector("#dr-btn-apply");
var resetBtn = bar.querySelector("#dr-btn-reset");
if (!unresolvedBtn || !processingIndicator || !autoBtn || !resetBtn) return;
var toggle = root.querySelector(".dropdown .dropdown-toggle");
if (!toggle || !toggle.parentNode) return;
var host = document.getElementById("dr-core-actions");
if (!host) {
host = document.createElement("span");
host.id = "dr-core-actions";
host.className = "dr-core-actions";
toggle.parentNode.insertBefore(host, toggle.nextSibling);
}
host.appendChild(unresolvedBtn);
host.appendChild(processingIndicator);
host.appendChild(resetBtn);
host.appendChild(autoBtn);
}
function setSmartResolveDetailsVisible(show, expandDrawer) {
var bar = document.getElementById("duplicate-resolver-toolbar");
if (!bar) return;
var drawerToggle = bar.querySelector("#dr-drawer-toggle");
var drawerPanel = bar.querySelector("#dr-drawer-panel");
var resetBtn = bar.querySelector("#dr-btn-reset");
var unresolvedBtn = bar.querySelector("#dr-btn-unresolved");
var processingIndicator = bar.querySelector("#dr-processing-indicator");
if (!drawerToggle || !drawerPanel) return;
bar.hidden = !show;
drawerToggle.hidden = !show;
if (resetBtn) resetBtn.hidden = !show;
if (unresolvedBtn) unresolvedBtn.hidden = !show;
if (processingIndicator) processingIndicator.hidden = !show;
if (!show) {
state.smartResolveUiActive = false;
drawerPanel.hidden = true;
drawerToggle.setAttribute("aria-expanded", "false");
drawerToggle.textContent = "Match Details: \u25b6";
updateUnresolvedButton(null, false);
return;
}
if (expandDrawer) {
drawerPanel.hidden = false;
drawerToggle.setAttribute("aria-expanded", "true");
drawerToggle.textContent = "Match Details: \u25bc";
}
}
async function loadPluginSetting() {
try {
var data = await gql(
"query DrCfg { configuration { plugins } }"
);
var plug = data.configuration && data.configuration.plugins;
var cfg = null;
if (plug && typeof plug === "object") {
cfg = plug[PLUGIN_ID] || null;
if (!cfg) {
var k = Object.keys(plug).find(function (key) {
return String(key).toLowerCase() === String(PLUGIN_ID).toLowerCase();
});
if (k) cfg = plug[k];
}
}
if (cfg && typeof cfg === "object") {
var v = cfg.autoCheckAfterSync;
if (v === true || v === "true") state.autoCheckDefault = true;
else if (v === false || v === "false") state.autoCheckDefault = false;
function boolOrDefault(key, fallback) {
var raw = cfg[key];
if (raw === true || raw === "true") return true;
if (raw === false || raw === "false") return false;
return fallback;
}
state.ruleToggles = {
step_01_total_pixels: !boolOrDefault("ignoreRule01TotalPixels", false),
step_02_framerate: !boolOrDefault("ignoreRule02Framerate", false),
step_03_codec: !boolOrDefault("ignoreRule03Codec", false),
step_upgrade_token: !boolOrDefault("ignoreRule05bUpgradeToken", false),
step_04_duration: !boolOrDefault("ignoreRule04Duration", false),
step_05_smaller_size: !boolOrDefault("ignoreRule05SmallerSize", false),
step_06_older_date: !boolOrDefault("ignoreRule06OlderDate", false),
step_07_more_groups: !boolOrDefault("ignoreRule07MoreGroups", false),
step_08_has_stashid: !boolOrDefault("ignoreRule08HasStashId", false),
step_09_more_performers: !boolOrDefault("ignoreRule09MorePerformers", false),
step_10_more_markers: !boolOrDefault("ignoreRule10MoreMarkers", false),
step_11_more_tags: !boolOrDefault("ignoreRule11MoreTags", false),
step_12_less_associated_files: !boolOrDefault("ignoreRule12LessAssociatedFiles", false),
step_13_more_metadata_cardinality: !boolOrDefault(
"ignoreRule13MoreMetadataCardinality",
false
),
};
state.protectionToggles = {
protect_o_count: !boolOrDefault("unprotectAOCount", false),
protect_group_association: !boolOrDefault("unprotectBGroupAssociation", false),
protect_performer_mismatch: !boolOrDefault("unprotectCPerformerMismatch", false),
protect_tag_loss_gt_1_non_stashed: !boolOrDefault("unprotectDTagLossGt1NonStashed", false),
protect_older_date: !boolOrDefault("unprotectEOlderDate", false),
protect_ignore_smart_resolve_tag: !boolOrDefault("unprotectFIgnoreSmartResolveTag", false),
};
}
} catch (e) {
state.autoCheckDefault = true;
state.ruleToggles = defaultRuleToggles();
state.protectionToggles = defaultProtectionToggles();
}
}
function mergeIds(target, additions) {
var set = new Set();
(target || []).forEach(function (x) { set.add(String(x)); });
(additions || []).forEach(function (x) { set.add(String(x)); });
return Array.from(set);
}
function mergeStashIds(target, additions) {
var map = new Map();
(target || []).forEach(function (s) {
if (!s || !s.endpoint || !s.stash_id) return;
// Stash enforces UNIQUE(scene_id, endpoint): keep one stash_id per endpoint.
// Prefer existing target value when endpoint already exists.
if (!map.has(s.endpoint)) {
map.set(s.endpoint, { endpoint: s.endpoint, stash_id: s.stash_id });
}
});
(additions || []).forEach(function (s) {
if (!s || !s.endpoint || !s.stash_id) return;
if (!map.has(s.endpoint)) {
map.set(s.endpoint, { endpoint: s.endpoint, stash_id: s.stash_id });
}
});
return Array.from(map.values()).map(function (s) {
return { endpoint: s.endpoint, stash_id: s.stash_id };
});
}
/**
* Image URL/base64 for the scene *cover* (UI + sceneUpdate `cover_image`).
* Stash `/scene/{id}/webp` is an animated *stream preview*, not cover — do not use.
* `/scene/{id}/screenshot` is served from the cover store first (see Stash SceneServer.ServeScreenshot).
*/
function sceneCoverDataUrl(scene) {
if (!scene) return "";
var c = scene.cover_image;
if (c && String(c).trim()) return String(c).trim();
var p = scene.paths || {};
var shot = p.screenshot;
return shot && String(shot).trim() ? String(shot).trim() : "";
}
function sceneResolution(scene) {
var f = primaryFile(scene);
var w = Number(f.width || 0) || 0;
var h = Number(f.height || 0) || 0;
return { width: w, height: h, totalPixels: w * h };
}
function sceneResolutionLabel(scene) {
var r = sceneResolution(scene);
if (r.width > 0 && r.height > 0) {
return r.width + "px x " + r.height + "px";
}
return "resolution unknown";
}
function mergeGroups(target, additions) {
var map = new Map();
(target || []).forEach(function (g) {
map.set(String(g.group.id), {
group_id: g.group.id,
scene_index: g.scene_index != null ? g.scene_index : null,
});
});
(additions || []).forEach(function (g) {
var id = String(g.group.id);
if (!map.has(id))
map.set(id, {
group_id: g.group.id,
scene_index: g.scene_index != null ? g.scene_index : null,
});
});
return Array.from(map.values());
}
/** Union groups from target + sources (scene `groups` shape). */
function collectMergedGroups(target, sources, enabled) {
if (!enabled) return mergeGroups(target.groups, []);
var map = new Map();
function addAll(arr) {
(arr || []).forEach(function (g) {
var id = String(g.group.id);
if (!map.has(id))
map.set(id, {
group_id: g.group.id,
scene_index: g.scene_index != null ? g.scene_index : null,
});
});
}
addAll(target.groups);
sources.forEach(function (s) {
addAll(s.groups);
});
return Array.from(map.values());
}
function buildSceneUpdateInput(target, sources, opt) {
var tag_ids = (target.tags || []).map(function (t) { return t.id; });
var performer_ids = (target.performers || []).map(function (t) { return t.id; });
var gallery_ids = (target.galleries || []).map(function (t) { return t.id; });
var urls = (target.urls || []).slice();
var stash_ids = target.stash_ids || [];
sources.forEach(function (src) {
if (opt.tags)
tag_ids = mergeIds(
tag_ids,
(src.tags || []).map(function (t) { return t.id; })
);
if (opt.performers)
performer_ids = mergeIds(
performer_ids,
(src.performers || []).map(function (t) { return t.id; })
);
if (opt.galleries)
gallery_ids = mergeIds(
gallery_ids,
(src.galleries || []).map(function (t) { return t.id; })
);
if (opt.urls) {
(src.urls || []).forEach(function (u) {
if (urls.indexOf(u) === -1) urls.push(u);
});
}
if (opt.stash_ids)
stash_ids = mergeStashIds(stash_ids, src.stash_ids || []);
});
var groups = collectMergedGroups(target, sources, opt.groups);
var input = {
id: target.id,
tag_ids: tag_ids,
performer_ids: performer_ids,
gallery_ids: gallery_ids,
groups: groups,
urls: urls,
stash_ids: stash_ids,
};
function hasText(v) {
return !!String(v || "").trim();
}
function sceneHasStashId(s) {
return !!((s && s.stash_ids && s.stash_ids.length) || 0);
}
function dateUpperBoundParts(raw) {
if (!raw || !String(raw).trim()) return null;
var m = String(raw).trim().match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/);
if (!m) return null;
var y = parseInt(m[1], 10);
var mo = m[2] ? parseInt(m[2], 10) : 12;
var d;
if (m[3]) {
d = parseInt(m[3], 10);
} else {
d = new Date(y, mo, 0).getDate();
}
return [y, mo, d];
}
function isDateBefore(a, b) {
var pa = dateUpperBoundParts(a);
var pb = dateUpperBoundParts(b);
if (!pa && !pb) return false;
if (!pa) return false;
if (!pb) return true;
if (pa[0] !== pb[0]) return pa[0] < pb[0];
if (pa[1] !== pb[1]) return pa[1] < pb[1];
return pa[2] < pb[2];
}
function pickSourceValue(field) {
var candidates = sources.filter(function (s) {
if (field === "studio") return !!(s.studio && s.studio.id);
if (field === "cover_image") return !!sceneCoverDataUrl(s);
return hasText(s[field]);
});
if (!candidates.length) return null;
if (field === "date") {
return candidates.reduce(function (best, cur) {
return isDateBefore(cur.date, best.date) ? cur : best;
}).date;
}
if (field === "cover_image") {
var bestCover = candidates.reduce(function (best, cur) {
var b = sceneResolution(best).totalPixels;
var c = sceneResolution(cur).totalPixels;
if (c !== b) return c > b ? cur : best;
var bestStash = sceneHasStashId(best) ? 1 : 0;
var curStash = sceneHasStashId(cur) ? 1 : 0;
if (curStash !== bestStash) return curStash > bestStash ? cur : best;
return best;
});
return sceneCoverDataUrl(bestCover);
}
var stashPreferred = candidates.find(sceneHasStashId);
var chosen = stashPreferred || candidates[0];
if (field === "studio") return chosen.studio.id;
if (field === "cover_image") return sceneCoverDataUrl(chosen);
return chosen[field];
}
var scalarWins = opt.scalarWins || {};
if (scalarWins.title === "source") {
var srcTitle = pickSourceValue("title");
if (hasText(srcTitle)) input.title = srcTitle;
}
if (scalarWins.code === "source") {
var srcCode = pickSourceValue("code");
if (hasText(srcCode)) input.code = srcCode;
}
if (scalarWins.director === "source") {
var srcDirector = pickSourceValue("director");
if (hasText(srcDirector)) input.director = srcDirector;
}
if (scalarWins.details === "source") {
var srcDetails = pickSourceValue("details");
if (hasText(srcDetails)) input.details = srcDetails;
}
if (scalarWins.date === "source") {
var srcDate = pickSourceValue("date");
if (hasText(srcDate)) input.date = srcDate;
}
if (scalarWins.studio === "source") {
var srcStudio = pickSourceValue("studio");
if (srcStudio) input.studio_id = srcStudio;
}
if (scalarWins.cover_image === "source") {
var srcCover = pickSourceValue("cover_image");
if (hasText(srcCover)) input.cover_image = srcCover;
}
return input;
}
/**
* Stash resolves `cover_image` URLs on the *server*. If the server cannot
* reach its public hostname (split DNS / hairpin), fetch here in the browser
* and send base64 data instead.
*/
function absolutizeMediaUrl(u) {
var s = String(u || "").trim();
if (!s) return s;
if (s.indexOf("/") === 0) return window.location.origin + s;
return s;
}
function fetchUrlAsDataUrl(url) {
var abs = absolutizeMediaUrl(url);
return fetch(abs, { credentials: "include" }).then(function (res) {
if (!res.ok)
throw new Error("Could not load cover image (" + res.status + ")");
return res.blob();
}).then(function (blob) {
return new Promise(function (resolve, reject) {
var r = new FileReader();
r.onload = function () {
resolve(r.result);
};
r.onerror = function () {
reject(new Error("Could not read cover image data"));
};
r.readAsDataURL(blob);
});
});
}
async function inlineRemoteCoverImages(input) {
var c = input && input.cover_image;
if (!c || typeof c !== "string") return;
var t = c.trim();
if (!t) return;
if (t.toLowerCase().indexOf("data:image") === 0) return;
if (
t.indexOf("http://") === 0 ||
t.indexOf("https://") === 0 ||
t.indexOf("/") === 0
) {
input.cover_image = await fetchUrlAsDataUrl(t);
}
}
async function runSceneUpdate(input) {
var mut =
"mutation DrSceneUpdate($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id } }";
await gql(mut, { input: input });
}
function showModal(target, group) {
var sources = group.filter(function (s) { return s.id !== target.id; });
var overlay = document.createElement("div");
overlay.id = "duplicate-resolver-modal-overlay";
var autoId = "dr-auto-check";
var opt = {
tags: true,
performers: true,
groups: true,
galleries: true,
urls: true,
stash_ids: true,
scalarWins: {},
};
function hasText(v) {
return !!String(v || "").trim();
}
function sceneHasStashId(s) {
return !!((s && s.stash_ids && s.stash_ids.length) || 0);
}
function dateUpperBoundParts(raw) {
if (!raw || !String(raw).trim()) return null;
var m = String(raw).trim().match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/);
if (!m) return null;
var y = parseInt(m[1], 10);
var mo = m[2] ? parseInt(m[2], 10) : 12;
var d;
if (m[3]) {
d = parseInt(m[3], 10);
} else {
d = new Date(y, mo, 0).getDate();
}
return [y, mo, d];
}
function isDateBefore(a, b) {
var pa = dateUpperBoundParts(a);
var pb = dateUpperBoundParts(b);
if (!pa && !pb) return false;
if (!pa) return false;
if (!pb) return true;
if (pa[0] !== pb[0]) return pa[0] < pb[0];
if (pa[1] !== pb[1]) return pa[1] < pb[1];
return pa[2] < pb[2];
}
function pickSourceScene(field) {
var candidates = sources.filter(function (s) {
if (field === "studio") return !!(s.studio && s.studio.id);
if (field === "cover_image") return !!sceneCoverDataUrl(s);
return hasText(s[field]);
});
if (!candidates.length) return null;
if (field === "date") {
return candidates.reduce(function (best, cur) {
return isDateBefore(cur.date, best.date) ? cur : best;
});
}
if (field === "cover_image") {
return candidates.reduce(function (best, cur) {
var b = sceneResolution(best).totalPixels;
var c = sceneResolution(cur).totalPixels;
if (c !== b) return c > b ? cur : best;
var bestStash = sceneHasStashId(best) ? 1 : 0;
var curStash = sceneHasStashId(cur) ? 1 : 0;
if (curStash !== bestStash) return curStash > bestStash ? cur : best;
return best;
});
}
var stashPreferred = candidates.find(sceneHasStashId);
return stashPreferred || candidates[0];
}
function sourceValueForField(field) {
var s = pickSourceScene(field);
if (!s) return "";
if (field === "studio") {
if (s.studio && s.studio.name) return s.studio.name;
if (s.studio && s.studio.id) return "Studio " + s.studio.id;
return "";
}
if (field === "cover_image") return sceneCoverDataUrl(s);
return String(s[field] || "").trim();
}
function defaultScalarWinner(field) {
var targetHas = sceneHasStashId(target);
var sourceHasAny = sources.some(sceneHasStashId);
if (field === "date") {
var targetDate = String(target.date || "").trim();
var sourceDate = sourceValueForField("date");
if (!targetDate && sourceDate) return "source";
if (targetDate && sourceDate && isDateBefore(sourceDate, targetDate)) return "source";
return "dest";
}
if (field === "cover_image") {
var tc = sceneCoverDataUrl(target);
var sc = sourceValueForField("cover_image");
var sourceScene = pickSourceScene("cover_image");
var sourcePixels = sourceScene ? sceneResolution(sourceScene).totalPixels : 0;
var targetPixels = sceneResolution(target).totalPixels;
if (sourcePixels > targetPixels) return "source";
if (targetPixels > sourcePixels) return "dest";
if (!tc && sc) return "source";
if (!targetHas && sourceHasAny && sc) return "source";
return "dest";
}
// For text/scalar fields (including title/details), prefer source when destination is blank.
var targetFieldHasValue =
field === "studio"
? !!(target.studio && target.studio.id)
: hasText(target[field]);
if (!targetFieldHasValue && hasText(sourceValueForField(field))) return "source";
if (!targetHas && sourceHasAny && hasText(sourceValueForField(field))) return "source";
return "dest";
}
function row(name, key) {
var lab = document.createElement("label");
lab.className = "dr-field-title";
var toggle = choicePrepend(!!opt[key], "Toggle " + name);
toggle.root.style.marginRight = "0.45rem";
toggle.button.onclick = function () {
opt[key] = !opt[key];
toggle.button.innerHTML = opt[key]
? '✓'
: '✕';
if (hint) hint.hidden = !opt[key];
};
lab.appendChild(toggle.root);
lab.appendChild(document.createTextNode(name));
var unionKeys = {
tags: true,
performers: true,
groups: true,
galleries: true,
urls: true,
stash_ids: true,
};
var hint = null;
if (unionKeys[key]) {
hint = document.createElement("span");
hint.className = "dr-opt-hint";
hint.textContent = " union all";
hint.hidden = !opt[key];
lab.appendChild(hint);
}
return lab;
}
var modal = document.createElement("div");
modal.className = "dr-modal";
modal.innerHTML =
'
" + sources.length + " scene(s): " + sources.map(function (s) { return s.id; }).join(", ") + "
" + "ID " + target.id + " - " + textOrFallback(target.title, "(no title)") + "
" + "