snapshot user threshold..

This commit is contained in:
legop3
2026-07-16 17:29:00 -04:00
parent 0c9df78070
commit efae430d65
15 changed files with 202 additions and 74 deletions
+32 -8
View File
@@ -10,7 +10,10 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
multiTabProtection: 'verifiedOnly',
nonTurnVideo: 'snapshots',
nonTurnVideo: Object.freeze({
mode: 'snapshots',
userThreshold: 0,
}),
externalSpectatorVideo: 'snapshots',
externalSpectatorAccess: 'on',
});
@@ -25,6 +28,23 @@ function normalizeEnum(value, allowed, fallback) {
return allowed.has(normalized) ? normalized : fallback;
}
function normalizeNonTurnVideo(value) {
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const threshold = Number(raw.userThreshold);
/*
userThreshold is intentionally "greater than", not "greater than or equal".
A value of 4 means the first four controllable users can keep live non-turn
video, and the fifth controllable user activates snapshot saving. Invalid
or negative values fall back to zero, which preserves always-on snapshots
for any real non-turn participant.
*/
const userThreshold = Number.isFinite(threshold) ? Math.max(0, Math.floor(threshold)) : 0;
return {
mode: normalizeEnum(raw.mode, VIDEO_MODES, DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo.mode),
userThreshold,
};
}
function buildBandwidthSavingsPolicy(config = loadConfig()) {
const raw = config.bandwidthSavings || {};
return {
@@ -33,11 +53,7 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
MULTI_TAB_MODES,
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
),
nonTurnVideo: normalizeEnum(
raw.nonTurnVideo,
VIDEO_MODES,
DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo,
),
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
externalSpectatorVideo: normalizeEnum(
raw.externalSpectatorVideo,
VIDEO_MODES,
@@ -72,8 +88,16 @@ function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } =
return !isVerified && !isAdmin;
}
function shouldUseSnapshotsForNonTurnVideo() {
return getBandwidthSavingsPolicy().nonTurnVideo === 'snapshots';
function shouldUseSnapshotsForNonTurnVideo({ controllableUserCount = 0 } = {}) {
const { nonTurnVideo } = getBandwidthSavingsPolicy();
if (nonTurnVideo.mode !== 'snapshots') return false;
/*
The threshold is evaluated centrally so MediaMTX auth, socket-issued video
tokens, PTZ authorization, and browser session state all agree. Using a
strict greater-than comparison makes the configured value read like the
maximum number of controllable users allowed before snapshots start.
*/
return Math.max(0, Number(controllableUserCount) || 0) > nonTurnVideo.userThreshold;
}
function shouldUseSnapshotsForExternalSpectatorVideo() {
+33 -1
View File
@@ -339,6 +339,34 @@ function getChatTargetForSocket(socketId) {
};
}
function getParticipantSocketIds() {
/*
PTZ has no roverManager record, so services that need a global "how many
controllable users are online" count need a tiny PTZ-owned participant list.
The operator and queue are the only users attached to this controllable
camera target; spectators merely viewing snapshots/live video are excluded.
*/
return Array.from(new Set([
state.operatorSocketId,
...state.queue,
].filter(Boolean)));
}
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
return ids.size;
}
function canSpeakThroughPtz(socket) {
/*
PTZ chat uses roverId for identity, but the camera has its own queue rather
@@ -1305,7 +1333,10 @@ function canRequestLiveVideo(socket) {
*/
return local || !shouldUseSnapshotsForExternalSpectatorVideo();
}
if (canUsePtzFeature(socket) && !shouldUseSnapshotsForNonTurnVideo()) {
if (
canUsePtzFeature(socket) &&
!shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() })
) {
/*
Verified/VIP users who can queue or claim the camera are PTZ "turn"
participants even before they become operator. When non-turn video is set
@@ -1577,6 +1608,7 @@ module.exports = {
ptzCameraEvents: events,
getPublicState,
getChatTargetForSocket,
getParticipantSocketIds,
canSpeakThroughPtz,
speakText,
canRequestLiveVideo,
+41 -14
View File
@@ -42,6 +42,7 @@ const { getFeatureFlags } = require('../../helpers/features');
const {
canUseExternalSpectatorAccess,
getBandwidthSavingsPolicy,
shouldUseSnapshotsForNonTurnVideo,
} = require('../../helpers/bandwidthSavings');
const {
getFeatureState,
@@ -79,12 +80,17 @@ function hasExternalSpectatorGrant(socket) {
return Boolean(state?.external);
}
function buildBandwidthSavingsSessionState(socket) {
function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
const policy = getBandwidthSavingsPolicy();
const local = isLocalNetwork(getSocketIp(socket));
const granted = hasExternalSpectatorGrant(socket);
return {
...policy,
nonTurnVideo: {
...policy.nonTurnVideo,
controllableUserCount,
snapshotsActive: shouldUseSnapshotsForNonTurnVideo({ controllableUserCount }),
},
/*
These derived fields let browser routes make clear UI choices without
re-implementing IP/admin/grant logic. The server still enforces the same
@@ -100,6 +106,24 @@ function buildBandwidthSavingsSessionState(socket) {
};
}
function countControllableUsers(userEntries = []) {
const ids = new Set();
userEntries.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const socketId = String(entry?.socketId || '').trim();
const roverId = String(entry?.roverId || '').trim();
/*
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
id and normal drivers to their physical rover. Counting entries after that
normalization gives the browser the same conceptual "controllable users"
count it shows in the user/queue panels without duplicating PTZ UI logic.
*/
if (socketId && roverId) ids.add(socketId);
});
return ids.size;
}
function buildUserEntry(socket) {
if (!socket) return null;
const role = getRole(socket);
@@ -124,19 +148,22 @@ function buildUserEntry(socket) {
function buildSession(socket) {
const overseerVote = getOverseerVoteStatus();
const features = getFeatureFlags();
const users = Array.from(io.sockets.sockets.values())
const userEntries = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock))
.filter(Boolean)
.map((entry) => ({
...entry,
/*
PTZ is intentionally not a roverManager record, so the normal physical
rover visibility filter would erase the user's PTZ chat target. Preserve
it here because getPtzChatTargetForSocket already applied the PTZ access
and queue/operator rules before buildUserEntry returned it.
*/
roverId: entry.roverId === PTZ_CAMERA_ID ? entry.roverId : filterVisibleRoverId(socket, entry.roverId),
}));
.filter(Boolean);
const controllableUserCount = countControllableUsers(userEntries);
const users = userEntries.map((entry) => ({
...entry,
/*
PTZ is intentionally not a roverManager record, so the normal physical
rover visibility filter would erase the user's PTZ chat target. Preserve
it here because getPtzChatTargetForSocket already applied the PTZ access
and queue/operator rules before buildUserEntry returned it.
*/
roverId: entry.roverId === PTZ_CAMERA_ID
? entry.roverId
: filterVisibleRoverId(socket, entry.roverId),
}));
const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || '');
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
@@ -148,7 +175,7 @@ function buildSession(socket) {
role: getRole(socket),
mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket),
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
/*
Features is the single UI contract for optional server capabilities. A
disabled feature should be absent from navigation/layout decisions even
@@ -30,6 +30,7 @@ const { canAccessStream } = createVideoAuthPolicy({
ptzCameraService,
getSocketIp,
isLocalNetwork,
io,
});
registerVideoAuthRoute({
+28 -1
View File
@@ -19,8 +19,31 @@ function createVideoAuthPolicy(deps) {
ptzCameraService,
getSocketIp,
isLocalNetwork,
io,
} = deps;
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
/*
MediaMTX can ask for authorization after a browser has already received
a token, so this count intentionally mirrors videoSocketService instead
of trusting the client-visible session policy snapshot.
*/
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
if (typeof ptzCameraService.getParticipantSocketIds === 'function') {
ptzCameraService.getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
}
return ids.size;
}
function canView(socket) {
const mode = getMode();
if (!socket) return false;
@@ -78,7 +101,11 @@ function createVideoAuthPolicy(deps) {
if (!roverManager.isDriver(roverId, socket)) {
return false;
}
if (!isAudio && shouldUseSnapshotsForNonTurnVideo() && !turnService.canDrive(roverId, socket)) {
if (
!isAudio &&
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
!turnService.canDrive(roverId, socket)
) {
/*
This mirrors videoSocketService's token gate. MediaMTX can ask auth
after a token has been issued, so the active-turn bandwidth rule must
@@ -85,6 +85,29 @@ function canViewRoomCamera(socket) {
return passesMode(socket);
}
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
/*
Rover drivers and PTZ participants are both "controllable" users for this
bandwidth decision because either group can create a non-turn video view.
Counting unique socket ids prevents someone who is transitioning between
rover and PTZ from being counted twice.
*/
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
if (typeof ptzCameraService.getParticipantSocketIds === 'function') {
ptzCameraService.getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
}
return ids.size;
}
function normalizeRequest(payload = {}) {
if (!payload) return null;
if (payload.type && payload.id) {
@@ -129,7 +152,7 @@ io.on('connection', (socket) => {
!isAudio &&
role !== 'spectator' &&
!isAdmin(socket) &&
shouldUseSnapshotsForNonTurnVideo() &&
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
!turnService.canDrive(baseId, socket)
) {
/*