Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions plugins/GroupDetails/GroupDetails.css
Original file line number Diff line number Diff line change
@@ -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;
}
318 changes: 318 additions & 0 deletions plugins/GroupDetails/GroupDetails.js
Original file line number Diff line number Diff line change
@@ -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();
});
})();
9 changes: 9 additions & 0 deletions plugins/GroupDetails/GroupDetails.yml
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions plugins/GroupDetails/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading