diff --git a/plugins/GroupDetails/GroupDetails.css b/plugins/GroupDetails/GroupDetails.css new file mode 100644 index 00000000..fa1d4473 --- /dev/null +++ b/plugins/GroupDetails/GroupDetails.css @@ -0,0 +1,20 @@ +.group-card .card-popovers .gd-stat { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 4.5rem; + padding: 0.2rem 0.45rem; + font-size: 0.9rem; + line-height: 1.1; + color: var(--text, #d5dbe3); + opacity: 0.95; + white-space: nowrap; +} + +.group-card .card-popovers .gd-stat-left { + margin-right: 0.25rem; +} + +.group-card .card-popovers .gd-stat-right { + margin-left: 0.25rem; +} diff --git a/plugins/GroupDetails/GroupDetails.js b/plugins/GroupDetails/GroupDetails.js new file mode 100644 index 00000000..66ee9aee --- /dev/null +++ b/plugins/GroupDetails/GroupDetails.js @@ -0,0 +1,318 @@ +"use strict"; + +(function () { + var ROOT_ID = "root"; + var ROUTE_PREFIX = "/groups"; + var GROUP_METRICS_QUERY = + "query GroupDetailsMetrics($id: ID!) {" + + " findGroup(id: $id) {" + + " id " + + " scenes { " + + " id " + + " files { duration height } " + + " groups { group { id } scene_index } " + + " } " + + " }" + + "}"; + + var state = { + observer: null, + attachedRoot: null, + retryTimer: null, + applyingDomEnhancements: false, + cacheByGroupId: new Map(), + inFlightByGroupId: new Map(), + }; + + 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 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 computeMetrics(groupId, scenes) { + var totalDurationSec = 0; + var verticalSum = 0; + var verticalCount = 0; + var list = scenes || []; + + for (var i = 0; i < list.length; i++) { + var scene = list[i]; + var idx = sceneIndexForGroup(scene, groupId); + if (!isEligibleSceneIndex(idx)) continue; + + var duration = getSceneDurationSeconds(scene); + totalDurationSec += duration; + + if (duration > 600) { + var height = getSceneVerticalPixels(scene); + if (height > 0) { + verticalSum += height; + verticalCount += 1; + } + } + } + + return { + totalDurationSec: Math.round(totalDurationSec), + averageVerticalPixels: + verticalCount > 0 ? Math.round(verticalSum / verticalCount) : null, + verticalSampleCount: verticalCount, + }; + } + + 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) || []); + } + + async function getMetricsForGroup(groupId) { + if (!groupId) return null; + var gid = String(groupId); + if (state.cacheByGroupId.has(gid)) return state.cacheByGroupId.get(gid); + if (state.inFlightByGroupId.has(gid)) return state.inFlightByGroupId.get(gid); + + var p = fetchMetricsForGroup(gid) + .then(function (metrics) { + state.cacheByGroupId.set(gid, metrics); + state.inFlightByGroupId.delete(gid); + return metrics; + }) + .catch(function (e) { + state.inFlightByGroupId.delete(gid); + throw e; + }); + state.inFlightByGroupId.set(gid, p); + return p; + } + + 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 formatVerticalPixels(avgHeight) { + var n = Number(avgHeight); + if (!Number.isFinite(n) || n <= 0) return "n/a"; + return String(Math.round(n)) + "p"; + } + + function buildStatNode(id, text, title) { + var el = document.createElement("span"); + el.id = id; + el.className = "gd-stat"; + el.textContent = text; + if (title) el.title = title; + return el; + } + + 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 oldLeft = popovers.querySelector(".gd-stat-left"); + if (oldLeft && oldLeft.parentNode) oldLeft.parentNode.removeChild(oldLeft); + var oldRight = popovers.querySelector(".gd-stat-right"); + if (oldRight && oldRight.parentNode) oldRight.parentNode.removeChild(oldRight); + + var durationNode = buildStatNode( + "gd-stat-left-" + Date.now(), + formatDuration(metrics.totalDurationSec), + "Total duration for scenes with scene_index null or 0..89" + ); + durationNode.classList.add("gd-stat-left"); + + var resolutionNode = buildStatNode( + "gd-stat-right-" + Date.now(), + formatVerticalPixels(metrics.averageVerticalPixels), + "Average vertical resolution for those scenes with duration > 600s" + ); + resolutionNode.classList.add("gd-stat-right"); + + popovers.insertBefore(durationNode, sceneCount); + if (sceneCount.nextSibling) popovers.insertBefore(resolutionNode, sceneCount.nextSibling); + else popovers.appendChild(resolutionNode); + } + + 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; + } + var root = document.getElementById(ROOT_ID); + if (!root) return false; + + if (state.attachedRoot === root && state.observer) { + 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; + + 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(); + }); +})(); diff --git a/plugins/GroupDetails/GroupDetails.yml b/plugins/GroupDetails/GroupDetails.yml new file mode 100644 index 00000000..643e896f --- /dev/null +++ b/plugins/GroupDetails/GroupDetails.yml @@ -0,0 +1,9 @@ +name: Group Details +description: Adds group-card metrics for filtered duration and average vertical resolution. +version: 0.1.0 +url: https://github.com/stashapp/CommunityScripts/tree/main/plugins/GroupDetails +ui: + javascript: + - GroupDetails.js + css: + - GroupDetails.css diff --git a/plugins/GroupDetails/README.md b/plugins/GroupDetails/README.md new file mode 100644 index 00000000..448704e3 --- /dev/null +++ b/plugins/GroupDetails/README.md @@ -0,0 +1,33 @@ +# Group Details + +`Group Details` is a UI plugin for Stash group pages. + +It adds two computed metrics to each group card's stats row, flanking the scene-count element: + +- Left: total duration (`H:MM:SS`) +- Right: average vertical resolution (`###p`) + +## Filtering rules + +Both metrics only consider scenes whose `scene_index` for the current group is: + +- `null`, or +- an integer in the inclusive range `0..89` + +This excludes common trailer/bonus/out-of-band indices such as `-1`, `90`, `99`, etc. + +## Resolution rule + +Average resolution is based on vertical pixels (`height`) with an additional duration filter: + +- Include only eligible scenes where `duration > 600` seconds. +- Compute as `round(sum(height) / count)`. +- Display as `NNNp` (for example, `720p`). + +## Duration rule + +Total duration is the sum of eligible scene durations and is displayed as `H:MM:SS`. + +## Data source + +The plugin fetches group scene data through GraphQL (`findGroup`) and computes metrics in-browser. It does not rely on card DOM text parsing.