Files
CommunityScripts/plugins/GroupDetails/GroupDetails.js
2026-04-16 00:30:56 +03:00

760 lines
24 KiB
JavaScript

"use strict";
(function () {
var ROOT_ID = "root";
var ROUTE_PREFIX = "/groups";
var PLUGIN_ID = "GroupDetails";
var GROUP_METRICS_QUERY =
"query GroupDetailsMetrics($id: ID!) {" +
" findGroup(id: $id) {" +
" id " +
" scenes { " +
" id " +
" title " +
" files { duration height } " +
" performers { id name image_path } " +
" groups { group { id } scene_index } " +
" } " +
" }" +
"}";
var state = {
observer: null,
attachedRoot: null,
retryTimer: null,
applyingDomEnhancements: false,
cacheByGroupId: new Map(),
inFlightByGroupId: new Map(),
includeAllScenes: false,
includePerformers: false,
tooltipDelegateBound: false,
};
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;
}
function routeMatches() {
var p = window.location.pathname || "";
return p === ROUTE_PREFIX || p.indexOf(ROUTE_PREFIX + "/") === 0;
}
function parseGroupIdFromHref(href) {
if (!href) return null;
var match = String(href).match(/\/groups\/(\d+)/);
return match ? String(match[1]) : null;
}
function parseGroupIdFromCard(card) {
if (!card) return null;
var header = card.querySelector("a.group-card-header");
if (header && header.getAttribute("href")) {
return parseGroupIdFromHref(header.getAttribute("href"));
}
var firstLink = card.querySelector("a[href*='/groups/']");
if (firstLink && firstLink.getAttribute("href")) {
return parseGroupIdFromHref(firstLink.getAttribute("href"));
}
return null;
}
function sceneIndexForGroup(scene, groupId) {
var groups = (scene && scene.groups) || [];
var gid = String(groupId);
for (var i = 0; i < groups.length; i++) {
var g = groups[i];
if (g && g.group && String(g.group.id) === gid) return g.scene_index;
}
return undefined;
}
function isEligibleSceneIndex(idx) {
if (idx == null) return true;
var n = Number(idx);
return Number.isFinite(n) && n >= 0 && n <= 89;
}
function readBoolSetting(raw, fallback) {
if (raw === true || raw === "true") return true;
if (raw === false || raw === "false") return false;
return fallback;
}
async function loadPluginSettings() {
try {
var data = await gql("query GdCfg { 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];
}
}
var next = false;
var nextPerformers = false;
if (cfg && typeof cfg === "object") {
next = readBoolSetting(cfg.includeAllScenes, false);
nextPerformers = readBoolSetting(cfg.includePerformers, false);
}
if (
next !== state.includeAllScenes ||
nextPerformers !== state.includePerformers
) {
state.includeAllScenes = next;
state.includePerformers = nextPerformers;
state.cacheByGroupId.clear();
state.inFlightByGroupId.clear();
}
} catch (e) {
state.includeAllScenes = false;
state.includePerformers = false;
}
}
function metricsCacheKey(groupId) {
return (
String(groupId) +
":" +
(state.includeAllScenes ? "1" : "0") +
":" +
(state.includePerformers ? "1" : "0")
);
}
function getSceneDurationSeconds(scene) {
var files = (scene && scene.files) || [];
var maxDur = 0;
for (var i = 0; i < files.length; i++) {
var dur = Number(files[i] && files[i].duration);
if (Number.isFinite(dur) && dur > maxDur) maxDur = dur;
}
return maxDur;
}
function getSceneVerticalPixels(scene) {
var files = (scene && scene.files) || [];
var maxHeight = 0;
for (var i = 0; i < files.length; i++) {
var h = Number(files[i] && files[i].height);
if (Number.isFinite(h) && h > maxHeight) maxHeight = h;
}
return maxHeight;
}
function formatDuration(totalSeconds) {
var s = Math.max(0, Math.round(Number(totalSeconds) || 0));
var hrs = Math.floor(s / 3600);
var mins = Math.floor((s % 3600) / 60);
var secs = s % 60;
return hrs + ":" + String(mins).padStart(2, "0") + ":" + String(secs).padStart(2, "0");
}
function formatSceneTooltipLine(sceneIndex, title, durationSec) {
var t = String(title || "").replace(/\s+/g, " ").trim();
if (!t) t = "(no title)";
var hasIndex = !(sceneIndex == null || sceneIndex === "");
if (hasIndex) return String(sceneIndex) + ". " + t + " " + formatDuration(durationSec);
return t + " " + formatDuration(durationSec);
}
/** For ordering only: missing index sorts like 90. */
function sceneIndexSortKey(idx) {
if (idx == null || idx === "") return 90;
var n = Number(idx);
return Number.isFinite(n) ? n : 90;
}
function computeMetrics(groupId, scenes, includeAllScenes) {
var totalDurationSec = 0;
var verticalSum = 0;
var verticalCount = 0;
var totalFileCount = 0;
var list = scenes || [];
var applySceneIndexFilter =
!includeAllScenes && list.length !== 1;
var rows = [];
for (var i = 0; i < list.length; i++) {
var scene = list[i];
var idx = sceneIndexForGroup(scene, groupId);
if (applySceneIndexFilter && !isEligibleSceneIndex(idx)) continue;
var duration = getSceneDurationSeconds(scene);
rows.push({ scene: scene, idx: idx, duration: duration });
}
rows.sort(function (a, b) {
var ka = sceneIndexSortKey(a.idx);
var kb = sceneIndexSortKey(b.idx);
if (ka !== kb) return ka - kb;
var da = Number(a.duration) || 0;
var db = Number(b.duration) || 0;
if (db !== da) return db - da;
var ida = String((a.scene && a.scene.id) || "");
var idb = String((b.scene && b.scene.id) || "");
return ida < idb ? -1 : ida > idb ? 1 : 0;
});
for (var fc = 0; fc < rows.length; fc++) {
var filesForCount = (rows[fc].scene && rows[fc].scene.files) || [];
totalFileCount += filesForCount.length;
}
var bypassDurationFilterForResolution = totalFileCount === 1;
var durationLines = [];
var performerById = new Map();
for (var j = 0; j < rows.length; j++) {
var row = rows[j];
var scene = row.scene;
var idx = row.idx;
var duration = row.duration;
totalDurationSec += duration;
durationLines.push(
formatSceneTooltipLine(idx, scene && scene.title, duration)
);
if (bypassDurationFilterForResolution || duration > 360) {
var height = getSceneVerticalPixels(scene);
if (height > 0) {
verticalSum += height;
verticalCount += 1;
}
}
var perfs = (scene && scene.performers) || [];
for (var p = 0; p < perfs.length; p++) {
var perf = perfs[p];
if (!perf) continue;
var pid = String(perf.id || perf.name || "");
if (!pid) continue;
if (!performerById.has(pid)) {
performerById.set(pid, {
id: String(perf.id || ""),
name: String(perf.name || "").trim() || "(unknown performer)",
imagePath: String(perf.image_path || ""),
});
}
}
}
var durationHeader =
"Scenes in total duration:\n";
var durationTooltip =
durationLines.length > 0
? durationHeader + durationLines.join("\n")
: "No eligible scenes for total duration.";
var avgPx =
verticalCount > 0 ? Math.round(verticalSum / verticalCount) : null;
var resolutionTooltip =
avgPx == null || verticalCount < 1
? "Resolution Average: \u2014"
: "Resolution Average: " + avgPx + "p";
return {
totalDurationSec: Math.round(totalDurationSec),
averageVerticalPixels: avgPx,
verticalSampleCount: verticalCount,
totalFileCount: totalFileCount,
durationTooltip: durationTooltip,
resolutionTooltip: resolutionTooltip,
performers: Array.from(performerById.values()).sort(function (a, b) {
return a.name.localeCompare(b.name);
}),
};
}
var RESOLUTION_PNG_LADDER = [
{ value: 4320, label: "8k", file: "8k.png" },
{ value: 3160, label: "6k", file: "6k.png" },
{ value: 2880, label: "5k", file: "5k.png" },
{ value: 2160, label: "4k", file: "4k.png" },
{ value: 1440, label: "2k", file: "2k.png" },
{ value: 1080, label: "1080p", file: "1080p.png" },
{ value: 720, label: "720p", file: "720p.png" },
{ value: 480, label: "480p", file: "480p.png" },
{ value: 360, label: "360p", file: "360p.png" },
{ value: 240, label: "240p", file: "240p.png" },
];
var LOWEST_RESOLUTION_PNG = { value: 144, label: "144p", file: "144p.png" };
var RESOLUTION_MATCH_RATIO = 0.98; // 2% tolerance
var LOWEST_RESOLUTION_CUTOFF = 234;
function pickResolutionPngSpec(avgHeightPx) {
var h = Math.round(Number(avgHeightPx) || 0);
if (!Number.isFinite(h) || h <= 0) return null;
if (h < LOWEST_RESOLUTION_CUTOFF) return LOWEST_RESOLUTION_PNG;
for (var i = 0; i < RESOLUTION_PNG_LADDER.length; i++) {
var spec = RESOLUTION_PNG_LADDER[i];
if (h >= Math.round(spec.value * RESOLUTION_MATCH_RATIO)) return spec;
}
return LOWEST_RESOLUTION_PNG;
}
function getEmbeddedResolutionImage(fileName) {
var map =
typeof window !== "undefined" && window.GroupDetailsImages
? window.GroupDetailsImages
: null;
if (!map) return "";
var key = String(fileName || "");
var uri = map[key];
return typeof uri === "string" ? uri : "";
}
function createResolutionPng(spec) {
if (!spec || !spec.file) return null;
var src = getEmbeddedResolutionImage(spec.file);
if (!src) return null;
var img = document.createElement("img");
img.className = "gd-resolution-png";
img.alt = spec.label;
img.setAttribute("aria-hidden", "true");
img.src = src;
return img;
}
function buildResolutionBucket(id, avgPixels, resolutionTooltip, totalFileCount) {
var wrap = document.createElement("span");
wrap.id = id;
wrap.className = "gd-stat gd-res-bucket";
wrap.setAttribute("role", "img");
var tip = resolutionTooltip || "";
var h = avgPixels == null ? NaN : Math.round(Number(avgPixels));
if (!Number.isFinite(h) || h <= 0) {
if ((Number(totalFileCount) || 0) > 1) {
wrap.textContent = "\u2014";
applySceneListTooltip(wrap, tip);
} else {
// No files (or only one file with no usable height): render nothing.
wrap.textContent = "";
applySceneListTooltip(wrap, "");
}
return wrap;
}
var spec = pickResolutionPngSpec(h);
if (spec) {
wrap.setAttribute("data-gd-resolution-tier", spec.label);
}
var img = createResolutionPng(spec);
if (img) wrap.appendChild(img);
else {
var fb = document.createElement("span");
fb.className = "gd-res-bucket-fallback";
fb.textContent = h + "p";
wrap.appendChild(fb);
}
applySceneListTooltip(wrap, tip);
return wrap;
}
function normalizePerformerImageUrl(path) {
var p = String(path || "");
if (!p) return "";
if (/^https?:\/\//i.test(p)) return p;
if (p.indexOf("/") === 0) return p;
return "/" + p;
}
function wirePerformerPopoverHover(trigger, popover) {
if (!trigger || !popover) return;
var enterDelayMs = 200;
var leaveDelayMs = 200;
var enterTimer = null;
var leaveTimer = null;
function openSoon() {
if (leaveTimer) {
clearTimeout(leaveTimer);
leaveTimer = null;
}
if (trigger.classList.contains("gd-open")) return;
if (enterTimer) clearTimeout(enterTimer);
enterTimer = setTimeout(function () {
trigger.classList.add("gd-open");
enterTimer = null;
}, enterDelayMs);
}
function closeSoon() {
if (enterTimer) {
clearTimeout(enterTimer);
enterTimer = null;
}
if (leaveTimer) clearTimeout(leaveTimer);
leaveTimer = setTimeout(function () {
trigger.classList.remove("gd-open");
leaveTimer = null;
}, leaveDelayMs);
}
function onEnter() {
openSoon();
}
function onLeave() {
closeSoon();
}
trigger.addEventListener("mouseenter", onEnter);
trigger.addEventListener("mouseleave", onLeave);
popover.addEventListener("mouseenter", onEnter);
popover.addEventListener("mouseleave", onLeave);
trigger.addEventListener("focusin", onEnter);
trigger.addEventListener("focusout", onLeave);
}
function buildPerformerChip(id, performers) {
var list = Array.isArray(performers) ? performers : [];
if (list.length === 0) return null;
var wrap = document.createElement("span");
wrap.id = id;
wrap.className = "gd-performer-count performer-count";
wrap.setAttribute("role", "img");
wrap.setAttribute("aria-label", "Performers: " + list.length);
var btn = document.createElement("button");
btn.type = "button";
btn.className = "minimal btn btn-primary";
btn.tabIndex = -1;
btn.setAttribute("aria-hidden", "true");
var icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
icon.setAttribute("data-prefix", "fas");
icon.setAttribute("data-icon", "user");
icon.setAttribute("class", "svg-inline--fa fa-user fa-icon");
icon.setAttribute("role", "img");
icon.setAttribute("viewBox", "0 0 448 512");
var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute(
"d",
"M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512l388.6 0c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304l-91.4 0z"
);
path.setAttribute("fill", "currentColor");
icon.appendChild(path);
var count = document.createElement("span");
count.textContent = String(list.length);
btn.appendChild(icon);
btn.appendChild(count);
wrap.appendChild(btn);
var pop = document.createElement("div");
pop.className = "gd-performer-popover popover bs-popover-bottom hover-popover-content";
var arrow = document.createElement("div");
arrow.className = "arrow";
pop.appendChild(arrow);
var body = document.createElement("div");
body.className = "popover-body";
for (var i = 0; i < list.length; i++) {
var perf = list[i];
var item = document.createElement("div");
item.className = "gd-performer-item";
var thumbLink = document.createElement(perf.id ? "a" : "div");
thumbLink.className = "gd-performer-thumb";
if (perf.id) thumbLink.href = "/performers/" + encodeURIComponent(String(perf.id));
var img = document.createElement("img");
img.className = "gd-performer-image image-thumbnail";
img.alt = perf.name;
var src = normalizePerformerImageUrl(perf.imagePath);
if (src) img.src = src;
else img.src = "/images/wall-item/performer";
thumbLink.appendChild(img);
var name = document.createElement(perf.id ? "a" : "span");
name.className = "gd-performer-name d-block";
name.textContent = perf.name;
if (perf.id) name.href = "/performers/" + encodeURIComponent(String(perf.id));
item.appendChild(thumbLink);
item.appendChild(name);
body.appendChild(item);
}
pop.appendChild(body);
wrap.appendChild(pop);
wirePerformerPopoverHover(wrap, pop);
return wrap;
}
async function fetchMetricsForGroup(groupId) {
var data = await gql(GROUP_METRICS_QUERY, { id: String(groupId) });
var group = data && data.findGroup;
return computeMetrics(
groupId,
(group && group.scenes) || [],
state.includeAllScenes
);
}
async function getMetricsForGroup(groupId) {
if (!groupId) return null;
var gid = String(groupId);
var key = metricsCacheKey(gid);
if (state.cacheByGroupId.has(key)) return state.cacheByGroupId.get(key);
if (state.inFlightByGroupId.has(key)) return state.inFlightByGroupId.get(key);
var p = fetchMetricsForGroup(gid)
.then(function (metrics) {
state.cacheByGroupId.set(key, metrics);
state.inFlightByGroupId.delete(key);
return metrics;
})
.catch(function (e) {
state.inFlightByGroupId.delete(key);
throw e;
});
state.inFlightByGroupId.set(key, p);
return p;
}
function applySceneListTooltip(el, tip) {
var s = tip == null ? "" : String(tip);
if (s.length > 0) {
el.setAttribute("data-gd-full-title", s);
el.setAttribute("title", s);
} else {
el.removeAttribute("data-gd-full-title");
el.removeAttribute("title");
}
}
function ensureTooltipRefreshDelegate() {
if (state.tooltipDelegateBound) return;
state.tooltipDelegateBound = true;
document.addEventListener(
"pointerenter",
function (ev) {
var raw = ev.target;
var el = raw && raw.nodeType === 1 ? raw : raw && raw.parentElement;
if (!el || !el.closest) return;
var host = el.closest("[data-gd-full-title]");
if (!host) return;
var full = host.getAttribute("data-gd-full-title");
if (full != null) host.setAttribute("title", full);
},
true
);
}
function buildStatNode(id, text, title) {
var el = document.createElement("span");
el.id = id;
el.className = "gd-stat";
el.textContent = text;
applySceneListTooltip(el, title);
return el;
}
function findDateLineInCard(card) {
if (!card || !card.querySelectorAll) return null;
var re = /^\d{4}-\d{2}-\d{2}$/;
var nodes = card.querySelectorAll("time, small, span, div, p");
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
if (!el || !el.textContent) continue;
if (el.closest && el.closest(".card-popovers")) continue;
if (el.querySelector && el.querySelector(".gd-date-duration")) continue;
var raw = String(el.textContent || "").trim();
if (re.test(raw)) return el;
}
return null;
}
function mountDurationOnDateLine(card, durationNode) {
if (!card || !durationNode) return false;
var old = card.querySelectorAll(".gd-date-duration");
for (var i = 0; i < old.length; i++) {
if (old[i] && old[i].parentNode) old[i].parentNode.removeChild(old[i]);
}
var line = findDateLineInCard(card);
if (!line) return false;
line.classList.add("gd-date-line");
var textSpan = line.querySelector(".gd-date-text");
if (!textSpan) {
var original = String(line.textContent || "").trim();
line.textContent = "";
textSpan = document.createElement("span");
textSpan.className = "gd-date-text";
textSpan.textContent = original;
line.appendChild(textSpan);
}
durationNode.classList.add("gd-date-duration");
line.appendChild(durationNode);
return true;
}
function injectMetricsIntoCard(card, metrics) {
if (!card || !metrics) return;
var popovers = card.querySelector(".card-popovers");
if (!popovers) return;
var sceneCount = popovers.querySelector(".scene-count");
if (!sceneCount) return;
var oldRow = popovers.querySelector(".gd-metrics-row");
if (oldRow && oldRow.parentNode) oldRow.parentNode.removeChild(oldRow);
var oldRight = popovers.querySelector(".gd-stat-right");
if (oldRight && oldRight.parentNode) oldRight.parentNode.removeChild(oldRight);
var oldPerf = popovers.querySelector(".gd-performer-count");
if (oldPerf && oldPerf.parentNode) oldPerf.parentNode.removeChild(oldPerf);
var durationNode = buildStatNode(
"gd-stat-duration-" + Date.now(),
formatDuration(metrics.totalDurationSec),
metrics.durationTooltip || ""
);
durationNode.classList.add("gd-duration");
var resolutionNode = buildResolutionBucket(
"gd-stat-right-" + Date.now(),
metrics.averageVerticalPixels,
metrics.resolutionTooltip || "",
metrics.totalFileCount
);
resolutionNode.classList.add("gd-stat-right");
resolutionNode.classList.add("chip");
if (state.includePerformers) {
var performerNode = buildPerformerChip(
"gd-performer-" + Date.now(),
metrics.performers
);
if (performerNode) popovers.appendChild(performerNode);
}
popovers.appendChild(resolutionNode);
mountDurationOnDateLine(card, durationNode);
}
async function decorateGroupCard(card) {
var groupId = parseGroupIdFromCard(card);
if (!groupId) return;
var metrics = await getMetricsForGroup(groupId);
injectMetricsIntoCard(card, metrics);
}
function applyDomEnhancements() {
if (state.applyingDomEnhancements) return;
state.applyingDomEnhancements = true;
var cards = Array.prototype.slice.call(
document.querySelectorAll("div.group-card")
);
Promise.all(
cards.map(function (card) {
return decorateGroupCard(card).catch(function () {
// Ignore per-card failures so one bad response does not block others.
});
})
).finally(function () {
state.applyingDomEnhancements = false;
});
}
function detachObserver() {
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
state.attachedRoot = null;
}
function clearRetryTimer() {
if (state.retryTimer) {
clearInterval(state.retryTimer);
state.retryTimer = null;
}
}
function attach() {
if (!routeMatches()) {
detachObserver();
return false;
}
ensureTooltipRefreshDelegate();
var root = document.getElementById(ROOT_ID);
if (!root) return false;
if (state.attachedRoot === root && state.observer) {
loadPluginSettings()
.then(function () {})
.catch(function () {})
.finally(function () {
applyDomEnhancements();
});
return true;
}
detachObserver();
state.cacheByGroupId.clear();
state.inFlightByGroupId.clear();
var obs = new MutationObserver(function () {
applyDomEnhancements();
});
obs.observe(root, { childList: true, subtree: true });
state.observer = obs;
state.attachedRoot = root;
loadPluginSettings()
.then(function () {})
.catch(function () {})
.finally(function () {
applyDomEnhancements();
});
return true;
}
function scheduleAttachRetries() {
clearRetryTimer();
state.retryTimer = setInterval(function () {
try {
if (!routeMatches()) {
detachObserver();
return;
}
if (attach()) clearRetryTimer();
} catch (e) {
// Ignore transient route/render timing errors.
}
}, 500);
setTimeout(clearRetryTimer, 60000);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
attach();
scheduleAttachRetries();
});
} else {
attach();
scheduleAttachRetries();
}
window.addEventListener("popstate", function () {
attach();
scheduleAttachRetries();
});
window.addEventListener("hashchange", function () {
attach();
scheduleAttachRetries();
});
})();