@mulingai-npm/redis 3.40.54 → 3.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import { RedisClient } from '../redis-client';
|
|
2
|
-
import { ListenerPresence } from '../data/listener-presence';
|
|
3
|
-
export { LISTENER_PRESENT_MS, LISTENER_GONE_MS, LISTENER_HEARTBEAT_INTERVAL_MS, listenerPresence } from '../data/listener-presence';
|
|
4
|
-
export type { ListenerPresence } from '../data/listener-presence';
|
|
5
2
|
export type ListenerBreakpoint = 'mobile' | 'desktop' | 'display';
|
|
6
3
|
export type MulingstreamListenerData = {
|
|
7
4
|
listenerId: string;
|
|
@@ -15,54 +12,12 @@ export type MulingstreamListenerData = {
|
|
|
15
12
|
color?: string;
|
|
16
13
|
isListening?: boolean;
|
|
17
14
|
breakpoint?: ListenerBreakpoint;
|
|
18
|
-
/**
|
|
19
|
-
* The listener told us their page went into the background: screen locked,
|
|
20
|
-
* app switched, tab hidden.
|
|
21
|
-
*
|
|
22
|
-
* Set by the client rather than inferred, because the socket cannot tell the
|
|
23
|
-
* difference between a locked phone and a closed tab, and waiting for missed
|
|
24
|
-
* heartbeats to decide costs ninety seconds of translating for somebody who
|
|
25
|
-
* is demonstrably not looking. Cleared by the next heartbeat, which is what a
|
|
26
|
-
* returning page sends immediately.
|
|
27
|
-
*
|
|
28
|
-
* Being away is not being gone. See data/listener-presence.ts.
|
|
29
|
-
*/
|
|
30
|
-
isAway?: boolean;
|
|
31
15
|
};
|
|
32
16
|
export declare class MulingstreamListenerManager {
|
|
33
17
|
private redisClient;
|
|
34
18
|
constructor(redisClient: RedisClient);
|
|
35
19
|
private parseHashData;
|
|
36
20
|
addListener(listenerData: MulingstreamListenerData): Promise<string>;
|
|
37
|
-
/**
|
|
38
|
-
* Every listener currently known, across every room.
|
|
39
|
-
*
|
|
40
|
-
* WHY THIS USES SCAN AND NOT KEYS
|
|
41
|
-
*
|
|
42
|
-
* It used to be `KEYS 'listener:*'`, with a TODO next to it saying it should
|
|
43
|
-
* not be. KEYS walks the entire keyspace in one go and Redis is single
|
|
44
|
-
* threaded, so for as long as it runs nothing else in the process is served:
|
|
45
|
-
* not a heartbeat, not a chunk being handed to the pipeline, not a listener
|
|
46
|
-
* joining. The cost is invisible on a development database with a few dozen
|
|
47
|
-
* keys and grows with the busiest moment we will ever have, which is precisely
|
|
48
|
-
* when it must not stall. This is called by the cleanup sweep every sixty
|
|
49
|
-
* seconds, for the entire life of the process, so it would have been the most
|
|
50
|
-
* frequently executed blocking command in the system.
|
|
51
|
-
*
|
|
52
|
-
* SCAN answers the same question in cursored batches, yielding between each,
|
|
53
|
-
* so a large keyspace costs more round trips rather than one long stall. The
|
|
54
|
-
* guarantees are weaker, deliberately: a key added or removed mid-scan may or
|
|
55
|
-
* may not appear. That is exactly right for the caller, which is looking for
|
|
56
|
-
* listeners who went silent minutes ago. One arriving during the scan is one
|
|
57
|
-
* for the next sweep, sixty seconds later, and there is nothing to do about a
|
|
58
|
-
* brand new listener anyway.
|
|
59
|
-
*
|
|
60
|
-
* The pattern also has to reject keys that merely start with `listener:`. The
|
|
61
|
-
* legacy `listener:panel-state:*` keys matched the old pattern and were parsed
|
|
62
|
-
* as listener records, producing entries with NaN timestamps. They never
|
|
63
|
-
* caused harm, since NaN fails every comparison, which is exactly how a
|
|
64
|
-
* defect like that survives.
|
|
65
|
-
*/
|
|
66
21
|
getAllListeners(): Promise<MulingstreamListenerData[]>;
|
|
67
22
|
getListenersByRoom(roomId: string): Promise<MulingstreamListenerData[]>;
|
|
68
23
|
getListener(listenerIdOrToken: string): Promise<MulingstreamListenerData | null>;
|
|
@@ -75,112 +30,13 @@ export declare class MulingstreamListenerManager {
|
|
|
75
30
|
*/
|
|
76
31
|
updateBreakpoint(listenerIdOrToken: string, breakpoint: ListenerBreakpoint): Promise<MulingstreamListenerData | null>;
|
|
77
32
|
getTargetSocketIdsByRoomLanguage(roomId: string, language: string): Promise<string[]>;
|
|
78
|
-
/**
|
|
79
|
-
* Which languages this room has someone PRESENT on.
|
|
80
|
-
*
|
|
81
|
-
* Feeds consumersFor, which decides what gets translated at all, so an
|
|
82
|
-
* over-generous answer here is money spent on every chunk for nobody. It
|
|
83
|
-
* therefore counts present listeners only: somebody whose phone has been dark
|
|
84
|
-
* for two minutes is receiving nothing, and translating for them is pure loss.
|
|
85
|
-
*
|
|
86
|
-
* It used to count every listener the room had ever registered, including ones
|
|
87
|
-
* whose heartbeat had been silent for fourteen minutes, because the only thing
|
|
88
|
-
* that removed a listener was the cleanup sweep. See data/listener-presence.ts
|
|
89
|
-
* for why that single threshold could not be lowered and had to be split.
|
|
90
|
-
*/
|
|
91
33
|
getUniqueLanguagesByRoom(roomId: string): Promise<string[]>;
|
|
92
|
-
private guestLanguagesKey;
|
|
93
|
-
/** Record that a guest, not the host, put this language in the room. */
|
|
94
|
-
markGuestLanguage(roomId: string, language: string): Promise<void>;
|
|
95
|
-
/** Forget a guest language, once it has been released from the room. */
|
|
96
|
-
unmarkGuestLanguage(roomId: string, language: string): Promise<void>;
|
|
97
|
-
/** Every language in this room that a guest added. */
|
|
98
|
-
getGuestLanguages(roomId: string): Promise<string[]>;
|
|
99
|
-
/**
|
|
100
|
-
* How many PRESENT listeners in this room are on a language.
|
|
101
|
-
*
|
|
102
|
-
* Decides whether a guest-added language slot can be handed back, and counts
|
|
103
|
-
* the same population `consumersFor` reads, so the two cannot disagree about
|
|
104
|
-
* whether a language has an audience: a slot is released exactly when the
|
|
105
|
-
* translation for it stops, never one without the other.
|
|
106
|
-
*
|
|
107
|
-
* Present rather than merely registered, and that is the deliberate part. A
|
|
108
|
-
* listener whose phone has been dark for two minutes is not hearing the
|
|
109
|
-
* language, so holding the room's last slot for them denies it to a guest
|
|
110
|
-
* standing in the room who is. If they come back and the slot has gone to
|
|
111
|
-
* somebody else, they choose again from what the room now has; if it is still
|
|
112
|
-
* free they simply take it back.
|
|
113
|
-
*/
|
|
114
|
-
countListenersOnLanguage(roomId: string, language: string): Promise<number>;
|
|
115
|
-
/**
|
|
116
|
-
* The client saying its page went into the background, or came back.
|
|
117
|
-
*
|
|
118
|
-
* This is the difference between reacting to a locked screen at once and
|
|
119
|
-
* reacting to it after three missed heartbeats. Both end in the same state;
|
|
120
|
-
* one of them costs ninety seconds of translation and synthesis for a phone
|
|
121
|
-
* nobody is looking at, on every language in the room.
|
|
122
|
-
*
|
|
123
|
-
* Does NOT touch lastHeartbeat. That field is now also the recorded end of the
|
|
124
|
-
* listener's session, so it has to keep meaning "when they were genuinely last
|
|
125
|
-
* there" and nothing else.
|
|
126
|
-
*/
|
|
127
|
-
setAwayState(listenerIdOrToken: string, isAway: boolean): Promise<MulingstreamListenerData | null>;
|
|
128
|
-
/**
|
|
129
|
-
* Present, away or gone, for one listener. For anything that needs to SHOW the
|
|
130
|
-
* distinction rather than act on it.
|
|
131
|
-
*/
|
|
132
|
-
getPresence(listenerIdOrToken: string): Promise<ListenerPresence | null>;
|
|
133
34
|
updateHeartbeat(listenerIdOrToken: string): Promise<MulingstreamListenerData | null>;
|
|
134
35
|
getListenerStats(roomId: string): Promise<{
|
|
135
36
|
totalListeners: number;
|
|
136
37
|
languageBreakdown: Record<string, number>;
|
|
137
38
|
}>;
|
|
138
|
-
/**
|
|
139
|
-
* How many people in this room are ACTUALLY RECEIVING translation right now.
|
|
140
|
-
*
|
|
141
|
-
* Church passes turn on this number rather than on how many people joined. A
|
|
142
|
-
* pass is spent only when translation reached a human being, so somebody
|
|
143
|
-
* sitting on the join screen, or with the tab open and audio stopped, must
|
|
144
|
-
* not burn a church's pass.
|
|
145
|
-
*
|
|
146
|
-
* `isListening` is already maintained by setListeningState, so this is the
|
|
147
|
-
* honest count rather than a proxy for it. Falls back to counting a listener
|
|
148
|
-
* whose flag has never been written, because the flag arrived after some
|
|
149
|
-
* clients shipped and an older client that is genuinely playing audio should
|
|
150
|
-
* still count.
|
|
151
|
-
*/
|
|
152
|
-
getReceivingCount(roomId: string): Promise<number>;
|
|
153
39
|
private capKey;
|
|
154
|
-
private previewKey;
|
|
155
|
-
private previewCountKey;
|
|
156
|
-
/** Open a preview window on one language. Re-pressing simply extends it. */
|
|
157
|
-
openPreview(roomId: string, language: string, windowSeconds: number): Promise<void>;
|
|
158
|
-
/** Seconds left on a language's preview window, or 0 when none is open. */
|
|
159
|
-
getPreviewRemaining(roomId: string, language: string): Promise<number>;
|
|
160
|
-
/**
|
|
161
|
-
* Which of `candidates` currently have a preview open.
|
|
162
|
-
*
|
|
163
|
-
* Takes the candidate list rather than scanning for a pattern on purpose:
|
|
164
|
-
* KEYS and SCAN against a live Redis to answer a question asked on every
|
|
165
|
-
* audio chunk is the kind of thing that is fine until it is not.
|
|
166
|
-
*/
|
|
167
|
-
getPreviewLanguages(roomId: string, candidates: string[]): Promise<string[]>;
|
|
168
|
-
/**
|
|
169
|
-
* Count one press against the room's allowance and say whether it is allowed.
|
|
170
|
-
*
|
|
171
|
-
* The cap exists because a preview translates for real. Without it, holding
|
|
172
|
-
* the button open is a way to run a translation service for nothing, and the
|
|
173
|
-
* obvious abuse is someone using a speaker preview as a free manual
|
|
174
|
-
* translation tool. The counter's own TTL resets the allowance, so a church
|
|
175
|
-
* running a genuine service every week is never permanently locked out.
|
|
176
|
-
*/
|
|
177
|
-
consumePreviewAllowance(roomId: string, maxPresses: number, resetSeconds: number): Promise<{
|
|
178
|
-
allowed: boolean;
|
|
179
|
-
used: number;
|
|
180
|
-
remaining: number;
|
|
181
|
-
}>;
|
|
182
|
-
/** Presses already spent in the current window. Read only, never increments. */
|
|
183
|
-
getPreviewUsage(roomId: string): Promise<number>;
|
|
184
40
|
/**
|
|
185
41
|
* Cache the room's listener cap. Called by the speaker service at go-live and
|
|
186
42
|
* on reconnect; `ttlSeconds` should comfortably exceed a session so the key
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MulingstreamListenerManager =
|
|
4
|
-
const listener_presence_1 = require("../data/listener-presence");
|
|
5
|
-
var listener_presence_2 = require("../data/listener-presence");
|
|
6
|
-
Object.defineProperty(exports, "LISTENER_PRESENT_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_PRESENT_MS; } });
|
|
7
|
-
Object.defineProperty(exports, "LISTENER_GONE_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_GONE_MS; } });
|
|
8
|
-
Object.defineProperty(exports, "LISTENER_HEARTBEAT_INTERVAL_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_HEARTBEAT_INTERVAL_MS; } });
|
|
9
|
-
Object.defineProperty(exports, "listenerPresence", { enumerable: true, get: function () { return listener_presence_2.listenerPresence; } });
|
|
3
|
+
exports.MulingstreamListenerManager = void 0;
|
|
10
4
|
const EXPIRATION = 24 * 60 * 60; // 24 hours in seconds
|
|
11
5
|
/**
|
|
12
6
|
* Generates a bright RGB color suitable for dark backgrounds.
|
|
@@ -54,8 +48,7 @@ class MulingstreamListenerManager {
|
|
|
54
48
|
language: data.language || '',
|
|
55
49
|
color: data.color || '',
|
|
56
50
|
isListening: data.isListening === 'true',
|
|
57
|
-
breakpoint: bp === 'mobile' || bp === 'desktop' || bp === 'display' ? bp : undefined
|
|
58
|
-
isAway: data.isAway === 'true'
|
|
51
|
+
breakpoint: bp === 'mobile' || bp === 'desktop' || bp === 'display' ? bp : undefined
|
|
59
52
|
};
|
|
60
53
|
}
|
|
61
54
|
// Creates a new listener.
|
|
@@ -81,60 +74,25 @@ class MulingstreamListenerManager {
|
|
|
81
74
|
language: (_c = listenerData.language) !== null && _c !== void 0 ? _c : '',
|
|
82
75
|
color,
|
|
83
76
|
isListening: 'false',
|
|
84
|
-
breakpoint: (_d = listenerData.breakpoint) !== null && _d !== void 0 ? _d : ''
|
|
85
|
-
// A listener who just joined is looking at the page by definition.
|
|
86
|
-
isAway: 'false'
|
|
77
|
+
breakpoint: (_d = listenerData.breakpoint) !== null && _d !== void 0 ? _d : ''
|
|
87
78
|
});
|
|
88
79
|
// expire listener
|
|
89
80
|
await this.redisClient.expire(`listener:${listenerId}`, EXPIRATION);
|
|
90
81
|
return listenerId;
|
|
91
82
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Every listener currently known, across every room.
|
|
94
|
-
*
|
|
95
|
-
* WHY THIS USES SCAN AND NOT KEYS
|
|
96
|
-
*
|
|
97
|
-
* It used to be `KEYS 'listener:*'`, with a TODO next to it saying it should
|
|
98
|
-
* not be. KEYS walks the entire keyspace in one go and Redis is single
|
|
99
|
-
* threaded, so for as long as it runs nothing else in the process is served:
|
|
100
|
-
* not a heartbeat, not a chunk being handed to the pipeline, not a listener
|
|
101
|
-
* joining. The cost is invisible on a development database with a few dozen
|
|
102
|
-
* keys and grows with the busiest moment we will ever have, which is precisely
|
|
103
|
-
* when it must not stall. This is called by the cleanup sweep every sixty
|
|
104
|
-
* seconds, for the entire life of the process, so it would have been the most
|
|
105
|
-
* frequently executed blocking command in the system.
|
|
106
|
-
*
|
|
107
|
-
* SCAN answers the same question in cursored batches, yielding between each,
|
|
108
|
-
* so a large keyspace costs more round trips rather than one long stall. The
|
|
109
|
-
* guarantees are weaker, deliberately: a key added or removed mid-scan may or
|
|
110
|
-
* may not appear. That is exactly right for the caller, which is looking for
|
|
111
|
-
* listeners who went silent minutes ago. One arriving during the scan is one
|
|
112
|
-
* for the next sweep, sixty seconds later, and there is nothing to do about a
|
|
113
|
-
* brand new listener anyway.
|
|
114
|
-
*
|
|
115
|
-
* The pattern also has to reject keys that merely start with `listener:`. The
|
|
116
|
-
* legacy `listener:panel-state:*` keys matched the old pattern and were parsed
|
|
117
|
-
* as listener records, producing entries with NaN timestamps. They never
|
|
118
|
-
* caused harm, since NaN fails every comparison, which is exactly how a
|
|
119
|
-
* defect like that survives.
|
|
120
|
-
*/
|
|
121
83
|
async getAllListeners() {
|
|
84
|
+
// get all keys that match 'listener:*'
|
|
85
|
+
// TODO: if we have many keys, consider using SCAN instead of KEYS to avoid performance issues.
|
|
86
|
+
const keys = await this.redisClient.keys('listener:*');
|
|
87
|
+
if (!keys || keys.length === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
// fetch each hash with HGETALL
|
|
122
91
|
const listeners = [];
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
for (const key of keys || []) {
|
|
128
|
-
// `listener:{id}` only. Anything with a further colon belongs to
|
|
129
|
-
// some other feature that happens to share the prefix.
|
|
130
|
-
if (key.split(':').length !== 2)
|
|
131
|
-
continue;
|
|
132
|
-
const data = await this.redisClient.hgetall(key);
|
|
133
|
-
if (!data || Object.keys(data).length === 0)
|
|
134
|
-
continue;
|
|
135
|
-
listeners.push(this.parseHashData(data));
|
|
136
|
-
}
|
|
137
|
-
} while (cursor !== 0);
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
const data = await this.redisClient.hgetall(key);
|
|
94
|
+
listeners.push(this.parseHashData(data));
|
|
95
|
+
}
|
|
138
96
|
return listeners;
|
|
139
97
|
}
|
|
140
98
|
async getListenersByRoom(roomId) {
|
|
@@ -231,23 +189,13 @@ class MulingstreamListenerManager {
|
|
|
231
189
|
});
|
|
232
190
|
return filteredListeners.map((listener) => listener.socketId);
|
|
233
191
|
}
|
|
234
|
-
/**
|
|
235
|
-
* Which languages this room has someone PRESENT on.
|
|
236
|
-
*
|
|
237
|
-
* Feeds consumersFor, which decides what gets translated at all, so an
|
|
238
|
-
* over-generous answer here is money spent on every chunk for nobody. It
|
|
239
|
-
* therefore counts present listeners only: somebody whose phone has been dark
|
|
240
|
-
* for two minutes is receiving nothing, and translating for them is pure loss.
|
|
241
|
-
*
|
|
242
|
-
* It used to count every listener the room had ever registered, including ones
|
|
243
|
-
* whose heartbeat had been silent for fourteen minutes, because the only thing
|
|
244
|
-
* that removed a listener was the cleanup sweep. See data/listener-presence.ts
|
|
245
|
-
* for why that single threshold could not be lowered and had to be split.
|
|
246
|
-
*/
|
|
247
192
|
async getUniqueLanguagesByRoom(roomId) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
193
|
+
// 1) Fetch all listeners for the room.
|
|
194
|
+
// (This includes inactive ones, but feel free to filter out isActive === false if you only want active ones.)
|
|
195
|
+
const listeners = await this.getListenersByRoom(roomId);
|
|
196
|
+
// if I wanted to get only active listeners
|
|
197
|
+
// const listeners = (await this.getListenersByRoom(roomId)).filter(l => l.isActive);
|
|
198
|
+
// 2) Count how many times each language appears.
|
|
251
199
|
const languageCountMap = {};
|
|
252
200
|
for (const listener of listeners) {
|
|
253
201
|
// skip blank/unset language
|
|
@@ -263,99 +211,6 @@ class MulingstreamListenerManager {
|
|
|
263
211
|
// 4) Map back to just the language strings in order
|
|
264
212
|
return sortedEntries.map(([language]) => language);
|
|
265
213
|
}
|
|
266
|
-
/*
|
|
267
|
-
* GUEST ADDED LANGUAGES
|
|
268
|
-
*
|
|
269
|
-
* Which of a room's target languages a guest put there, as opposed to the
|
|
270
|
-
* host. The distinction is the whole reason this exists: a guest-added
|
|
271
|
-
* language is released the moment nobody is on it, and the host's own
|
|
272
|
-
* language never is, however empty the room gets. Without the distinction a
|
|
273
|
-
* church that pinned Danish and had every guest wander off would find its room
|
|
274
|
-
* translating nothing.
|
|
275
|
-
*
|
|
276
|
-
* It also stops a single guest eating the room. Changing language seven times
|
|
277
|
-
* would otherwise leave seven languages standing, every one of them translated
|
|
278
|
-
* and synthesised for the rest of the service on a counted pass, and no slot
|
|
279
|
-
* left for the person who actually needed one.
|
|
280
|
-
*
|
|
281
|
-
* A set rather than a column: it is per-session bookkeeping, not a property of
|
|
282
|
-
* the room, and it should disappear on its own if a process dies mid-service.
|
|
283
|
-
* The TTL is the listener expiry for exactly that reason.
|
|
284
|
-
*/
|
|
285
|
-
guestLanguagesKey(roomId) {
|
|
286
|
-
return `room:${roomId}:guest-languages`;
|
|
287
|
-
}
|
|
288
|
-
/** Record that a guest, not the host, put this language in the room. */
|
|
289
|
-
async markGuestLanguage(roomId, language) {
|
|
290
|
-
if (!roomId || !language)
|
|
291
|
-
return;
|
|
292
|
-
const key = this.guestLanguagesKey(roomId);
|
|
293
|
-
await this.redisClient.sadd(key, language);
|
|
294
|
-
await this.redisClient.expire(key, EXPIRATION);
|
|
295
|
-
}
|
|
296
|
-
/** Forget a guest language, once it has been released from the room. */
|
|
297
|
-
async unmarkGuestLanguage(roomId, language) {
|
|
298
|
-
if (!roomId || !language)
|
|
299
|
-
return;
|
|
300
|
-
await this.redisClient.srem(this.guestLanguagesKey(roomId), language);
|
|
301
|
-
}
|
|
302
|
-
/** Every language in this room that a guest added. */
|
|
303
|
-
async getGuestLanguages(roomId) {
|
|
304
|
-
if (!roomId)
|
|
305
|
-
return [];
|
|
306
|
-
return (await this.redisClient.smembers(this.guestLanguagesKey(roomId))) || [];
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* How many PRESENT listeners in this room are on a language.
|
|
310
|
-
*
|
|
311
|
-
* Decides whether a guest-added language slot can be handed back, and counts
|
|
312
|
-
* the same population `consumersFor` reads, so the two cannot disagree about
|
|
313
|
-
* whether a language has an audience: a slot is released exactly when the
|
|
314
|
-
* translation for it stops, never one without the other.
|
|
315
|
-
*
|
|
316
|
-
* Present rather than merely registered, and that is the deliberate part. A
|
|
317
|
-
* listener whose phone has been dark for two minutes is not hearing the
|
|
318
|
-
* language, so holding the room's last slot for them denies it to a guest
|
|
319
|
-
* standing in the room who is. If they come back and the slot has gone to
|
|
320
|
-
* somebody else, they choose again from what the room now has; if it is still
|
|
321
|
-
* free they simply take it back.
|
|
322
|
-
*/
|
|
323
|
-
async countListenersOnLanguage(roomId, language) {
|
|
324
|
-
if (!roomId || !language)
|
|
325
|
-
return 0;
|
|
326
|
-
const now = Date.now();
|
|
327
|
-
const listeners = await this.getListenersByRoom(roomId);
|
|
328
|
-
return listeners.filter((listener) => listener.language === language && (0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now)).length;
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* The client saying its page went into the background, or came back.
|
|
332
|
-
*
|
|
333
|
-
* This is the difference between reacting to a locked screen at once and
|
|
334
|
-
* reacting to it after three missed heartbeats. Both end in the same state;
|
|
335
|
-
* one of them costs ninety seconds of translation and synthesis for a phone
|
|
336
|
-
* nobody is looking at, on every language in the room.
|
|
337
|
-
*
|
|
338
|
-
* Does NOT touch lastHeartbeat. That field is now also the recorded end of the
|
|
339
|
-
* listener's session, so it has to keep meaning "when they were genuinely last
|
|
340
|
-
* there" and nothing else.
|
|
341
|
-
*/
|
|
342
|
-
async setAwayState(listenerIdOrToken, isAway) {
|
|
343
|
-
const listener = await this.getListener(listenerIdOrToken);
|
|
344
|
-
if (!listener)
|
|
345
|
-
return null;
|
|
346
|
-
await this.redisClient.hset(`listener:${listener.listenerId}`, { isAway: isAway.toString() });
|
|
347
|
-
return { ...listener, isAway };
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* Present, away or gone, for one listener. For anything that needs to SHOW the
|
|
351
|
-
* distinction rather than act on it.
|
|
352
|
-
*/
|
|
353
|
-
async getPresence(listenerIdOrToken) {
|
|
354
|
-
const listener = await this.getListener(listenerIdOrToken);
|
|
355
|
-
if (!listener)
|
|
356
|
-
return null;
|
|
357
|
-
return (0, listener_presence_1.listenerPresence)(listener.lastHeartbeat, listener.isAway === true);
|
|
358
|
-
}
|
|
359
214
|
async updateHeartbeat(listenerIdOrToken) {
|
|
360
215
|
const listener = await this.getListener(listenerIdOrToken);
|
|
361
216
|
if (!listener) {
|
|
@@ -363,16 +218,9 @@ class MulingstreamListenerManager {
|
|
|
363
218
|
return null;
|
|
364
219
|
}
|
|
365
220
|
const now = Date.now();
|
|
366
|
-
// Update the heartbeat timestamp
|
|
367
|
-
//
|
|
368
|
-
// A heartbeat only arrives from a page that is running, so receiving one is
|
|
369
|
-
// proof the listener is back: an explicit "I have returned" event would be
|
|
370
|
-
// a second way of saying the same thing, and a second way to get it wrong.
|
|
371
|
-
// A returning page sends a heartbeat immediately on visibility, so this is
|
|
372
|
-
// as fast as anything else could be.
|
|
221
|
+
// Update the heartbeat timestamp
|
|
373
222
|
await this.redisClient.hset(`listener:${listener.listenerId}`, {
|
|
374
|
-
lastHeartbeat: now.toString()
|
|
375
|
-
isAway: 'false'
|
|
223
|
+
lastHeartbeat: now.toString()
|
|
376
224
|
});
|
|
377
225
|
// Reset expiration on activity
|
|
378
226
|
await this.redisClient.expire(`listener:${listener.listenerId}`, EXPIRATION);
|
|
@@ -391,31 +239,6 @@ class MulingstreamListenerManager {
|
|
|
391
239
|
languageBreakdown
|
|
392
240
|
};
|
|
393
241
|
}
|
|
394
|
-
/**
|
|
395
|
-
* How many people in this room are ACTUALLY RECEIVING translation right now.
|
|
396
|
-
*
|
|
397
|
-
* Church passes turn on this number rather than on how many people joined. A
|
|
398
|
-
* pass is spent only when translation reached a human being, so somebody
|
|
399
|
-
* sitting on the join screen, or with the tab open and audio stopped, must
|
|
400
|
-
* not burn a church's pass.
|
|
401
|
-
*
|
|
402
|
-
* `isListening` is already maintained by setListeningState, so this is the
|
|
403
|
-
* honest count rather than a proxy for it. Falls back to counting a listener
|
|
404
|
-
* whose flag has never been written, because the flag arrived after some
|
|
405
|
-
* clients shipped and an older client that is genuinely playing audio should
|
|
406
|
-
* still count.
|
|
407
|
-
*/
|
|
408
|
-
async getReceivingCount(roomId) {
|
|
409
|
-
const now = Date.now();
|
|
410
|
-
const listeners = await this.getListenersByRoom(roomId);
|
|
411
|
-
return listeners.filter(
|
|
412
|
-
// Present AND playing audio. Presence is the half that was missing:
|
|
413
|
-
// a listener who closed their phone kept the isListening flag they had
|
|
414
|
-
// when they left, so they went on being counted as receiving until the
|
|
415
|
-
// sweep removed them, and went on pushing a church's pass toward being
|
|
416
|
-
// spent. Nobody ever pauses audio on the way out of a building.
|
|
417
|
-
(l) => (0, listener_presence_1.isListenerPresent)(l.lastHeartbeat, l.isAway === true, now) && l.isListening !== false).length;
|
|
418
|
-
}
|
|
419
242
|
// ─── Per-room listener capacity (design B, 2026-07-09) ────────────────────
|
|
420
243
|
// The cap value is the room owner's plan `max_audience`, resolved ONCE by the
|
|
421
244
|
// speaker service when it goes live (it already fetches the plan entitlements)
|
|
@@ -427,82 +250,6 @@ class MulingstreamListenerManager {
|
|
|
427
250
|
capKey(roomId) {
|
|
428
251
|
return `room:${roomId}:listener-cap`;
|
|
429
252
|
}
|
|
430
|
-
/*
|
|
431
|
-
* SPEAKER PREVIEW
|
|
432
|
-
*
|
|
433
|
-
* A speaker looking at their own translation feed is a consumer of
|
|
434
|
-
* translation but must never be a payer for it: nobody should be able to
|
|
435
|
-
* spend a church's pass, or their own credits, by watching their own screen.
|
|
436
|
-
* So a preview is a short window they open deliberately, not a state they
|
|
437
|
-
* fall into by leaving a tab open.
|
|
438
|
-
*
|
|
439
|
-
* In Redis rather than in the pipeline's memory because the request arrives
|
|
440
|
-
* at the room service and the decision is read by the pipeline service, and
|
|
441
|
-
* because a pipeline replica restarting mid window must not silently start
|
|
442
|
-
* translating for nobody. The TTL IS the expiry: nothing has to remember to
|
|
443
|
-
* close it, and a crash fails in the safe direction.
|
|
444
|
-
*/
|
|
445
|
-
previewKey(roomId, language) {
|
|
446
|
-
return `room:${roomId}:preview:${language}`;
|
|
447
|
-
}
|
|
448
|
-
previewCountKey(roomId) {
|
|
449
|
-
return `room:${roomId}:preview-count`;
|
|
450
|
-
}
|
|
451
|
-
/** Open a preview window on one language. Re-pressing simply extends it. */
|
|
452
|
-
async openPreview(roomId, language, windowSeconds) {
|
|
453
|
-
if (!roomId || !language || windowSeconds <= 0)
|
|
454
|
-
return;
|
|
455
|
-
const key = this.previewKey(roomId, language);
|
|
456
|
-
await this.redisClient.set(key, '1');
|
|
457
|
-
await this.redisClient.expire(key, Math.floor(windowSeconds));
|
|
458
|
-
}
|
|
459
|
-
/** Seconds left on a language's preview window, or 0 when none is open. */
|
|
460
|
-
async getPreviewRemaining(roomId, language) {
|
|
461
|
-
const ttl = await this.redisClient.ttl(this.previewKey(roomId, language));
|
|
462
|
-
return ttl > 0 ? ttl : 0;
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* Which of `candidates` currently have a preview open.
|
|
466
|
-
*
|
|
467
|
-
* Takes the candidate list rather than scanning for a pattern on purpose:
|
|
468
|
-
* KEYS and SCAN against a live Redis to answer a question asked on every
|
|
469
|
-
* audio chunk is the kind of thing that is fine until it is not.
|
|
470
|
-
*/
|
|
471
|
-
async getPreviewLanguages(roomId, candidates) {
|
|
472
|
-
if (!roomId || candidates.length === 0)
|
|
473
|
-
return [];
|
|
474
|
-
const open = [];
|
|
475
|
-
for (const language of candidates) {
|
|
476
|
-
if (await this.redisClient.get(this.previewKey(roomId, language)))
|
|
477
|
-
open.push(language);
|
|
478
|
-
}
|
|
479
|
-
return open;
|
|
480
|
-
}
|
|
481
|
-
/**
|
|
482
|
-
* Count one press against the room's allowance and say whether it is allowed.
|
|
483
|
-
*
|
|
484
|
-
* The cap exists because a preview translates for real. Without it, holding
|
|
485
|
-
* the button open is a way to run a translation service for nothing, and the
|
|
486
|
-
* obvious abuse is someone using a speaker preview as a free manual
|
|
487
|
-
* translation tool. The counter's own TTL resets the allowance, so a church
|
|
488
|
-
* running a genuine service every week is never permanently locked out.
|
|
489
|
-
*/
|
|
490
|
-
async consumePreviewAllowance(roomId, maxPresses, resetSeconds) {
|
|
491
|
-
const key = this.previewCountKey(roomId);
|
|
492
|
-
const used = await this.redisClient.incr(key);
|
|
493
|
-
// Only the first press starts the clock, so the window is a fixed period
|
|
494
|
-
// from first use rather than one that slides forward on every press.
|
|
495
|
-
if (used === 1)
|
|
496
|
-
await this.redisClient.expire(key, Math.floor(resetSeconds));
|
|
497
|
-
const allowed = used <= maxPresses;
|
|
498
|
-
return { allowed, used, remaining: Math.max(0, maxPresses - used) };
|
|
499
|
-
}
|
|
500
|
-
/** Presses already spent in the current window. Read only, never increments. */
|
|
501
|
-
async getPreviewUsage(roomId) {
|
|
502
|
-
const raw = await this.redisClient.get(this.previewCountKey(roomId));
|
|
503
|
-
const used = raw ? parseInt(raw, 10) : 0;
|
|
504
|
-
return Number.isFinite(used) && used > 0 ? used : 0;
|
|
505
|
-
}
|
|
506
253
|
/**
|
|
507
254
|
* Cache the room's listener cap. Called by the speaker service at go-live and
|
|
508
255
|
* on reconnect; `ttlSeconds` should comfortably exceed a session so the key
|
|
@@ -583,7 +330,6 @@ class MulingstreamListenerManager {
|
|
|
583
330
|
* Used by pipeline to determine which languages need TTS generation.
|
|
584
331
|
*/
|
|
585
332
|
async getLanguagesWithActiveListeners(roomId) {
|
|
586
|
-
const now = Date.now();
|
|
587
333
|
const listeners = await this.getListenersByRoom(roomId);
|
|
588
334
|
const languageCountMap = {};
|
|
589
335
|
for (const listener of listeners) {
|
|
@@ -591,10 +337,6 @@ class MulingstreamListenerManager {
|
|
|
591
337
|
continue;
|
|
592
338
|
if (!listener.isListening)
|
|
593
339
|
continue;
|
|
594
|
-
// Speech synthesis is the most expensive thing we do. A language whose
|
|
595
|
-
// only listener has a dark phone must not be synthesised.
|
|
596
|
-
if (!(0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now))
|
|
597
|
-
continue;
|
|
598
340
|
const lang = listener.language;
|
|
599
341
|
languageCountMap[lang] = (languageCountMap[lang] || 0) + 1;
|
|
600
342
|
}
|
package/package.json
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mulingai-npm/redis",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"main": "dist/index.js",
|
|
5
|
-
"types": "dist/index.d.ts",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "https://github.com/mulingai/mulingai-backend.git"
|
|
9
|
-
},
|
|
10
|
-
"publishConfig": {
|
|
11
|
-
"registry": "https://registry.npmjs.org/"
|
|
12
|
-
},
|
|
13
|
-
"private": false,
|
|
14
|
-
"scripts": {
|
|
15
|
-
"dev": "rm -f tsconfig.tsbuildinfo && tsc --watch",
|
|
16
|
-
"build": "rm -f tsconfig.tsbuildinfo && tsc",
|
|
17
|
-
"prepublishOnly": "npm run build"
|
|
18
|
-
},
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"ioredis": "^5.6.0",
|
|
21
|
-
"uuid": "^11.1.0"
|
|
22
|
-
},
|
|
23
|
-
"devDependencies": {
|
|
24
|
-
"concurrently": "^9.1.2",
|
|
25
|
-
"copyfiles": "^2.4.1",
|
|
26
|
-
"nodemon": "^3.1.9",
|
|
27
|
-
"typescript": "^4.9.5"
|
|
28
|
-
},
|
|
29
|
-
"files": [
|
|
30
|
-
"dist",
|
|
31
|
-
"package.json",
|
|
32
|
-
"README.md"
|
|
33
|
-
]
|
|
34
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mulingai-npm/redis",
|
|
3
|
+
"version": "3.41.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/mulingai/mulingai-backend.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://registry.npmjs.org/"
|
|
12
|
+
},
|
|
13
|
+
"private": false,
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "rm -f tsconfig.tsbuildinfo && tsc --watch",
|
|
16
|
+
"build": "rm -f tsconfig.tsbuildinfo && tsc",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ioredis": "^5.6.0",
|
|
21
|
+
"uuid": "^11.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"concurrently": "^9.1.2",
|
|
25
|
+
"copyfiles": "^2.4.1",
|
|
26
|
+
"nodemon": "^3.1.9",
|
|
27
|
+
"typescript": "^4.9.5"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"package.json",
|
|
32
|
+
"README.md"
|
|
33
|
+
]
|
|
34
|
+
}
|