mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
569146c5bc | ||
|
|
1b1d48b05e | ||
|
|
cadca2c18e | ||
|
|
2e3f78b3c6 |
@@ -13891,6 +13891,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -13928,6 +13936,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14227,6 +14236,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14264,6 +14281,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14299,6 +14317,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null for no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14356,6 +14382,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14393,6 +14427,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14428,6 +14463,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null for no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct VoiceServer {
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub is_active: Option<bool>,
|
||||
pub soft_connection_limit: Option<i64>,
|
||||
pub vip_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub required_guild_features: Vec<String>,
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
api::client::AdminApiClient,
|
||||
middleware::{auth::AuthContext, csrf},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, pages::voice_servers::VoiceServersPageParams},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -13,12 +13,34 @@ use axum::{
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VoiceServersQuery {
|
||||
region_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
|
||||
let response = match client.get_gateway_voice_state_counts().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: load voice state counts");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
servers
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let server_id = entry.get("server_id")?.as_str()?.to_owned();
|
||||
let count = entry.get("voice_state_count")?.as_i64()?;
|
||||
Some((server_id, count))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -79,17 +101,21 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: None,
|
||||
region_name: None,
|
||||
servers: None,
|
||||
connection_counts: &HashMap::new(),
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
return Html(markup.into_string()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let connection_counts = load_server_connection_counts(&client).await;
|
||||
|
||||
let region_name = match client.get_voice_region(region_id, false).await {
|
||||
Ok(resp) => resp
|
||||
@@ -107,11 +133,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
Some(&resp.servers),
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: Some(&resp.servers),
|
||||
connection_counts: &connection_counts,
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -120,11 +149,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
None,
|
||||
Some(&msg),
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: None,
|
||||
connection_counts: &connection_counts,
|
||||
error: Some(&msg),
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -92,6 +92,16 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
body.insert("longitude".into(), lng.into());
|
||||
}
|
||||
body.insert("is_active".into(), form.bool_value("is_active").into());
|
||||
if let Some(raw) = form.first("soft_connection_limit") {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
|
||||
} else if let Ok(limit) = trimmed.parse::<i64>()
|
||||
&& limit > 0
|
||||
{
|
||||
body.insert("soft_connection_limit".into(), limit.into());
|
||||
}
|
||||
}
|
||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
@@ -255,6 +265,47 @@ mod tests {
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
|
||||
let form =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
|
||||
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
|
||||
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
|
||||
assert!(
|
||||
!build_server_body(&absent)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let invalid =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
|
||||
assert!(
|
||||
!build_server_body(&invalid)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
|
||||
assert!(
|
||||
!build_server_body(&zero)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_preserves_single_and_repeated_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
|
||||
@@ -18,19 +18,33 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::voice_servers_forms::{create_server_form, edit_server_form};
|
||||
|
||||
pub struct VoiceServersPageParams<'a> {
|
||||
pub region_id: Option<&'a str>,
|
||||
pub region_name: Option<&'a str>,
|
||||
pub servers: Option<&'a [VoiceServer]>,
|
||||
pub connection_counts: &'a HashMap<String, i64>,
|
||||
pub error: Option<&'a str>,
|
||||
pub csrf_token: &'a str,
|
||||
}
|
||||
|
||||
pub fn voice_servers_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
region_id: Option<&str>,
|
||||
region_name: Option<&str>,
|
||||
servers: Option<&[VoiceServer]>,
|
||||
error: Option<&str>,
|
||||
csrf_token: &str,
|
||||
p: &VoiceServersPageParams<'_>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let VoiceServersPageParams {
|
||||
region_id,
|
||||
region_name,
|
||||
servers,
|
||||
connection_counts,
|
||||
error,
|
||||
csrf_token,
|
||||
} = *p;
|
||||
let options = LayoutOptions {
|
||||
csrf_token,
|
||||
inspected_voice_region_id: region_id,
|
||||
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
|
||||
html! {},
|
||||
))
|
||||
@if let Some(servers) = servers {
|
||||
(servers_list(config, rid, servers, csrf_token))
|
||||
(servers_list(config, rid, servers, connection_counts, csrf_token))
|
||||
}
|
||||
div id="create" class="mt-8" {
|
||||
(create_server_form(config, rid, csrf_token))
|
||||
@@ -109,6 +123,7 @@ fn servers_list(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
servers: &[VoiceServer],
|
||||
connection_counts: &HashMap<String, i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
if servers.is_empty() {
|
||||
@@ -121,7 +136,7 @@ fn servers_list(
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
@for server in servers {
|
||||
(server_card(config, region_id, server, csrf_token))
|
||||
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +146,7 @@ fn server_card(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
server: &VoiceServer,
|
||||
connection_count: Option<i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
@@ -145,6 +161,15 @@ fn server_card(
|
||||
let lng_str = server
|
||||
.longitude
|
||||
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
|
||||
let soft_limit_str = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
|
||||
let connections_str =
|
||||
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
|
||||
let at_soft_limit = matches!(
|
||||
(server.soft_connection_limit, connection_count),
|
||||
(Some(limit), Some(count)) if limit > 0 && count >= limit
|
||||
);
|
||||
|
||||
card(html! {
|
||||
div class="mb-4 flex flex-col gap-1" {
|
||||
@@ -155,6 +180,9 @@ fn server_card(
|
||||
} @else {
|
||||
(badge("INACTIVE", BadgeVariant::Default))
|
||||
}
|
||||
@if at_soft_limit {
|
||||
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
|
||||
}
|
||||
(voice_status_badges(vip_only, has_features, has_guild_ids))
|
||||
}
|
||||
p class="text-sm text-neutral-500" { (endpoint) }
|
||||
@@ -164,6 +192,8 @@ fn server_card(
|
||||
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
|
||||
(data_field_text("Latitude", &lat_str))
|
||||
(data_field_text("Longitude", &lng_str))
|
||||
(data_field_text("Soft connection limit", &soft_limit_str))
|
||||
(data_field_text("Live connections", &connections_str))
|
||||
}
|
||||
(voice_features_list(&server.required_guild_features))
|
||||
(voice_guild_ids_list(&server.allowed_guild_ids))
|
||||
|
||||
@@ -26,6 +26,9 @@ pub fn edit_server_form(
|
||||
let lat_val = server.latitude.map_or_else(String::new, |v| v.to_string());
|
||||
let lng_val = server.longitude.map_or_else(String::new, |v| v.to_string());
|
||||
let is_active = server.is_active.unwrap_or(false);
|
||||
let soft_limit_val = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(String::new, |v| v.to_string());
|
||||
let vip_only = server.vip_only.unwrap_or(false);
|
||||
let features_csv = server.required_guild_features.join(", ");
|
||||
let guild_ids_csv = server.allowed_guild_ids.join(", ");
|
||||
@@ -57,6 +60,15 @@ pub fn edit_server_form(
|
||||
"Optional per-server coordinate override",
|
||||
))
|
||||
}
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit",
|
||||
&format!("{id_prefix}-soft-connection-limit"),
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
&soft_limit_val,
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
(form_field_with_helper(
|
||||
"API Key",
|
||||
&format!("{id_prefix}-api-key"),
|
||||
@@ -105,6 +117,15 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
|
||||
(form_field_with_id("API Secret", "new-server-api-secret", "api_secret", "password", "", "LiveKit API secret", true))
|
||||
(form_field_with_id("Latitude (optional)", "new-server-latitude", "latitude", "number", "", "40.7128", false))
|
||||
(form_field_with_id("Longitude (optional)", "new-server-longitude", "longitude", "number", "", "-74.0060", false))
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit (optional)",
|
||||
"new-server-soft-connection-limit",
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
"",
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
}
|
||||
div class="space-y-3" {
|
||||
(checkbox("is_active", "true", "Server is active", true, true))
|
||||
|
||||
@@ -235,6 +235,7 @@ export class AdminVoiceService {
|
||||
serverId: data.server_id,
|
||||
endpoint: data.endpoint,
|
||||
isActive: data.is_active ?? true,
|
||||
softConnectionLimit: data.soft_connection_limit ?? null,
|
||||
apiKey: data.api_key ?? null,
|
||||
apiSecret: data.api_secret ?? null,
|
||||
latitude: data.latitude ?? null,
|
||||
@@ -271,6 +272,7 @@ export class AdminVoiceService {
|
||||
if (data.latitude !== undefined) updates.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updates.longitude = data.longitude;
|
||||
if (data.is_active !== undefined) updates.isActive = data.is_active;
|
||||
if (data.soft_connection_limit !== undefined) updates.softConnectionLimit = data.soft_connection_limit;
|
||||
updates.restrictions = patchVoiceRestrictions(existing.restrictions, data);
|
||||
updates.updatedAt = new Date();
|
||||
await voiceRepository.upsertServer(updates);
|
||||
@@ -339,6 +341,7 @@ export class AdminVoiceService {
|
||||
latitude: server.latitude ?? null,
|
||||
longitude: server.longitude ?? null,
|
||||
is_active: server.isActive,
|
||||
soft_connection_limit: server.softConnectionLimit ?? null,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: allowedGuildIds,
|
||||
|
||||
@@ -266,4 +266,77 @@ describe('VoiceAdminController', () => {
|
||||
expect(persisted?.apiKey).toBe(fixture.initialApiKey);
|
||||
expect(persisted?.apiSecret).toBe(fixture.initialApiSecret);
|
||||
});
|
||||
test('stores, keeps, and clears a voice server soft connection limit', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_LIST,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-soft-limit';
|
||||
const serverId = 'voice-server-soft-limit';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const created = await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-soft-limit.example.com/socket',
|
||||
api_key: 'soft-limit-api-key',
|
||||
api_secret: 'soft-limit-api-secret',
|
||||
soft_connection_limit: 250,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(created.server.soft_connection_limit).toBe(250);
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({endpoint: 'https://voice-soft-limit-2.example.com/socket'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({soft_connection_limit: null})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared.server.soft_connection_limit).toBeNull();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBeNull();
|
||||
});
|
||||
test('rejects a voice server soft connection limit below one', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_REGION_CREATE, AdminACLs.VOICE_SERVER_CREATE]);
|
||||
const regionId = 'voice-region-soft-limit-invalid';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: 'voice-server-soft-limit-invalid',
|
||||
endpoint: 'https://voice-soft-limit-invalid.example.com/socket',
|
||||
api_key: 'soft-limit-invalid-api-key',
|
||||
api_secret: 'soft-limit-invalid-api-secret',
|
||||
soft_connection_limit: 0,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface VoiceServerRow {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
is_active: boolean | null;
|
||||
soft_connection_limit: number | null;
|
||||
vip_only: boolean | null;
|
||||
required_guild_features: Set<string> | null;
|
||||
allowed_guild_ids: Set<bigint> | null;
|
||||
@@ -56,6 +57,7 @@ export const VOICE_SERVER_COLUMNS = [
|
||||
'latitude',
|
||||
'longitude',
|
||||
'is_active',
|
||||
'soft_connection_limit',
|
||||
'vip_only',
|
||||
'required_guild_features',
|
||||
'allowed_guild_ids',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {setInjectedSearchProvider} from '../SearchFactory';
|
||||
import type {ISearchProvider} from '../search/ISearchProvider';
|
||||
import {VoiceAvailabilityService} from '../voice/VoiceAvailabilityService';
|
||||
import {VoiceRepository} from '../voice/VoiceRepository';
|
||||
import {VoiceServerLoadTracker} from '../voice/VoiceServerLoad';
|
||||
import {VoiceTopology} from '../voice/VoiceTopology';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
|
||||
@@ -289,7 +290,10 @@ export async function ensureVoiceResourcesInitialized(): Promise<void> {
|
||||
const topology = new VoiceTopology(voiceRepository, voiceConfigSubscriber);
|
||||
await topology.initialize();
|
||||
voiceTopology = topology;
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(topology);
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(
|
||||
topology,
|
||||
new VoiceServerLoadTracker({gatewayService: getGatewayService()}),
|
||||
);
|
||||
liveKitServiceInstance = new LiveKitService(topology);
|
||||
voiceRoomStoreInstance = new VoiceRoomStore(getKVClient());
|
||||
})().finally(() => {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildID, UserID} from '../BrandedTypes';
|
||||
import type {VoiceRegionAvailability, VoiceRegionMetadata, VoiceRegionRecord, VoiceServerRecord} from './VoiceModel';
|
||||
import {preferServersUnderSoftLimit} from './VoiceRegionSelection';
|
||||
import type {VoiceServerLoadSource} from './VoiceServerLoad';
|
||||
import type {VoiceTopology} from './VoiceTopology';
|
||||
|
||||
export interface VoiceAccessContext {
|
||||
@@ -11,10 +13,19 @@ export interface VoiceAccessContext {
|
||||
guildFeatures?: Set<string>;
|
||||
}
|
||||
|
||||
const EMPTY_CONNECTION_COUNTS: ReadonlyMap<string, number> = new Map();
|
||||
|
||||
export class VoiceAvailabilityService {
|
||||
private rotationIndex: Map<string, number> = new Map();
|
||||
|
||||
constructor(private topology: VoiceTopology) {}
|
||||
constructor(
|
||||
private topology: VoiceTopology,
|
||||
private loadSource: VoiceServerLoadSource | null = null,
|
||||
) {}
|
||||
|
||||
getServerConnectionCounts(): ReadonlyMap<string, number> {
|
||||
return this.loadSource?.getConnectionCounts() ?? EMPTY_CONNECTION_COUNTS;
|
||||
}
|
||||
|
||||
getRegionMetadata(): Array<VoiceRegionMetadata> {
|
||||
return this.topology.getRegionMetadataList();
|
||||
@@ -140,9 +151,10 @@ export class VoiceAvailabilityService {
|
||||
if (accessibleServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidateServers = preferServersUnderSoftLimit(accessibleServers, this.getServerConnectionCounts());
|
||||
const index = this.rotationIndex.get(regionId) ?? 0;
|
||||
const server = accessibleServers[index % accessibleServers.length];
|
||||
this.rotationIndex.set(regionId, (index + 1) % accessibleServers.length);
|
||||
const server = candidateServers[index % candidateServers.length];
|
||||
this.rotationIndex.set(regionId, (index + 1) % candidateServers.length);
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export class VoiceDataInitializer {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
isActive: true,
|
||||
softConnectionLimit: null,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface VoiceServerRecord {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
isActive: boolean;
|
||||
softConnectionLimit: number | null;
|
||||
restrictions: VoiceRestriction;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
|
||||
@@ -77,12 +77,14 @@ export function selectVoiceRegionId({
|
||||
export function selectClosestPseudoRegionServer({
|
||||
mode,
|
||||
accessibleServers,
|
||||
connectionCounts,
|
||||
latitude,
|
||||
longitude,
|
||||
selectionKey,
|
||||
}: {
|
||||
mode: VoiceRegionPreference['mode'];
|
||||
accessibleServers: Array<VoiceServerRecord>;
|
||||
connectionCounts: ReadonlyMap<string, number>;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
selectionKey: string;
|
||||
@@ -95,10 +97,31 @@ export function selectClosestPseudoRegionServer({
|
||||
if (userLat === null || userLon === null) {
|
||||
return null;
|
||||
}
|
||||
const closestServers = findClosestServers(accessibleServers, userLat, userLon);
|
||||
const preferredServers = preferServersUnderSoftLimit(accessibleServers, connectionCounts);
|
||||
const closestServers = findClosestServers(preferredServers, userLat, userLon);
|
||||
return selectBalancedServer(closestServers, selectionKey);
|
||||
}
|
||||
|
||||
export function preferServersUnderSoftLimit(
|
||||
servers: Array<VoiceServerRecord>,
|
||||
connectionCounts: ReadonlyMap<string, number>,
|
||||
): Array<VoiceServerRecord> {
|
||||
const serversUnderLimit = servers.filter((server) => !isServerAtSoftLimit(server, connectionCounts));
|
||||
return serversUnderLimit.length > 0 ? serversUnderLimit : servers;
|
||||
}
|
||||
|
||||
function isServerAtSoftLimit(server: VoiceServerRecord, connectionCounts: ReadonlyMap<string, number>): boolean {
|
||||
const limit = server.softConnectionLimit;
|
||||
if (limit === null || limit <= 0) {
|
||||
return false;
|
||||
}
|
||||
const connectionCount = connectionCounts.get(server.serverId);
|
||||
if (connectionCount === undefined) {
|
||||
return false;
|
||||
}
|
||||
return connectionCount >= limit;
|
||||
}
|
||||
|
||||
function findClosestRegionIds(
|
||||
latitude: string | undefined,
|
||||
longitude: string | undefined,
|
||||
|
||||
@@ -147,6 +147,7 @@ export class VoiceRepository implements IVoiceRepository {
|
||||
latitude: server.latitude ?? null,
|
||||
longitude: server.longitude ?? null,
|
||||
is_active: server.isActive,
|
||||
soft_connection_limit: server.softConnectionLimit ?? null,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: new Set(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: new Set(Array.from(server.restrictions.allowedGuildIds).map((id) => BigInt(id))),
|
||||
@@ -190,6 +191,7 @@ export class VoiceRepository implements IVoiceRepository {
|
||||
latitude: row.latitude ?? null,
|
||||
longitude: row.longitude ?? null,
|
||||
isActive: row.is_active ?? true,
|
||||
softConnectionLimit: row.soft_connection_limit ?? null,
|
||||
restrictions: {
|
||||
vipOnly: row.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(toIterable<string>(row.required_guild_features)),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IGatewayService} from '../infrastructure/IGatewayService';
|
||||
import {Logger} from '../Logger';
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_MS = 15000;
|
||||
const STALE_REFRESH_INTERVALS = 4;
|
||||
const EMPTY_CONNECTION_COUNTS: ReadonlyMap<string, number> = new Map();
|
||||
|
||||
export interface VoiceServerLoadSource {
|
||||
getConnectionCounts(): ReadonlyMap<string, number>;
|
||||
}
|
||||
|
||||
export class VoiceServerLoadTracker implements VoiceServerLoadSource {
|
||||
private readonly gatewayService: IGatewayService;
|
||||
private readonly refreshIntervalMs: number;
|
||||
private readonly staleAfterMs: number;
|
||||
private readonly now: () => number;
|
||||
private connectionCounts: ReadonlyMap<string, number> = EMPTY_CONNECTION_COUNTS;
|
||||
private lastAttemptAt = 0;
|
||||
private lastSuccessAt = 0;
|
||||
private refreshing: Promise<void> | null = null;
|
||||
|
||||
constructor(options: {
|
||||
gatewayService: IGatewayService;
|
||||
refreshIntervalMs?: number;
|
||||
now?: () => number;
|
||||
}) {
|
||||
this.gatewayService = options.gatewayService;
|
||||
this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
|
||||
this.staleAfterMs = this.refreshIntervalMs * STALE_REFRESH_INTERVALS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
getConnectionCounts(): ReadonlyMap<string, number> {
|
||||
const now = this.now();
|
||||
if (now - this.lastAttemptAt >= this.refreshIntervalMs) {
|
||||
void this.refresh();
|
||||
}
|
||||
if (this.lastSuccessAt === 0 || now - this.lastSuccessAt > this.staleAfterMs) {
|
||||
return EMPTY_CONNECTION_COUNTS;
|
||||
}
|
||||
return this.connectionCounts;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.refreshing) {
|
||||
return this.refreshing;
|
||||
}
|
||||
this.lastAttemptAt = this.now();
|
||||
this.refreshing = this.gatewayService
|
||||
.getVoiceStateCounts()
|
||||
.then((counts) => {
|
||||
const nextCounts = new Map<string, number>();
|
||||
for (const server of counts.servers) {
|
||||
nextCounts.set(server.server_id, server.voice_state_count);
|
||||
}
|
||||
this.connectionCounts = nextCounts;
|
||||
this.lastSuccessAt = this.now();
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn({error}, 'Failed to refresh voice server connection counts');
|
||||
})
|
||||
.finally(() => {
|
||||
this.refreshing = null;
|
||||
});
|
||||
return this.refreshing;
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,7 @@ export class VoiceService {
|
||||
const pseudoRegionServer = selectClosestPseudoRegionServer({
|
||||
mode: regionPreference.mode,
|
||||
accessibleServers,
|
||||
connectionCounts: this.voiceAvailabilityService.getServerConnectionCounts(),
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
selectionKey,
|
||||
|
||||
@@ -38,6 +38,7 @@ function createMockServer(overrides: Partial<VoiceServerRecord> = {}): VoiceServ
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
isActive: true,
|
||||
softConnectionLimit: null,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
@@ -384,5 +385,37 @@ describe('VoiceAvailabilityService', () => {
|
||||
expect(first!.serverId).toBe('server-1');
|
||||
expect(second!.serverId).toBe('server-2');
|
||||
});
|
||||
it('rotates only between servers below their soft connection limit', () => {
|
||||
const region = createMockRegion();
|
||||
const server1 = createMockServer({serverId: 'server-1', softConnectionLimit: 50});
|
||||
const server2 = createMockServer({serverId: 'server-2'});
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
|
||||
service = new VoiceAvailabilityService(topology, {
|
||||
getConnectionCounts: () => new Map([['server-1', 50]]),
|
||||
});
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
|
||||
});
|
||||
it('rotates across every server when all of them are at their soft connection limit', () => {
|
||||
const region = createMockRegion();
|
||||
const server1 = createMockServer({serverId: 'server-1', softConnectionLimit: 50});
|
||||
const server2 = createMockServer({serverId: 'server-2', softConnectionLimit: 50});
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
|
||||
service = new VoiceAvailabilityService(topology, {
|
||||
getConnectionCounts: () =>
|
||||
new Map([
|
||||
['server-1', 90],
|
||||
['server-2', 90],
|
||||
]),
|
||||
});
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-1');
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {VoiceRegionAvailability, VoiceServerRecord} from '../VoiceModel';
|
||||
import {
|
||||
preferServersUnderSoftLimit,
|
||||
resolveVoiceRegionPreference,
|
||||
selectClosestPseudoRegionServer,
|
||||
selectVoiceRegionId,
|
||||
@@ -45,11 +46,13 @@ function createVoiceServer({
|
||||
serverId,
|
||||
latitude,
|
||||
longitude,
|
||||
softConnectionLimit = null,
|
||||
}: {
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
softConnectionLimit?: number | null;
|
||||
}): VoiceServerRecord {
|
||||
return {
|
||||
regionId,
|
||||
@@ -60,6 +63,7 @@ function createVoiceServer({
|
||||
latitude,
|
||||
longitude,
|
||||
isActive: true,
|
||||
softConnectionLimit,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
@@ -123,6 +127,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -136,6 +141,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedFromForwardOrder = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverB, serverA],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -143,6 +149,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedFromReverseOrder = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -150,6 +157,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedForAnotherRoom = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverB, serverA],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:2',
|
||||
@@ -164,6 +172,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'explicit',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -206,4 +215,91 @@ describe('VoiceRegionSelection', () => {
|
||||
expect(selectedFromReverseOrder).toBe('b');
|
||||
expect(selectedForAnotherRoom).toBe('a');
|
||||
});
|
||||
it('skips a pseudo-region server that reached its soft connection limit', () => {
|
||||
const nearServer = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: 51,
|
||||
longitude: 51,
|
||||
softConnectionLimit: 100,
|
||||
});
|
||||
const farServer = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: 0, longitude: 0});
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [nearServer, farServer],
|
||||
connectionCounts: new Map([['a1', 100]]),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
});
|
||||
expect(selectedServer?.serverId).toBe('b1');
|
||||
});
|
||||
it('keeps a pseudo-region server that is still below its soft connection limit', () => {
|
||||
const nearServer = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: 51,
|
||||
longitude: 51,
|
||||
softConnectionLimit: 100,
|
||||
});
|
||||
const farServer = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: 0, longitude: 0});
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [nearServer, farServer],
|
||||
connectionCounts: new Map([['a1', 99]]),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
});
|
||||
expect(selectedServer?.serverId).toBe('a1');
|
||||
});
|
||||
it('falls back to a server over its soft connection limit when every candidate is over', () => {
|
||||
const serverA = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: 51,
|
||||
longitude: 51,
|
||||
softConnectionLimit: 10,
|
||||
});
|
||||
const serverB = createVoiceServer({
|
||||
regionId: 'b',
|
||||
serverId: 'b1',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
softConnectionLimit: 10,
|
||||
});
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map([
|
||||
['a1', 40],
|
||||
['b1', 40],
|
||||
]),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
});
|
||||
expect(selectedServer?.serverId).toBe('a1');
|
||||
});
|
||||
it('ignores a soft connection limit when no count is known for the server', () => {
|
||||
const serverA = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
softConnectionLimit: 1,
|
||||
});
|
||||
const serverB = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: null, longitude: null});
|
||||
expect(preferServersUnderSoftLimit([serverA, serverB], new Map())).toEqual([serverA, serverB]);
|
||||
});
|
||||
it('ignores a soft connection limit that is not positive', () => {
|
||||
const serverA = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
softConnectionLimit: 0,
|
||||
});
|
||||
expect(preferServersUnderSoftLimit([serverA], new Map([['a1', 500]]))).toEqual([serverA]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {GatewayVoiceStateCounts, IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import {VoiceServerLoadTracker} from '../VoiceServerLoad';
|
||||
|
||||
function createGatewayService(respond: () => Promise<GatewayVoiceStateCounts>): {
|
||||
gatewayService: IGatewayService;
|
||||
callCount: () => number;
|
||||
} {
|
||||
let calls = 0;
|
||||
const gatewayService = {
|
||||
getVoiceStateCounts: () => {
|
||||
calls += 1;
|
||||
return respond();
|
||||
},
|
||||
} as IGatewayService;
|
||||
return {gatewayService, callCount: () => calls};
|
||||
}
|
||||
|
||||
function counts(servers: Array<{server_id: string; voice_state_count: number}>): GatewayVoiceStateCounts {
|
||||
return {
|
||||
total_voice_states: servers.reduce((total, server) => total + server.voice_state_count, 0),
|
||||
regions: [],
|
||||
servers,
|
||||
};
|
||||
}
|
||||
|
||||
describe('VoiceServerLoadTracker', () => {
|
||||
it('reports no counts until the first refresh resolves', () => {
|
||||
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
|
||||
const tracker = new VoiceServerLoadTracker({gatewayService});
|
||||
expect(tracker.getConnectionCounts().size).toBe(0);
|
||||
});
|
||||
it('reports the counts the gateway returned', async () => {
|
||||
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
|
||||
const tracker = new VoiceServerLoadTracker({gatewayService});
|
||||
await tracker.refresh();
|
||||
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
|
||||
});
|
||||
it('keeps the last counts when a refresh fails', async () => {
|
||||
let shouldFail = false;
|
||||
const {gatewayService} = createGatewayService(async () => {
|
||||
if (shouldFail) {
|
||||
throw new Error('gateway unavailable');
|
||||
}
|
||||
return counts([{server_id: 'server-1', voice_state_count: 7}]);
|
||||
});
|
||||
const tracker = new VoiceServerLoadTracker({gatewayService});
|
||||
await tracker.refresh();
|
||||
shouldFail = true;
|
||||
await tracker.refresh();
|
||||
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
|
||||
});
|
||||
it('refreshes no more often than the refresh interval', async () => {
|
||||
let currentTime = 1000;
|
||||
const {gatewayService, callCount} = createGatewayService(async () =>
|
||||
counts([{server_id: 'server-1', voice_state_count: 7}]),
|
||||
);
|
||||
const tracker = new VoiceServerLoadTracker({
|
||||
gatewayService,
|
||||
refreshIntervalMs: 5000,
|
||||
now: () => currentTime,
|
||||
});
|
||||
await tracker.refresh();
|
||||
tracker.getConnectionCounts();
|
||||
currentTime += 4999;
|
||||
tracker.getConnectionCounts();
|
||||
expect(callCount()).toBe(1);
|
||||
currentTime += 1;
|
||||
tracker.getConnectionCounts();
|
||||
expect(callCount()).toBe(2);
|
||||
});
|
||||
it('drops counts that are too old to place against', async () => {
|
||||
let currentTime = 1000;
|
||||
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
|
||||
const tracker = new VoiceServerLoadTracker({
|
||||
gatewayService,
|
||||
refreshIntervalMs: 5000,
|
||||
now: () => currentTime,
|
||||
});
|
||||
await tracker.refresh();
|
||||
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
|
||||
currentTime += 20001;
|
||||
expect(tracker.getConnectionCounts().size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {FavoriteGifEntry} from './FavoriteGifTypes';
|
||||
import {buildGifPickerGridData} from './GifPickerGridData';
|
||||
import type {FavoriteAwareGif} from './GifPickerTypes';
|
||||
|
||||
@@ -19,7 +20,84 @@ function gif(id: string, width = 200, height = 120): FavoriteAwareGif {
|
||||
};
|
||||
}
|
||||
|
||||
const OPAQUE_PROXY_URL =
|
||||
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
|
||||
|
||||
function slimFavorite(overrides: Partial<FavoriteGifEntry> = {}): FavoriteGifEntry {
|
||||
return {
|
||||
url: 'https://klipy.com/gifs/doc-brown-bttf',
|
||||
proxy_url: OPAQUE_PROXY_URL,
|
||||
width: 640,
|
||||
height: 360,
|
||||
media: {},
|
||||
content_type: 'video/webm',
|
||||
placeholder: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GifPickerGridData', () => {
|
||||
it('carries the stored content type of a slimmed favorite into its tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'favorites',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [slimFavorite()],
|
||||
favoriteMemes: [],
|
||||
useSavedMediaForGifFavorites: false,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({
|
||||
type: 'gif',
|
||||
gif: {proxy_src: OPAQUE_PROXY_URL, contentType: 'video/webm'},
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the stored content type of a slimmed favorite into the featured favorites tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [gif('featured')], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [slimFavorite()],
|
||||
favoriteMemes: [],
|
||||
useSavedMediaForGifFavorites: false,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({
|
||||
type: 'category',
|
||||
previewProxySrc: OPAQUE_PROXY_URL,
|
||||
previewContentType: 'video/webm',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the content type of the saved media standing in for an empty favorites tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [gif('featured')], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [],
|
||||
favoriteMemes: [{contentType: 'video/mp4', url: 'meme-0'}],
|
||||
useSavedMediaForGifFavorites: true,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({type: 'category', previewProxySrc: 'meme-0', previewContentType: 'video/mp4'});
|
||||
});
|
||||
|
||||
it('keeps featured data deterministic while choosing preview tiles', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type FavoriteGifEntry,
|
||||
inferFormatContentType,
|
||||
pickBestPreviewFormat,
|
||||
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
@@ -72,6 +73,7 @@ function buildFavoriteGifItems(
|
||||
const previewProxySrc = best?.format.proxy_src ?? fallbackSrc;
|
||||
const previewWidth = best?.format.width ?? entry.width;
|
||||
const previewHeight = best?.format.height ?? entry.height;
|
||||
const previewContentType = best ? inferFormatContentType(best.key) : entry.content_type;
|
||||
items.push({
|
||||
type: 'gif',
|
||||
key: entry.url,
|
||||
@@ -86,6 +88,7 @@ function buildFavoriteGifItems(
|
||||
width: previewWidth > 0 ? previewWidth : DEFAULT_GIF_SIZE,
|
||||
height: previewHeight > 0 ? previewHeight : DEFAULT_GIF_SIZE,
|
||||
media: entry.media,
|
||||
contentType: previewContentType,
|
||||
favoriteGifLookup: {url: entry.url},
|
||||
},
|
||||
});
|
||||
@@ -102,21 +105,21 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
|
||||
const favoriteGifPreviewEntry =
|
||||
favoriteGifPreviewIndex >= 0 ? input.favoriteGifs[favoriteGifPreviewIndex] : undefined;
|
||||
const favoriteGifPreview = pickBestPreviewFormat(favoriteGifPreviewEntry?.media);
|
||||
const favoriteMemePreview =
|
||||
gifvMemes.length > 0
|
||||
? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)]?.url ?? '')
|
||||
: '';
|
||||
const favoriteTilePreview =
|
||||
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
|
||||
? favoriteMemePreview
|
||||
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileProxyPreview =
|
||||
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
|
||||
? favoriteTilePreview
|
||||
: favoriteGifPreview?.format.proxy_src ||
|
||||
favoriteGifPreviewEntry?.proxy_url ||
|
||||
favoriteGifPreviewEntry?.url ||
|
||||
'';
|
||||
const favoriteMemeCandidate =
|
||||
gifvMemes.length > 0 ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)] ?? null) : null;
|
||||
const favoriteMemePreview = favoriteMemeCandidate?.url ?? '';
|
||||
const usesFavoriteMemePreview = input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0;
|
||||
const favoriteTilePreview = usesFavoriteMemePreview
|
||||
? favoriteMemePreview
|
||||
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileProxyPreview = usesFavoriteMemePreview
|
||||
? favoriteTilePreview
|
||||
: favoriteGifPreview?.format.proxy_src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileContentType = (() => {
|
||||
if (usesFavoriteMemePreview) return favoriteMemeCandidate?.contentType ?? '';
|
||||
if (favoriteGifPreview) return inferFormatContentType(favoriteGifPreview.key);
|
||||
return favoriteGifPreviewEntry?.proxy_url ? favoriteGifPreviewEntry.content_type : '';
|
||||
})();
|
||||
const favoritesTile: Array<GifPickerGridItemData> =
|
||||
(input.includeFavoritesTile ?? true)
|
||||
? [
|
||||
@@ -128,6 +131,7 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
|
||||
title: input.favoriteTitle,
|
||||
previewUrl: favoriteTilePreview,
|
||||
previewProxySrc: favoriteTileProxyPreview,
|
||||
previewContentType: favoriteTileContentType,
|
||||
width: CATEGORY_TILE_WIDTH,
|
||||
height: CATEGORY_TILE_HEIGHT,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import styles from '@app/features/channel/components/GifPicker.module.css';
|
||||
import {safePause, safePlay, useGifVideoPool} from '@app/features/channel/components/GifVideoPool';
|
||||
import {resolvesToVideo} from '@app/features/channel/components/pickers/gif/GifPickerMediaKind';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
import {PickerThumbnail} from '@app/features/channel/components/pickers/shared/PickerThumbnail';
|
||||
import {usePooledVideo} from '@app/features/channel/components/pickers/shared/usePooledVideo';
|
||||
@@ -52,30 +53,6 @@ const SELECT_GIF_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Button label in the gif picker grid item when picking a GIF for an avatar, banner, or video background. Keep it concise. Preserve {title}; it is inserted by code.',
|
||||
});
|
||||
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
|
||||
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
|
||||
|
||||
function testSourcePath(value: string, pattern: RegExp): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return pattern.test(url.pathname);
|
||||
} catch {
|
||||
return pattern.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoSourceUrl(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function statesItsMediaKind(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function resolvesToVideo(proxySrc: string, mediaSourceUrl: string | null): boolean {
|
||||
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
|
||||
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
|
||||
}
|
||||
|
||||
export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
item,
|
||||
@@ -126,6 +103,11 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
if (item.type === 'category') return item.previewUrl;
|
||||
return null;
|
||||
})();
|
||||
const mediaContentType = (() => {
|
||||
if (item.type === 'gif') return item.gif.contentType ?? '';
|
||||
if (item.type === 'category') return item.previewContentType ?? '';
|
||||
return '';
|
||||
})();
|
||||
const thumbnailPlaceholder = (() => {
|
||||
if (item.type !== 'gif') return null;
|
||||
if (item.gif.placeholder) return item.gif.placeholder;
|
||||
@@ -133,7 +115,10 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
return FavoriteGif.findByUrl(lookupUrl)?.placeholder ?? null;
|
||||
})();
|
||||
const usesVideoElement =
|
||||
!isSkeleton && proxySrc !== null && proxySrc.length > 0 && resolvesToVideo(proxySrc, mediaSourceUrl);
|
||||
!isSkeleton &&
|
||||
proxySrc !== null &&
|
||||
proxySrc.length > 0 &&
|
||||
resolvesToVideo(mediaContentType, proxySrc, mediaSourceUrl);
|
||||
const videoThumbHashURL = decodeThumbHashDataURL(usesVideoElement ? thumbnailPlaceholder : null);
|
||||
const hasThumbnailContent = (proxySrc !== null && proxySrc.length > 0) || thumbnailPlaceholder !== null;
|
||||
const videoRef = usePooledVideo({
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {resolvesToVideo} from './GifPickerMediaKind';
|
||||
|
||||
const OPAQUE_PROXY_SRC =
|
||||
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
|
||||
const PLAIN_PROXY_SRC = 'https://media.test/external/sig/https/static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm';
|
||||
|
||||
describe('resolvesToVideo', () => {
|
||||
it('trusts a stored video content type when no url states its kind', () => {
|
||||
expect(resolvesToVideo('video/webm', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts a stored image content type when no url states its kind', () => {
|
||||
expect(resolvesToVideo('image/gif', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to an image when nothing states the kind', () => {
|
||||
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the extension off a plain proxy path', () => {
|
||||
expect(resolvesToVideo('', PLAIN_PROXY_SRC, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the extension off the media source when the proxy path is opaque', () => {
|
||||
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, 'https://static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps images as images when the proxy path states its kind', () => {
|
||||
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.gif', null)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a query string when reading the extension', () => {
|
||||
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.mp4?width=200', null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
|
||||
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
|
||||
|
||||
function testSourcePath(value: string, pattern: RegExp): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return pattern.test(url.pathname);
|
||||
} catch {
|
||||
return pattern.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoSourceUrl(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function statesItsMediaKind(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
export function resolvesToVideo(contentType: string, proxySrc: string, mediaSourceUrl: string | null): boolean {
|
||||
if (contentType.startsWith('video/')) return true;
|
||||
if (contentType.startsWith('image/')) return false;
|
||||
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
|
||||
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface FavoriteGifLookup {
|
||||
|
||||
export type FavoriteAwareGif = Gif & {
|
||||
favoriteGifLookup?: FavoriteGifLookup;
|
||||
contentType?: string;
|
||||
};
|
||||
export type GifPickerGridItemData =
|
||||
| {
|
||||
@@ -20,6 +21,7 @@ export type GifPickerGridItemData =
|
||||
categoryKind: 'favorites' | 'trending' | 'category';
|
||||
previewUrl: string;
|
||||
previewProxySrc: string;
|
||||
previewContentType?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -867,7 +867,6 @@ class ReadStates {
|
||||
state.ackedManually = false;
|
||||
state.clearStickyUnread();
|
||||
}
|
||||
this.cancelPendingAckIfCovered(state.channelId, decision.messageId);
|
||||
return {acked: true, messageId: decision.messageId, hadMentions: decision.hadMentions};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {Channel} from '@app/features/channel/models/Channel';
|
||||
import {ACK_BATCH_DELAY_MS, type GatewayReadState} from '@app/features/read_state/state/read_states/shared';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const channels = new Map<string, Channel>();
|
||||
let pinnedToEnd = false;
|
||||
let automaticAck = false;
|
||||
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
|
||||
|
||||
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
|
||||
vi.mock('@app/features/channel/state/Channels', () => ({
|
||||
default: {getChannel: (id: string) => channels.get(id)},
|
||||
}));
|
||||
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
|
||||
default: {
|
||||
getMessages: () => ({
|
||||
get hasMoreBefore() {
|
||||
return false;
|
||||
},
|
||||
get length() {
|
||||
return loadedMessages.length;
|
||||
},
|
||||
jumpDestinationId: null,
|
||||
hasNewestMessages: () => true,
|
||||
has: (id: string) => loadedMessages.some((m) => m.id === id),
|
||||
last: () => loadedMessages[loadedMessages.length - 1],
|
||||
forEachBuffered: (cb: (m: unknown) => void) => {
|
||||
for (const m of loadedMessages) cb(m);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({
|
||||
default: {getCurrentUser: () => ({id: 'me'}), cacheUsers: () => {}},
|
||||
}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
|
||||
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
|
||||
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
|
||||
default: {
|
||||
isEveryoneMentionSuppressed: () => false,
|
||||
isRoleMentionSuppressed: () => false,
|
||||
isGuildOrChannelMuted: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => pinnedToEnd}}));
|
||||
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
|
||||
default: {isAutomaticAckEnabled: () => automaticAck, disableForChannel: () => {}},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
||||
http: {post: vi.fn(async () => ({body: {read_states: []}})), get: vi.fn()},
|
||||
}));
|
||||
|
||||
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
|
||||
const {http} = await import('@app/features/platform/transport/RestTransport');
|
||||
|
||||
const CHANNEL = '1485064866382176262';
|
||||
const MESSAGE = {
|
||||
acked: '1547700000000000000',
|
||||
unread: '1547743000000000000',
|
||||
};
|
||||
|
||||
function user(id: string): UserPartial {
|
||||
return {id, username: id, discriminator: '0', global_name: null, avatar: null, avatar_color: null, flags: 0};
|
||||
}
|
||||
|
||||
function guildText(lastMessageId: string): WireChannel {
|
||||
return {id: CHANNEL, type: ChannelTypes.GUILD_TEXT, guild_id: 'guild-1', last_message_id: lastMessageId};
|
||||
}
|
||||
|
||||
function readState(ackMessageId: string): GatewayReadState {
|
||||
return {id: CHANNEL, last_message_id: ackMessageId, mention_count: 0, version: '1'};
|
||||
}
|
||||
|
||||
function ready(): void {
|
||||
channels.clear();
|
||||
channels.set(CHANNEL, new Channel(guildText(MESSAGE.unread)));
|
||||
ReadStates.handleGatewayReady({readState: [readState(MESSAGE.acked)], channels: [guildText(MESSAGE.unread)]});
|
||||
}
|
||||
|
||||
function wireMessage(id: string, authorId: string): WireMessage {
|
||||
return {
|
||||
id,
|
||||
channel_id: CHANNEL,
|
||||
author: user(authorId),
|
||||
type: MessageTypes.DEFAULT,
|
||||
flags: 0,
|
||||
pinned: false,
|
||||
tts: false,
|
||||
mention_everyone: false,
|
||||
content: 'test',
|
||||
timestamp: '2026-09-11T00:00:00.000Z',
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
} as unknown as WireMessage;
|
||||
}
|
||||
|
||||
function openChannel(): void {
|
||||
loadedMessages.push({id: MESSAGE.unread, author: {id: 'tuna'}});
|
||||
ReadStates.handleLoadMessages({channelId: CHANNEL, isAfter: false, messages: [{id: MESSAGE.unread}] as never});
|
||||
pinnedToEnd = true;
|
||||
automaticAck = true;
|
||||
ReadStates.handleChannelAckWithStickyUnread({channelId: CHANNEL});
|
||||
}
|
||||
|
||||
describe('ReadStates ack batching', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
loadedMessages.length = 0;
|
||||
pinnedToEnd = false;
|
||||
automaticAck = false;
|
||||
vi.mocked(http.post).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ReadStates.clearAll();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sends the ack one batch delay after a quiet channel is read', async () => {
|
||||
ready();
|
||||
expect(ReadStates.hasUnread(CHANNEL)).toBe(true);
|
||||
openChannel();
|
||||
expect(ReadStates.hasUnread(CHANNEL)).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS - 1);
|
||||
expect(http.post).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(http.post).toHaveBeenCalledWith(Endpoints.READ_STATES_ACK, {
|
||||
body: {read_states: [{channel_id: CHANNEL, message_id: MESSAGE.unread}]},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the batch window anchored to the first ack while messages keep arriving', async () => {
|
||||
ready();
|
||||
openChannel();
|
||||
let messageId = 1547743900000000000n;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
messageId += 1000000n;
|
||||
ReadStates.handleIncomingMessage({channelId: CHANNEL, message: wireMessage(messageId.toString(), 'tuna')});
|
||||
}
|
||||
expect(http.post).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS - 2700);
|
||||
expect(http.post).toHaveBeenCalledTimes(1);
|
||||
expect(http.post).toHaveBeenCalledWith(Endpoints.READ_STATES_ACK, {
|
||||
body: {read_states: [{channel_id: CHANNEL, message_id: messageId.toString()}]},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps acking a channel read for a long stretch of steady traffic', async () => {
|
||||
ready();
|
||||
openChannel();
|
||||
let messageId = 1547743900000000000n;
|
||||
for (let i = 0; i < 22; i++) {
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
messageId += 1000000n;
|
||||
ReadStates.handleIncomingMessage({channelId: CHANNEL, message: wireMessage(messageId.toString(), 'tuna')});
|
||||
}
|
||||
expect(vi.mocked(http.post).mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {screenShareWatchAttemptKey} from '@app/features/voice/components/useScreenShareWatchFailure';
|
||||
import {
|
||||
selectVoiceMediaGraphFailure,
|
||||
selectVoiceMediaGraphViewerStreamKeys,
|
||||
@@ -101,7 +102,7 @@ describe('VoiceMediaGraphTimerScheduler', () => {
|
||||
store.transition({
|
||||
type: 'watch.attemptEnsured',
|
||||
streamKey: STREAM_KEY,
|
||||
attemptKey: `${STREAM_KEY}:1:watch`,
|
||||
attemptKey: screenShareWatchAttemptKey({streamKey: STREAM_KEY, watchGeneration: 1}),
|
||||
startedAt: store.nowMs(),
|
||||
});
|
||||
harness.advance(WATCH_ATTEMPT_TIMEOUT_MS);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {screenShareWatchAttemptKey} from '@app/features/voice/components/useScreenShareWatchFailure';
|
||||
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
|
||||
import {ScreenShareWatchErrorCode, ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
|
||||
import type {RemoteTrackPublication} from 'livekit-client';
|
||||
@@ -104,7 +105,7 @@ describe('ScreenShareWatchFailures failure history', () => {
|
||||
|
||||
it('keeps a failure raised by a watch deadline after the viewer stops watching', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-deadline';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef: {current: null}});
|
||||
@@ -122,7 +123,7 @@ describe('ScreenShareWatchFailures failure history', () => {
|
||||
|
||||
it('records the inbound counters of the watched publication alongside the failure', async () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-counters';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {
|
||||
@@ -160,7 +161,7 @@ describe('ScreenShareWatchFailures failure history', () => {
|
||||
|
||||
it('records the size and readiness of the attached video element', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-tile';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
|
||||
const video = {readyState: 0, videoWidth: 0, videoHeight: 0, clientWidth: 960, clientHeight: 540};
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
@@ -186,7 +187,7 @@ describe('ScreenShareWatchFailures failure history', () => {
|
||||
|
||||
it('leaves the running watch deadline alone when the watch target is registered', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-target';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
const deadline = watchDeadlineFor(streamKey);
|
||||
|
||||
@@ -99,6 +99,8 @@ The `region_id` and `server_id` pair addresses one server, and a server belongs
|
||||
|
||||
A server can have its own coordinate. Placement then measures distance from that coordinate to pick the closest server for an automatically placed session. A server without one takes no part in distance comparison.
|
||||
|
||||
A server can also have a soft connection limit, described under [soft connection limits](#soft-connection-limits).
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
@@ -109,6 +111,7 @@ A server can have its own coordinate. Placement then measures distance from that
|
||||
| latitude<sup>2</sup> | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null when the region coordinate is used |
|
||||
| longitude<sup>2</sup> | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null when the region coordinate is used |
|
||||
| is_active<sup>3</sup> | boolean | Whether the server is in rotation for new placement |
|
||||
| soft_connection_limit<sup>5</sup> | ?integer | The count at which placement starts preferring another server, or null when the server has no limit (1-2147483647) |
|
||||
| vip_only | boolean | Whether the guild has to hold `VIP_VOICE` |
|
||||
| required_guild_features | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild, any one of which is enough (max 100) |
|
||||
| allowed_guild_ids<sup>4</sup> | array[snowflake] | The guilds admitted without checking the other two guild gates (max 1000) |
|
||||
@@ -124,6 +127,8 @@ A server can have its own coordinate. Placement then measures distance from that
|
||||
|
||||
<sup>4</sup> Duplicate entries collapse and the returned order is not the submitted order
|
||||
|
||||
<sup>5</sup> The limit is a preference, not a cap. It is described under [soft connection limits](#soft-connection-limits)
|
||||
|
||||
:::caution[Server credentials are never returned]
|
||||
`api_key` and `api_secret` are stored on the record and accepted by the create and update bodies. No read returns them, so a lost secret has to be replaced with an update.
|
||||
:::
|
||||
@@ -138,6 +143,7 @@ A server can have its own coordinate. Placement then measures distance from that
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"is_active": true,
|
||||
"soft_connection_limit": null,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
@@ -147,6 +153,18 @@ A server can have its own coordinate. Placement then measures distance from that
|
||||
}
|
||||
```
|
||||
|
||||
## Soft connection limits
|
||||
|
||||
`soft_connection_limit` is the connection count at which a server stops being a preferred placement target. It orders the candidates for one placement and never refuses one.
|
||||
|
||||
Placement splits the servers it may use into those below their limit and those at or above it. It picks from the first group, and it picks from the full set when the first group is empty. A server with a null limit is always in the first group, and so is a server whose current count is unknown. A session is therefore still placed on a server past its limit when no other server can take it.
|
||||
|
||||
The count is the number of voice connections the gateway holds for the server, the same figure [Get voice state counts](/admin-api/gateway/#get-voice-state-counts) returns. Each API node reads it at most once every 15 seconds and places against the last reading, so a burst of placements can push a server past its limit before the next reading. A node that has no reading less than 60 seconds old places as though no server had a limit.
|
||||
|
||||
The count is keyed by `server_id` alone. Two servers registered in different regions under the same `server_id` share one count, and each is measured against its own limit.
|
||||
|
||||
The limit is read when a channel is first placed and the server is pinned for it, as [Voice](/voice/#regions) describes. Later placements into that channel inherit the pin and are not measured against the limit. Lowering or clearing a limit moves no live session.
|
||||
|
||||
## List voice regions
|
||||
|
||||
<RouteHeader method="GET" path="/v1/admin/voice/regions" />
|
||||
@@ -444,6 +462,7 @@ Registers a voice server in a region and returns it. Requires `voice:server:crea
|
||||
| latitude?<sup>3</sup> | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
|
||||
| longitude?<sup>3</sup> | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
|
||||
| is_active? | boolean | Whether the server is in rotation for new placement (default true) |
|
||||
| soft_connection_limit?<sup>4</sup> | ?integer | The count at which placement starts preferring another server, or null for no limit (1-2147483647, default null) |
|
||||
| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` (default false) |
|
||||
| required_guild_features? | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items of 1-64 characters each, default empty) |
|
||||
| allowed_guild_ids? | array[snowflake] | The guilds admitted without checking the other two guild gates (max 1000, default empty) |
|
||||
@@ -455,6 +474,8 @@ Registers a voice server in a region and returns it. Requires `voice:server:crea
|
||||
|
||||
<sup>3</sup> The two coordinates are supplied together and are either both null or both a number. A mismatched pair fails body validation on the `latitude` path
|
||||
|
||||
<sup>4</sup> Zero and every negative value fail validation, so a server with no limit is registered by omitting the field or sending null. [Soft connection limits](#soft-connection-limits) describes what the value does
|
||||
|
||||
The body has no `region_id`. A `region_id` member in the body is overwritten from the path and cannot register the server under a different region.
|
||||
|
||||
### Response body
|
||||
@@ -506,6 +527,7 @@ Every field is optional and an omitted field is left unchanged. An absent, empty
|
||||
| latitude?<sup>2</sup> | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
|
||||
| longitude?<sup>2</sup> | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
|
||||
| is_active? | boolean | Whether the server is in rotation for new placement |
|
||||
| soft_connection_limit?<sup>4</sup> | ?integer | The count at which placement starts preferring another server, or null for no limit (1-2147483647) |
|
||||
| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` |
|
||||
| required_guild_features?<sup>3</sup> | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items of 1-64 characters each) |
|
||||
| allowed_guild_ids?<sup>3</sup> | array[snowflake] | The guilds admitted without checking the other two guild gates (max 1000) |
|
||||
@@ -517,6 +539,8 @@ Every field is optional and an omitted field is left unchanged. An absent, empty
|
||||
|
||||
<sup>3</sup> A supplied collection replaces the stored collection outright, so removing one entry means sending the complete remaining set and clearing a collection means sending an empty array
|
||||
|
||||
<sup>4</sup> An omitted limit is left unchanged and null clears it. Zero and every negative value fail validation
|
||||
|
||||
A `region_id` or `server_id` member in the body is overwritten from the path and cannot move the server to another region.
|
||||
|
||||
### Response body
|
||||
|
||||
@@ -135,6 +135,8 @@ A member whose `communication_disabled_until` is still in the future is refused
|
||||
|
||||
The first placement in the channel pins one voice server for it, and every later placement inherits that pinned server whatever its own coordinates are. A placement that finds no usable pin takes the accessible server nearest to the `latitude` and `longitude` the placement command supplied. Where the command supplied no usable coordinates, the placement falls back to the deployment's default region, and then to the first accessible region.
|
||||
|
||||
A voice server can have a soft connection limit. A placement that has to choose a server prefers the servers below their limit and uses one that is at or above its limit only when no other server can take the placement. [Soft connection limits](/admin-api/voice/#soft-connection-limits) describes the rule in full.
|
||||
|
||||
The pin drops when the channel's `rtc_region` changes, when a call changes region, when the pinned server stops being accessible, or when the media server reports the room finished. That last case also disconnects every connection in a guild voice channel.
|
||||
|
||||
The literal `automatic` is not a channel region. Only the `region` field of [Modify call region](/http-api/calls/#modify-call-region) accepts it, as a synonym for null.
|
||||
|
||||
@@ -21,6 +21,8 @@ function areServerCoordinatesPaired(
|
||||
return latitudeIsNull === longitudeIsNull;
|
||||
}
|
||||
|
||||
const SoftConnectionLimitType = z.number().int().min(1).max(2147483647).nullable();
|
||||
|
||||
export const VoiceRegionAdminResponse = z.object({
|
||||
id: z.string().describe('Unique identifier for the voice region'),
|
||||
name: z.string().describe('Display name of the voice region'),
|
||||
@@ -45,6 +47,9 @@ export const VoiceServerAdminResponse = z.object({
|
||||
latitude: z.number().nullable().describe('Optional geographic latitude override for this server'),
|
||||
longitude: z.number().nullable().describe('Optional geographic longitude override for this server'),
|
||||
is_active: z.boolean().describe('Whether the server is currently active'),
|
||||
soft_connection_limit: SoftConnectionLimitType.describe(
|
||||
'Connection count above which placement prefers another server, or null when the server has no limit',
|
||||
),
|
||||
vip_only: z.boolean().describe('Whether this server is restricted to VIP users'),
|
||||
required_guild_features: z.array(z.string()).max(100).describe('Guild features required to use this server'),
|
||||
allowed_guild_ids: z.array(SnowflakeStringType).max(1000).describe('Guild IDs explicitly allowed to use this server'),
|
||||
@@ -128,6 +133,9 @@ export const CreateVoiceServerRequest = z
|
||||
latitude: z.number().nullable().optional().describe('Optional geographic latitude override for this server'),
|
||||
longitude: z.number().nullable().optional().describe('Optional geographic longitude override for this server'),
|
||||
is_active: z.boolean().optional().default(true).describe('Whether the server is currently active'),
|
||||
soft_connection_limit: SoftConnectionLimitType.optional()
|
||||
.default(null)
|
||||
.describe('Connection count above which placement prefers another server, or null for no limit'),
|
||||
vip_only: z.boolean().optional().default(false).describe('Whether this server is restricted to VIP users'),
|
||||
required_guild_features: z
|
||||
.array(createStringType(1, 64))
|
||||
@@ -165,6 +173,9 @@ export const UpdateVoiceServerRequest = z
|
||||
latitude: z.number().nullable().optional().describe('Optional geographic latitude override for this server'),
|
||||
longitude: z.number().nullable().optional().describe('Optional geographic longitude override for this server'),
|
||||
is_active: z.boolean().optional().describe('Whether the server is currently active'),
|
||||
soft_connection_limit: SoftConnectionLimitType.optional().describe(
|
||||
'Connection count above which placement prefers another server, or null for no limit',
|
||||
),
|
||||
vip_only: z.boolean().optional().describe('Whether this server is restricted to VIP users'),
|
||||
required_guild_features: z
|
||||
.array(createStringType(1, 64))
|
||||
|
||||
@@ -8883,6 +8883,10 @@
|
||||
{
|
||||
"name": "longitude",
|
||||
"type": "double"
|
||||
},
|
||||
{
|
||||
"name": "soft_connection_limit",
|
||||
"type": "int"
|
||||
}
|
||||
],
|
||||
"primary_key": "((region_id), server_id)",
|
||||
|
||||
Reference in New Issue
Block a user