mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412a1ae79d | ||
|
|
0b2306ec3d | ||
|
|
242ed3a934 | ||
|
|
70e1ce682a | ||
|
|
7601bf98ee | ||
|
|
c7ec2a0f58 | ||
|
|
6a5e0056a8 | ||
|
|
78d105b46e | ||
|
|
c68d62b8a0 | ||
|
|
df58020f4c | ||
|
|
f052ce05aa | ||
|
|
eedfd9275f | ||
|
|
416af4bec4 | ||
|
|
108d282ddd | ||
|
|
a6103244b0 | ||
|
|
38935c83c5 | ||
|
|
c157ab5752 | ||
|
|
574a93257c |
@@ -7,7 +7,7 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
ARG NODE_MAJOR=26
|
||||
ARG ELP_VERSION=2026-08-10
|
||||
ARG PNPM_VERSION=12.4.2
|
||||
ARG PNPM_VERSION=11.27.0
|
||||
ARG WASM_BINDGEN_VERSION=0.2.128
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -38,7 +38,11 @@
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"erlang.includePaths": ["."],
|
||||
"search.exclude": {
|
||||
"**/_build/default/lib/fluxer_gateway": true
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"biomejs.biome",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/.direnv/
|
||||
/.fluxer/
|
||||
/.pnpm-store/
|
||||
/.vscode/
|
||||
|
||||
**/*.css.d.ts
|
||||
**/*.tsbuildinfo
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="https://fluxer.app/download">
|
||||
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
|
||||
<a href="https://docs.fluxer.app">
|
||||
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
|
||||
</p>
|
||||
@@ -19,5 +21,128 @@
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
|
||||
| Windows | macOS | Linux | Android | iOS |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flatpak][flatpak-ref] | [APK][android-apk] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
|
||||
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
|
||||
| | | [tar.gz (x64)][linux-targz-x64] | | |
|
||||
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
|
||||
|
||||
The macOS disk image is universal and runs on both Apple silicon and Intel. Windows and Linux need the build that matches your processor.
|
||||
|
||||
On Linux, prefer a package repository over a file. Fluxer then updates with the rest of your system.
|
||||
|
||||
## Linux package repositories
|
||||
|
||||
All four repositories serve stable and canary. The package is `fluxer` for stable and `fluxer-canary` for canary.
|
||||
|
||||
### Flatpak
|
||||
|
||||
Opening [this reference file][flatpak-ref] hands the install to your desktop software manager. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` pasted into the address bar.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
```
|
||||
|
||||
### Debian and Ubuntu
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
|
||||
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
|
||||
sudo apt update && sudo apt install fluxer
|
||||
```
|
||||
|
||||
### Fedora and RHEL
|
||||
|
||||
```sh
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
|
||||
sudo dnf install fluxer
|
||||
```
|
||||
|
||||
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because the base repositories do not ship `libXScrnSaver`. Fedora does not need this.
|
||||
|
||||
### Arch Linux
|
||||
|
||||
The repository is signed, so pacman needs the key in its own keyring once:
|
||||
|
||||
```sh
|
||||
sudo pacman-key --init
|
||||
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
|
||||
```
|
||||
|
||||
`--lsign-key` is the step that makes pacman trust the key. Then add the repository:
|
||||
|
||||
```sh
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
|
||||
|
||||
[fluxer]
|
||||
SigLevel = Required TrustedOnly
|
||||
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
|
||||
REPO
|
||||
sudo pacman -Syu fluxer
|
||||
```
|
||||
|
||||
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, which is why the heredoc above is quoted.
|
||||
|
||||
Full setup notes, including the canary channel, live in the [Linux repositories documentation][docs-linux].
|
||||
|
||||
## Other ways to run it
|
||||
|
||||
- [Open Fluxer in a browser](https://web.fluxer.app) with no install at all.
|
||||
- [Host your own instance][docs-selfhost] from this repository.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Documentation home][docs]
|
||||
- [Downloads][docs-downloads]
|
||||
- [Self-hosting][docs-selfhost]
|
||||
|
||||
## License
|
||||
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer and all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its
|
||||
own terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
endorsement rights.
|
||||
|
||||
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
|
||||
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
|
||||
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
|
||||
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
|
||||
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
|
||||
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
|
||||
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
|
||||
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
|
||||
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
|
||||
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
|
||||
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
|
||||
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||
[docs]: https://docs.fluxer.app
|
||||
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
|
||||
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
|
||||
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
|
||||
|
||||
@@ -281,6 +281,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_POSTGRES_WORK_MEM=8MB
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
#FLUXER_POSTGRES_SHM_SIZE=1gb
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache, so it runs with an
|
||||
# append-only file and with noeviction, which fails an over-limit write instead
|
||||
|
||||
@@ -188,7 +188,7 @@ services:
|
||||
-c autovacuum_vacuum_cost_limit=2000
|
||||
-c track_io_timing=on
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
shm_size: 256mb
|
||||
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
|
||||
environment:
|
||||
POSTGRES_DB: fluxer
|
||||
POSTGRES_USER: fluxer
|
||||
|
||||
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
|
||||
&& npm install -g pnpm@12.4.2 \
|
||||
&& npm install -g pnpm@11.27.0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
|
||||
|
||||
@@ -10524,6 +10524,7 @@
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10951,6 +10952,7 @@
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"screen_share_delivery",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11085,6 +11087,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -15178,6 +15184,24 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"ScreenShareDeliveryConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15243,6 +15267,36 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ScreenShareDeliveryConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
@@ -446,7 +448,8 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
}
|
||||
|
||||
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const SCREEN_SHARE_DELIVERY_DEFAULT_SALT: &str = "screen-share-delivery-v1";
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
@@ -537,6 +540,44 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ScreenShareDeliveryConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ScreenShareDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: SCREEN_SHARE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ScreenShareDeliveryConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -653,6 +694,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
@@ -990,18 +1033,30 @@ mod tests {
|
||||
.expect("admin schema");
|
||||
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||
.expect("default noise config");
|
||||
let screen_share = serde_json::from_value::<ScreenShareDeliveryConfigResponse>(json!({}))
|
||||
.expect("default screen share config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let screen_share =
|
||||
serde_json::to_value(screen_share).expect("serializable screen share config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_screen_share: generated_types::ScreenShareDeliveryConfigResponse =
|
||||
serde_json::from_value(screen_share.clone())
|
||||
.expect("generated screen share config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_screen_share)
|
||||
.expect("serializable generated screen share config"),
|
||||
screen_share
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
@@ -1009,6 +1064,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ScreenShareDeliveryConfigResponse", screen_share),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
@@ -1020,6 +1076,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_share_delivery_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = ScreenShareDeliveryConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::ScreenShareDeliveryConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ScreenShareDeliveryConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
|
||||
@@ -7,20 +7,20 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, RegistrationMode, ScreenShareDeliveryConfigUpdateRequest,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -207,6 +207,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_screen_share_delivery" => match build_screen_share_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -514,9 +518,9 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
|
||||
if ids.iter().any(|existing| existing == candidate) {
|
||||
continue;
|
||||
}
|
||||
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
|
||||
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
|
||||
return Err(format!(
|
||||
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
|
||||
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
|
||||
));
|
||||
}
|
||||
ids.push(candidate.to_owned());
|
||||
@@ -629,6 +633,38 @@ fn build_voice_noise_suppression_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_screen_share_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"screen_share_delivery_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"screen_share_delivery_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("screen_share_delivery_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("screen_share_delivery_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1348,13 +1384,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
@@ -1550,6 +1586,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"screen_share_delivery_enabled=true&screen_share_delivery_rollout_basis_points=%20250%20&screen_share_delivery_rollout_salt=%20screen-share-delivery-v2%20&screen_share_delivery_included_user_ids=1500000000000000001%0A1500000000000000002&screen_share_delivery_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_screen_share_delivery_update(&form)
|
||||
.expect("valid form")
|
||||
.screen_share_delivery
|
||||
.expect("screen share delivery update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("screen-share-delivery-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000001".to_owned(),
|
||||
"1500000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_screen_share_delivery_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"screen_share_delivery": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"screen_share_delivery_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_rollout_basis_points=abc",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_rollout_salt=%20%20",
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"screen_share_delivery_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_screen_share_delivery_update(&form).expect_err("invalid rollout field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
|
||||
RegistrationUrlResponse, SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
|
||||
ScreenShareDeliveryConfigResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -148,6 +149,7 @@ pub fn instance_config_page(
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_delivery))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1105,7 +1107,7 @@ fn voice_noise_suppression_section(
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
@@ -1125,7 +1127,7 @@ fn voice_noise_suppression_section(
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.excluded_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
@@ -1176,6 +1178,117 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn screen_share_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if screen_share_delivery.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = screen_share_delivery.included_user_ids.join("\n");
|
||||
let excluded_user_ids = screen_share_delivery.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Screen Share Delivery",
|
||||
"Pick how many clients publish screen shares through the reworked delivery path. While \
|
||||
the master switch below is off nothing on this form reaches any client: every user \
|
||||
keeps the screen share pipeline they have today, whatever the rest of these fields say. \
|
||||
A client that is already sharing keeps the path it started on until the share ends.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_screen_share_delivery"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (screen_share_delivery.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"screen_share_delivery_enabled",
|
||||
"true",
|
||||
"Serve screen share delivery assignments to clients",
|
||||
screen_share_delivery.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
feature is inert and keeps its current behavior, so the rollout and \
|
||||
targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"screen_share_delivery_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&screen_share_delivery.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"screen_share_delivery_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&screen_share_delivery.rollout_salt,
|
||||
SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"screen_share_delivery_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
screen_share_delivery.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"screen_share_delivery_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
screen_share_delivery.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Screen Share Delivery Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -1184,7 +1297,7 @@ fn experiment_delivery_section(
|
||||
section_card_with_description(
|
||||
"Experiment Delivery",
|
||||
"How often every client revalidates its experiment assignments. This is instance-wide \
|
||||
and covers every experiment, not just the one above. Raising the interval sheds \
|
||||
and covers every experiment, not just the ones above. Raising the interval sheds \
|
||||
request volume and makes a change take longer to reach a client. Raising the jitter \
|
||||
spreads a fleet that has synchronised on one tick back out across the interval.",
|
||||
html! {
|
||||
@@ -1819,10 +1932,29 @@ mod tests {
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_share_delivery_section_shows_list_counts_and_the_master_switch() {
|
||||
let screen_share_delivery = ScreenShareDeliveryConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..ScreenShareDeliveryConfigResponse::default()
|
||||
};
|
||||
let markup =
|
||||
screen_share_delivery_section("/admin", "csrf", &screen_share_delivery).into_string();
|
||||
assert!(markup.contains("action=update_screen_share_delivery"));
|
||||
assert!(markup.contains("screen_share_delivery_enabled"));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
|
||||
@@ -409,6 +409,15 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "screen-share-delivery-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"future_delivery_knob": 9,
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -538,6 +547,14 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
|
||||
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
|
||||
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
|
||||
assert!(resp.screen_share_delivery.enabled);
|
||||
assert_eq!(resp.screen_share_delivery.config_version, 2);
|
||||
assert_eq!(resp.screen_share_delivery.rollout_basis_points, 2500);
|
||||
assert_eq!(
|
||||
*resp.screen_share_delivery.rollout_salt,
|
||||
"screen-share-delivery-v1"
|
||||
);
|
||||
assert_eq!(resp.screen_share_delivery.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
@@ -548,6 +565,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
.replace("\"future_rollout_knob\": 3,", "")
|
||||
.replace("\"future_presentation_knob\": \"verbose\",", "")
|
||||
.replace("\"future_knob\": 7,", "")
|
||||
.replace("\"future_delivery_knob\": 9,", "")
|
||||
.replace("\"future_object_knob\": {\"nested\": true},", "")
|
||||
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
|
||||
.replace(
|
||||
|
||||
@@ -465,6 +465,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_screen_share_delivery",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -1196,6 +1197,14 @@ fn instance_config() -> Value {
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "screen-share-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
"poll_jitter_percent": 15
|
||||
|
||||
@@ -5,7 +5,7 @@ FROM node:26-trixie-slim AS base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN npm install -g pnpm@12.4.2
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
FROM base AS deploy
|
||||
|
||||
@@ -57,7 +57,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libvips42t64 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g pnpm@12.4.2
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
COPY --from=deploy /out ./
|
||||
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
|
||||
|
||||
@@ -94,5 +94,5 @@
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
"packageManager": "pnpm@11.27.0"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ interface PostgresIpInfoOptions {
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
|
||||
return options.client ?? options.getClient?.() ?? null;
|
||||
@@ -34,12 +36,9 @@ async function upsertKvRow(
|
||||
partitionKey: string,
|
||||
key: string,
|
||||
row: Record<string, unknown>,
|
||||
ttlSeconds?: number,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
const expiresAt =
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0
|
||||
? new Date(Date.now() + ttlSeconds * 1000)
|
||||
: null;
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
||||
await client.query(
|
||||
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
@@ -77,7 +76,14 @@ export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInf
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
await upsertKvRow(client, 'ipinfo_cache', rowKey([key]), rowKey([key]), {cache_key: key, payload}, ttlSeconds);
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_cache',
|
||||
rowKey([key]),
|
||||
rowKey([key]),
|
||||
{cache_key: key, payload},
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_set');
|
||||
}
|
||||
@@ -124,6 +130,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_request_audit_record');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {Pool, PoolClient, QueryResult, QueryResultRow} from 'pg';
|
||||
import type {Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow} from 'pg';
|
||||
import pg from 'pg';
|
||||
|
||||
const MAX_DIAGNOSTIC_FIELD_LENGTH = 128;
|
||||
@@ -131,7 +131,7 @@ class PostgresClient implements IPostgresClient {
|
||||
}
|
||||
|
||||
private async openPool(): Promise<void> {
|
||||
const pool = new pg.Pool({
|
||||
const poolConfig: PoolConfig & {scramMaxIterations: number} = {
|
||||
connectionString: this.config.url || undefined,
|
||||
host: this.config.url ? undefined : (this.config.host ?? '127.0.0.1'),
|
||||
port: this.config.url ? undefined : (this.config.port ?? 5432),
|
||||
@@ -140,7 +140,9 @@ class PostgresClient implements IPostgresClient {
|
||||
password: this.config.url ? undefined : (this.config.password ?? 'fluxer'),
|
||||
ssl: this.config.ssl ? {rejectUnauthorized: true, ca: normalizePem(this.config.sslCa)} : undefined,
|
||||
max: this.config.maxConnections ?? 20,
|
||||
});
|
||||
scramMaxIterations: 0,
|
||||
};
|
||||
const pool = new pg.Pool(poolConfig);
|
||||
this.observePoolConnections(pool);
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -341,6 +341,7 @@ import {
|
||||
type UsersPendingDeletionRow,
|
||||
} from '@app/api/database/types/UserTypes';
|
||||
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
export const Users = defineTable<UserRow, 'user_id'>({
|
||||
name: 'users',
|
||||
@@ -499,16 +500,19 @@ export const GuildAuditLogs = defineTable<GuildAuditLogRow, 'guild_id' | 'log_id
|
||||
name: 'guild_audit_logs_v2',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByUser = defineTable<GuildAuditLogRow, 'guild_id' | 'user_id' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_user',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByAction = defineTable<GuildAuditLogRow, 'guild_id' | 'action_type' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'action_type', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByUserAction = defineTable<
|
||||
GuildAuditLogRow,
|
||||
@@ -517,6 +521,7 @@ export const GuildAuditLogsByUserAction = defineTable<
|
||||
name: 'guild_audit_logs_v2_by_user_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'action_type', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildMembershipMetadata = defineTable<GuildMembershipMetadataRow, 'guild_id' | 'user_id'>({
|
||||
name: 'guild_membership_metadata',
|
||||
@@ -655,6 +660,7 @@ export const RecentMentions = defineTable<RecentMentionRow, 'user_id' | 'message
|
||||
name: 'recent_mentions',
|
||||
columns: RECENT_MENTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'message_id'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
|
||||
interface RecentMentionsByGuildRow {
|
||||
@@ -678,6 +684,7 @@ export const RecentMentionsByGuild = defineTable<RecentMentionsByGuildRow, 'user
|
||||
name: 'recent_mentions_by_guild',
|
||||
columns: RECENT_MENTIONS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['user_id', 'guild_id', 'message_id'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_id'>({
|
||||
name: 'saved_messages',
|
||||
@@ -688,6 +695,7 @@ export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 's
|
||||
name: 'push_subscriptions',
|
||||
columns: PUSH_SUBSCRIPTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'subscription_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const Payments = defineTable<PaymentRow, 'checkout_session_id'>({
|
||||
name: 'payments',
|
||||
@@ -854,11 +862,13 @@ export const EmailVerificationTokens = defineTable<EmailVerificationTokenRow, 't
|
||||
name: 'email_verification_tokens',
|
||||
columns: EMAIL_VERIFICATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const PasswordResetTokens = defineTable<PasswordResetTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'password_reset_tokens',
|
||||
columns: PASSWORD_RESET_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const PasswordResetTokensByUserId = defineTable<
|
||||
{
|
||||
@@ -870,16 +880,19 @@ export const PasswordResetTokensByUserId = defineTable<
|
||||
name: 'password_reset_tokens_by_user_id',
|
||||
columns: ['user_id', 'token_'],
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'email_revert_tokens',
|
||||
columns: EMAIL_REVERT_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('48 hours'),
|
||||
});
|
||||
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
|
||||
name: 'phone_tokens',
|
||||
columns: PHONE_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
|
||||
name: 'auth_sessions',
|
||||
@@ -901,11 +914,13 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
|
||||
name: 'auth_session_tombstones',
|
||||
columns: AUTH_SESSION_TOMBSTONE_COLUMNS,
|
||||
primaryKey: ['user_id', 'session_id_hash'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
|
||||
name: 'user_country_history',
|
||||
columns: USER_COUNTRY_HISTORY_COLUMNS,
|
||||
primaryKey: ['user_id', 'country'],
|
||||
defaultTtlSeconds: seconds('365 days'),
|
||||
});
|
||||
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
|
||||
name: 'mfa_backup_codes',
|
||||
@@ -932,6 +947,7 @@ export const IpAuthorizationTokens = defineTable<IpAuthorizationTokenRow, 'token
|
||||
name: 'ip_authorization_tokens',
|
||||
columns: IP_AUTHORIZATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('30 minutes'),
|
||||
});
|
||||
export const AuthorizedIps = defineTable<AuthorizedIpRow, 'user_id' | 'ip'>({
|
||||
name: 'authorized_ips_v2',
|
||||
@@ -1057,26 +1073,31 @@ export const OAuth2AuthorizationCodes = defineTable<OAuth2AuthorizationCodeRow,
|
||||
name: 'oauth2_authorization_codes',
|
||||
columns: OAUTH2_AUTHORIZATION_CODE_COLUMNS,
|
||||
primaryKey: ['code'],
|
||||
defaultTtlSeconds: seconds('10 minutes'),
|
||||
});
|
||||
export const OAuth2AccessTokens = defineTable<OAuth2AccessTokenRow, 'token_'>({
|
||||
name: 'oauth2_access_tokens',
|
||||
columns: OAUTH2_ACCESS_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const OAuth2AccessTokensByUser = defineTable<OAuth2AccessTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_access_tokens_by_user',
|
||||
columns: OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const OAuth2RefreshTokens = defineTable<OAuth2RefreshTokenRow, 'token_'>({
|
||||
name: 'oauth2_refresh_tokens',
|
||||
columns: OAUTH2_REFRESH_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const OAuth2RefreshTokensByUser = defineTable<OAuth2RefreshTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_refresh_tokens_by_user',
|
||||
columns: OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
|
||||
interface WebhooksByChannelRow {
|
||||
@@ -1117,12 +1138,14 @@ export const JobsById = defineTable<JobByIdRow, 'job_id'>({
|
||||
name: 'jobs_by_id',
|
||||
columns: JOB_BY_ID_COLUMNS,
|
||||
primaryKey: ['job_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const JobsByDayBucket = defineTable<JobByDayBucketRow, 'bucket_day' | 'created_at' | 'job_id'>({
|
||||
name: 'jobs_by_day_bucket',
|
||||
columns: JOB_BY_DAY_BUCKET_COLUMNS,
|
||||
primaryKey: ['bucket_day', 'created_at', 'job_id'],
|
||||
partitionKey: ['bucket_day'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const JobsActive = defineTable<JobActiveRow, 'job_id'>({
|
||||
name: 'jobs_active',
|
||||
@@ -1133,11 +1156,13 @@ export const AttachmentUploadTracesByKey = defineTable<AttachmentUploadTraceByKe
|
||||
name: 'attachment_upload_traces_by_key',
|
||||
columns: ATTACHMENT_UPLOAD_TRACE_BY_KEY_COLUMNS,
|
||||
primaryKey: ['upload_key'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const AttachmentUploadTracesByAttachment = defineTable<AttachmentUploadTraceByAttachmentRow, 'attachment_id'>({
|
||||
name: 'attachment_upload_traces_by_attachment',
|
||||
columns: ATTACHMENT_UPLOAD_TRACE_BY_ATTACHMENT_COLUMNS,
|
||||
primaryKey: ['attachment_id'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const NcmecAttachmentSubmissions = defineTable<NcmecAttachmentSubmissionRow, 'attachment_id'>({
|
||||
name: 'ncmec_attachment_submissions',
|
||||
@@ -1154,6 +1179,7 @@ export const RegistrationEventsByIp = defineTable<RegistrationEventByIpRow, 'ip'
|
||||
columns: REGISTRATION_EVENT_BY_IP_COLUMNS,
|
||||
primaryKey: ['ip', 'created_at', 'user_id'],
|
||||
partitionKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsBySubnet = defineTable<
|
||||
RegistrationEventBySubnetRow,
|
||||
@@ -1164,6 +1190,7 @@ export const RegistrationEventsBySubnet = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
|
||||
primaryKey: ['subnet', 'created_at', 'user_id'],
|
||||
partitionKey: ['subnet'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsByEmailDomain = defineTable<
|
||||
RegistrationEventByEmailDomainRow,
|
||||
@@ -1174,6 +1201,7 @@ export const RegistrationEventsByEmailDomain = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
|
||||
primaryKey: ['email_domain', 'created_at', 'user_id'],
|
||||
partitionKey: ['email_domain'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsByPlusAddressBase = defineTable<
|
||||
RegistrationEventByPlusAddressBaseRow,
|
||||
@@ -1184,6 +1212,7 @@ export const RegistrationEventsByPlusAddressBase = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
|
||||
primaryKey: ['plus_address_base', 'created_at', 'user_id'],
|
||||
partitionKey: ['plus_address_base'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const LatestRiskContextByUser = defineTable<LatestRiskContextByUserRow, 'user_id'>({
|
||||
name: 'latest_risk_context_by_user',
|
||||
@@ -1194,6 +1223,7 @@ export const SuspiciousIps = defineTable<SuspiciousIpRow, 'ip'>({
|
||||
name: 'suspicious_ips',
|
||||
columns: SUSPICIOUS_IP_COLUMNS,
|
||||
primaryKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_at' | 'user_id' | 'outcome_code', 'ip'>(
|
||||
{
|
||||
@@ -1201,6 +1231,7 @@ export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_
|
||||
columns: RISK_OUTCOME_BY_IP_COLUMNS,
|
||||
primaryKey: ['ip', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
},
|
||||
);
|
||||
export const RiskOutcomesBySubnet = defineTable<
|
||||
@@ -1212,6 +1243,7 @@ export const RiskOutcomesBySubnet = defineTable<
|
||||
columns: RISK_OUTCOME_BY_SUBNET_COLUMNS,
|
||||
primaryKey: ['subnet', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['subnet'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByEmailDomain = defineTable<
|
||||
RiskOutcomeByEmailDomainRow,
|
||||
@@ -1222,6 +1254,7 @@ export const RiskOutcomesByEmailDomain = defineTable<
|
||||
columns: RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
|
||||
primaryKey: ['email_domain', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['email_domain'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByAsn = defineTable<
|
||||
RiskOutcomeByAsnRow,
|
||||
@@ -1232,6 +1265,7 @@ export const RiskOutcomesByAsn = defineTable<
|
||||
columns: RISK_OUTCOME_BY_ASN_COLUMNS,
|
||||
primaryKey: ['asn', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['asn'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskAssessments = defineTable<RiskAssessmentRow, 'assessment_id'>({
|
||||
name: 'risk_assessments',
|
||||
@@ -1248,6 +1282,7 @@ export const InboundSmsChallenges = defineTable<InboundSmsChallengeRow, 'challen
|
||||
name: 'inbound_sms_challenges',
|
||||
columns: INBOUND_SMS_CHALLENGE_COLUMNS,
|
||||
primaryKey: ['challenge_code'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
export const InboundSmsChallengesByUser = defineTable<
|
||||
InboundSmsChallengeByUserRow,
|
||||
@@ -1258,16 +1293,19 @@ export const InboundSmsChallengesByUser = defineTable<
|
||||
columns: INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'created_at'],
|
||||
partitionKey: ['user_id'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
|
||||
name: 'phone_lookup_cache',
|
||||
columns: PHONE_LOOKUP_CACHE_COLUMNS,
|
||||
primaryKey: ['phone'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
|
||||
name: 'phone_verification_attempts',
|
||||
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
|
||||
primaryKey: ['attempt_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
|
||||
name: 'billing_customers',
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
|
||||
import {
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {
|
||||
AdminAuditLogRow,
|
||||
BannedAvatarHashRow,
|
||||
@@ -282,6 +290,7 @@ export class AdminRepository implements IAdminRepository {
|
||||
}
|
||||
|
||||
async isEmailDomainDisposable(domain: string): Promise<boolean> {
|
||||
if (!Config.blocklistFeeds.enabled) return false;
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
const result = await fetchOne<{
|
||||
@@ -395,6 +404,15 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()}));
|
||||
}
|
||||
|
||||
async unbanFeedFileSha(sha256Hex: string): Promise<boolean> {
|
||||
return executeConditional(
|
||||
BannedFileShas.conditionalDeleteByPk(
|
||||
{sha256_hex: sha256Hex.toLowerCase()},
|
||||
{added_by: null, category: ContentBlocklistCategory.MALWARE_BAZAAR},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> {
|
||||
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract unbanFileSha(sha256Hex: string): Promise<void>;
|
||||
|
||||
abstract unbanFeedFileSha(sha256Hex: string): Promise<boolean>;
|
||||
|
||||
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
|
||||
|
||||
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -59,6 +60,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
screenShareDelivery,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -67,6 +69,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getScreenShareDeliveryConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -98,6 +101,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
screen_share_delivery: screenShareDelivery,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -261,6 +265,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.screen_share_delivery) {
|
||||
const patch = omitUndefinedFields(data.screen_share_delivery);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentScreenShareDelivery = await instanceConfigRepository.getScreenShareDeliveryConfig();
|
||||
const validated = ScreenShareDeliveryConfigSchema.parse({
|
||||
...currentScreenShareDelivery,
|
||||
...patch,
|
||||
config_version: currentScreenShareDelivery.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setScreenShareDeliveryConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
|
||||
@@ -150,7 +150,7 @@ function serializeGroupDMChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
name: channel.name ?? undefined,
|
||||
name: channel.name ?? null,
|
||||
icon: channel.iconHash ?? null,
|
||||
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
|
||||
nicks: nicknameMap.size > 0 ? nicks : undefined,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearch
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {serializeChannelForAudit} from '@app/api/utils/AuditSerializationUtils';
|
||||
import {applyProtectedOverwriteBits} from '@app/api/utils/featureUtils';
|
||||
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
|
||||
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionAvailability} from '@app/api/voice/VoiceModel';
|
||||
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
|
||||
@@ -208,25 +209,6 @@ export class ChannelOperationsService {
|
||||
userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
if (!isOwner) {
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const allowPerms = (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS;
|
||||
if ((allowPerms & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
const nextDeny = new Map<RoleID | UserID, bigint>();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
|
||||
}
|
||||
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
|
||||
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
|
||||
if ((removedDeny & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
permissionOverwrites = new Map();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
@@ -251,6 +233,18 @@ export class ChannelOperationsService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!isOwner) {
|
||||
const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]);
|
||||
for (const targetId of targetIds) {
|
||||
const grantedBits = overwriteGrantedBits(
|
||||
previousPermissionOverwrites?.get(targetId),
|
||||
permissionOverwrites.get(targetId),
|
||||
);
|
||||
if ((grantedBits & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const requestedParentId =
|
||||
data.parent_id !== undefined ? (data.parent_id ? createChannelID(data.parent_id) : null) : channel.parentId;
|
||||
@@ -646,9 +640,8 @@ export class ChannelOperationsService {
|
||||
const sanitizedAllow = protectedBits.allow;
|
||||
const sanitizedDeny = protectedBits.deny;
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
|
||||
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const grantedBits = overwriteGrantedBits(existing, {allow: sanitizedAllow, deny: sanitizedDeny});
|
||||
if (!hasAdministrator && (grantedBits & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
const nextOverwrite = new ChannelPermissionOverwrite({
|
||||
type: params.overwrite.type,
|
||||
|
||||
@@ -294,6 +294,48 @@ describe('Channel Permission Overwrites', () => {
|
||||
expect(overwrite?.allow).toBe(Permissions.VIEW_CHANNEL.toString());
|
||||
expect(overwrite?.deny).toBe(Permissions.MANAGE_MESSAGES.toString());
|
||||
});
|
||||
test('should let an editor change an overwrite that already allows a permission they lack', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const manager = members[0];
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Queue Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
const botRole = await createRole(harness, owner.token, guild.id, {name: 'Bot'});
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, botRole.id, {
|
||||
type: 0,
|
||||
allow: Permissions.PIN_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
await createBuilder(harness, manager.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${botRole.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: (Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES).toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const updated = await getChannel(harness, owner.token, systemChannel.id);
|
||||
const botOverwrite = updated.permission_overwrites?.find((o) => o.id === botRole.id);
|
||||
expect(botOverwrite?.allow).toBe((Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES).toString());
|
||||
});
|
||||
test('should reject an editor granting a permission they lack', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const manager = members[0];
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Queue Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
const botRole = await createRole(harness, owner.token, guild.id, {name: 'Bot'});
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
await createBuilder(harness, manager.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${botRole.id}`)
|
||||
.body({type: 0, allow: Permissions.PIN_MESSAGES.toString(), deny: '0'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('should propagate category permission patches only to children that were synced when the category changed', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {name: 'Readers'});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createFriendship, createGroupDmChannel, getChannel} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('Group DM name clear', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['null', null],
|
||||
])('sends a null name to every recipient when cleared with %s', async (_label, clearedName) => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
await createBuilder<ChannelResponse>(harness, user1.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({name: 'Weekend plans'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
try {
|
||||
const cleared = await createBuilder<ChannelResponse>(harness, user1.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({name: clearedName})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared).toHaveProperty('name', null);
|
||||
const channelUpdates = dispatchSpy.mock.calls.filter(([params]) => params.event === 'CHANNEL_UPDATE');
|
||||
expect(channelUpdates.map(([params]) => params.userId.toString()).sort()).toEqual(
|
||||
[user1.userId, user2.userId, user3.userId].sort(),
|
||||
);
|
||||
for (const [params] of channelUpdates) {
|
||||
expect(params.data).toHaveProperty('name', null);
|
||||
}
|
||||
} finally {
|
||||
dispatchSpy.mockRestore();
|
||||
}
|
||||
expect(await getChannel(harness, user2.token, groupDm.id)).toHaveProperty('name', null);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {BannedFileShaRow} from '@app/api/database/types/AdminArchiveTypes';
|
||||
|
||||
export const BANNED_URLS_REFRESH_CHANNEL = 'banned_urls_refresh';
|
||||
export const BANNED_URL_DOMAINS_REFRESH_CHANNEL = 'banned_url_domains_refresh';
|
||||
export const BANNED_FILE_SHAS_REFRESH_CHANNEL = 'banned_file_shas_refresh';
|
||||
@@ -23,3 +25,7 @@ export const ContentBlocklistCategory = {
|
||||
GIFCT: 'gifct',
|
||||
STOP_NCII: 'stop_ncii',
|
||||
} as const;
|
||||
|
||||
export function isBlocklistFeedFileSha(row: Pick<BannedFileShaRow, 'category' | 'added_by'>): boolean {
|
||||
return row.added_by == null && row.category === ContentBlocklistCategory.MALWARE_BAZAAR;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {DEFAULT_TTL_TABLES} from '@app/api/database/PostgresKvDefaultTtlExpiry';
|
||||
import * as DonationTables from '@app/api/donation/DonationTables';
|
||||
import * as Tables from '@app/api/Tables';
|
||||
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(THIS_DIR, '../../../..');
|
||||
|
||||
interface SchemaTable {
|
||||
name: string;
|
||||
options: string;
|
||||
}
|
||||
|
||||
const SCHEMA = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'tools/dev/cassandra_target_schema.json'), 'utf8')) as {
|
||||
tables: Array<SchemaTable>;
|
||||
};
|
||||
|
||||
const SCHEMA_DEFAULTS = new Map<string, number>(
|
||||
SCHEMA.tables.flatMap((table): Array<[string, number]> => {
|
||||
const match = /default_time_to_live = (\d+)/.exec(table.options);
|
||||
return match ? [[table.name, Number(match[1])]] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
const DSL_TABLES = [...Object.values(Tables), ...Object.values(DonationTables)];
|
||||
const DSL_NAMES = new Set<string>(DSL_TABLES.map((table) => table.name));
|
||||
|
||||
const NON_DSL_DEFAULTS: Record<string, number | null> = {
|
||||
ipinfo_cache: IPINFO_CACHE_TTL_SECONDS,
|
||||
ipinfo_requests_by_hour: IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
billing_webhook_events: null,
|
||||
forensic_identifier_by_key_day: null,
|
||||
forensic_identifier_by_request: null,
|
||||
forensic_request_meta_by_actor_day: null,
|
||||
forensic_request_meta_by_id: null,
|
||||
forensic_request_meta_by_route_day_shard: null,
|
||||
forensic_resource_exposure_by_request: null,
|
||||
forensic_resource_exposure_by_route_day_shard: null,
|
||||
forensic_resource_exposure_by_subject_day: null,
|
||||
};
|
||||
|
||||
const OWN_EXPIRY_PASS = new Set(['jobs_by_id', 'jobs_by_day_bucket']);
|
||||
|
||||
function schemaDefault(name: string): number {
|
||||
return SCHEMA_DEFAULTS.get(name) ?? 0;
|
||||
}
|
||||
|
||||
function byName(left: {name: string}, right: {name: string}): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
describe('Cassandra default TTL parity', () => {
|
||||
it('declares every Cassandra default TTL on the matching table', () => {
|
||||
const mismatches = DSL_TABLES.flatMap((table) => {
|
||||
const declared = table.defaultTtlSeconds ?? 0;
|
||||
return declared === schemaDefault(table.name)
|
||||
? []
|
||||
: [{table: table.name, declared, schema: schemaDefault(table.name)}];
|
||||
});
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
|
||||
it('declares a writer or no writer for every other table with a default', () => {
|
||||
const undeclared = [...SCHEMA_DEFAULTS]
|
||||
.filter(([name, ttl]) => ttl > 0 && !DSL_NAMES.has(name) && !Object.hasOwn(NON_DSL_DEFAULTS, name))
|
||||
.map(([name]) => name);
|
||||
expect(undeclared).toEqual([]);
|
||||
const stale = Object.keys(NON_DSL_DEFAULTS).filter((name) => schemaDefault(name) === 0 || DSL_NAMES.has(name));
|
||||
expect(stale).toEqual([]);
|
||||
const mismatched = Object.entries(NON_DSL_DEFAULTS)
|
||||
.filter(([name, ttl]) => ttl !== null && ttl !== schemaDefault(name))
|
||||
.map(([name]) => name);
|
||||
expect(mismatched).toEqual([]);
|
||||
});
|
||||
|
||||
it('the Postgres expiry pass covers every table with a default except the job ledger', () => {
|
||||
const expected = [...SCHEMA_DEFAULTS]
|
||||
.filter(([name, ttl]) => ttl > 0 && NON_DSL_DEFAULTS[name] !== null && !OWN_EXPIRY_PASS.has(name))
|
||||
.map(([name, ttl]) => ({name, defaultTtlSeconds: ttl}))
|
||||
.sort(byName);
|
||||
expect([...DEFAULT_TTL_TABLES].sort(byName)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ interface TableMetadata {
|
||||
columns: ReadonlyArray<string>;
|
||||
primaryKey: ReadonlyArray<string>;
|
||||
partitionKey: ReadonlyArray<string>;
|
||||
defaultTtlSeconds?: number;
|
||||
}
|
||||
|
||||
const kvMetaRegistry = new Map<string, KvQueryMeta<Record<string, unknown>>>();
|
||||
@@ -24,6 +25,7 @@ export function registerTableSpec<Row extends object>(tableSpec: KvTableSpec<Row
|
||||
columns: tableSpec.columns as ReadonlyArray<string>,
|
||||
primaryKey: tableSpec.primaryKey as ReadonlyArray<string>,
|
||||
partitionKey: tableSpec.partitionKey as ReadonlyArray<string>,
|
||||
defaultTtlSeconds: tableSpec.defaultTtlSeconds,
|
||||
};
|
||||
tableRegistry.set(tableSpec.name, metadata);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getTableMetadata} from '@app/api/database/CassandraMetaRegistry';
|
||||
import {defineTable} from '@app/api/database/CassandraTableDsl';
|
||||
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
@@ -76,3 +77,41 @@ describe('CassandraTableDsl select templates', () => {
|
||||
expect(longQuery.cql).not.toContain('LIMIT 20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CassandraTableDsl default TTL', () => {
|
||||
it('keeps the CQL of a table with a default TTL free of USING TTL', () => {
|
||||
const DefaultTtlRows = defineTable<TtlHelperTestRow, 'id'>({
|
||||
name: 'default_ttl_dsl_rows',
|
||||
columns: ['id', 'value'],
|
||||
primaryKey: ['id'],
|
||||
defaultTtlSeconds: 600,
|
||||
});
|
||||
expect(DefaultTtlRows.defaultTtlSeconds).toBe(600);
|
||||
const queries = [
|
||||
DefaultTtlRows.insert({id: 'insert', value: 'a'}),
|
||||
DefaultTtlRows.upsertAll({id: 'upsert', value: 'b'}),
|
||||
DefaultTtlRows.patchByPk({id: 'patch'}, {value: Db.set('c')}),
|
||||
];
|
||||
for (const query of queries) {
|
||||
expect(query.cql).not.toContain('USING TTL');
|
||||
expect(query.kvMeta?.table.defaultTtlSeconds).toBe(600);
|
||||
}
|
||||
expect(getTableMetadata('default_ttl_dsl_rows')?.defaultTtlSeconds).toBe(600);
|
||||
expect(TtlHelperTestRows.defaultTtlSeconds).toBeUndefined();
|
||||
expect(getTableMetadata('ttl_helper_test_rows')?.defaultTtlSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a default TTL of zero, a fraction or past the maximum', () => {
|
||||
for (const defaultTtlSeconds of [0, 1.5, 630_720_001]) {
|
||||
expect(() =>
|
||||
defineTable<TtlHelperTestRow, 'id'>({
|
||||
name: 'default_ttl_dsl_rejected_rows',
|
||||
columns: ['id', 'value'],
|
||||
primaryKey: ['id'],
|
||||
defaultTtlSeconds,
|
||||
}),
|
||||
).toThrow();
|
||||
}
|
||||
expect(getTableMetadata('default_ttl_dsl_rejected_rows')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ export function defineTable<Row extends object, PK extends ColumnName<Row>, Part
|
||||
columns: ReadonlyArray<ColumnName<Row>>;
|
||||
primaryKey: ReadonlyArray<PK>;
|
||||
partitionKey?: ReadonlyArray<PartKey>;
|
||||
defaultTtlSeconds?: number;
|
||||
}): Table<Row, PK, PartKey> {
|
||||
const columns = [...def.columns];
|
||||
const pk = [...def.primaryKey];
|
||||
@@ -91,11 +92,15 @@ export function defineTable<Row extends object, PK extends ColumnName<Row>, Part
|
||||
for (const c of columns) assertCqlIdentifier(c as string);
|
||||
for (const k of pk) assertCqlIdentifier(k as string);
|
||||
for (const k of partitionKey) assertCqlIdentifier(k as string);
|
||||
if (def.defaultTtlSeconds !== undefined && validateTtlSeconds(def.defaultTtlSeconds) === 0) {
|
||||
throw new Error(`Table "${def.name}" needs a positive default TTL`);
|
||||
}
|
||||
const tableSpec: KvTableSpec<Row> = {
|
||||
name: def.name,
|
||||
columns,
|
||||
primaryKey: pk as ReadonlyArray<ColumnName<Row>>,
|
||||
partitionKey: partitionKey as ReadonlyArray<ColumnName<Row>>,
|
||||
defaultTtlSeconds: def.defaultTtlSeconds,
|
||||
};
|
||||
registerTableSpec(tableSpec);
|
||||
const nonPkColumns = columns.filter((c) => !pk.includes(c as PK)) as Array<Exclude<ColumnName<Row>, PK>>;
|
||||
@@ -685,6 +690,7 @@ WHERE ${pk.map((k) => `${k} = :${k}`).join(' AND ')};
|
||||
columns: def.columns,
|
||||
primaryKey: def.primaryKey,
|
||||
partitionKey: partitionKey,
|
||||
defaultTtlSeconds: def.defaultTtlSeconds,
|
||||
selectCql,
|
||||
select,
|
||||
updateAllCql() {
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface KvTableSpec<Row extends object = Record<string, unknown>> {
|
||||
columns: ReadonlyArray<ColumnName<Row>>;
|
||||
primaryKey: ReadonlyArray<ColumnName<Row>>;
|
||||
partitionKey: ReadonlyArray<ColumnName<Row>>;
|
||||
defaultTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface KvColumnParam<Row extends object = Record<string, unknown>> {
|
||||
@@ -190,6 +191,7 @@ export interface Table<Row extends object, PK extends ColumnName<Row>, PartKey e
|
||||
columns: ReadonlyArray<ColumnName<Row>>;
|
||||
primaryKey: ReadonlyArray<PK>;
|
||||
partitionKey: ReadonlyArray<PartKey>;
|
||||
defaultTtlSeconds: number | undefined;
|
||||
selectCql(opts?: {
|
||||
columns?: ReadonlyArray<ColumnName<Row>>;
|
||||
where?: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>>;
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {defineTable} from '@app/api/database/CassandraTableDsl';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import {
|
||||
DEFAULT_TTL_EXPIRY_RESUME,
|
||||
DEFAULT_TTL_TABLES,
|
||||
expireLegacyDefaultTtlRows,
|
||||
} from '@app/api/database/PostgresKvDefaultTtlExpiry';
|
||||
import {
|
||||
ensurePostgresKvSchema,
|
||||
PostgresKvQueryExecutor,
|
||||
pruneExpiredPostgresKvRows,
|
||||
} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const KV_TABLE = 'kv_default_ttl';
|
||||
const CONTAINER = `fluxer-kvttl-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
const DEFAULT_TTL_SECONDS = 600;
|
||||
|
||||
interface ProbeRow {
|
||||
id: string;
|
||||
value: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface OwnedProbeRow {
|
||||
owner: string;
|
||||
id: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const DefaultTtlProbe = defineTable<ProbeRow, 'id'>({
|
||||
name: 'default_ttl_probe',
|
||||
columns: ['id', 'value', 'note'],
|
||||
primaryKey: ['id'],
|
||||
defaultTtlSeconds: DEFAULT_TTL_SECONDS,
|
||||
});
|
||||
|
||||
const DefaultTtlProbeRows = defineTable<OwnedProbeRow, 'owner' | 'id', 'owner'>({
|
||||
name: 'default_ttl_probe_rows',
|
||||
columns: ['owner', 'id', 'value'],
|
||||
primaryKey: ['owner', 'id'],
|
||||
partitionKey: ['owner'],
|
||||
defaultTtlSeconds: DEFAULT_TTL_SECONDS,
|
||||
});
|
||||
|
||||
const NoTtlProbe = defineTable<ProbeRow, 'id'>({
|
||||
name: 'no_ttl_probe',
|
||||
columns: ['id', 'value', 'note'],
|
||||
primaryKey: ['id'],
|
||||
});
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function expectExpiresIn(value: Date | number | null, ttlSeconds: number): void {
|
||||
expect(value).toBeInstanceOf(Date);
|
||||
const remainingSeconds = ((value as Date).getTime() - Date.now()) / 1000;
|
||||
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 60);
|
||||
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('Postgres KV default TTL', () => {
|
||||
let raw: IPostgresClient;
|
||||
let executor: PostgresKvQueryExecutor;
|
||||
|
||||
async function stored(table: string, id: string): Promise<{expires_at: Date | number | null; row_data: object}> {
|
||||
const result = await raw.query<{expires_at: Date | number | null; row_data: object}>(
|
||||
`SELECT expires_at, row_data FROM ${KV_TABLE} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
|
||||
[table, id],
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
async function expiresAt(table: string, id: string): Promise<Date | number | null> {
|
||||
return (await stored(table, id)).expires_at;
|
||||
}
|
||||
|
||||
async function neverExpires(table: string, id: string): Promise<boolean> {
|
||||
const result = await raw.query<{forever: boolean}>(
|
||||
`SELECT expires_at = 'infinity'::timestamptz AS forever FROM ${KV_TABLE} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
|
||||
[table, id],
|
||||
);
|
||||
return result.rows[0]?.forever === true;
|
||||
}
|
||||
|
||||
async function setExpiry(table: string, id: string, expression: string): Promise<void> {
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET expires_at = ${expression} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
|
||||
[table, id],
|
||||
);
|
||||
}
|
||||
|
||||
async function seed(table: string, key: string, age: string, expires: Date | string | null = null): Promise<string> {
|
||||
const result = await raw.query<{updated_at: string}>(
|
||||
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $2, '{}'::jsonb, $3::timestamptz, now() - $4::interval)
|
||||
RETURNING updated_at::text`,
|
||||
[table, key, expires, age],
|
||||
);
|
||||
return result.rows[0]!.updated_at;
|
||||
}
|
||||
|
||||
async function remaining(): Promise<Array<{table_name: string; row_key: string}>> {
|
||||
const result = await raw.query<{table_name: string; row_key: string}>(
|
||||
`SELECT table_name, row_key FROM ${KV_TABLE} WHERE table_name <> '__fluxer_schema_migrations' ORDER BY table_name, row_key`,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function ageMarker(): Promise<void> {
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('applied_at', now() - interval '2 days') WHERE table_name = '__fluxer_schema_migrations' AND row_key = 'default_ttl_expiry_v1'`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resumePoint(): Promise<object | null> {
|
||||
const result = await raw.query<{row_data: object}>(
|
||||
`SELECT row_data FROM ${KV_TABLE} WHERE table_name = '__fluxer_schema_migrations' AND row_key = $1`,
|
||||
[DEFAULT_TTL_EXPIRY_RESUME],
|
||||
);
|
||||
return result.rows[0]?.row_data ?? null;
|
||||
}
|
||||
|
||||
async function markerCount(): Promise<number> {
|
||||
const result = await raw.query<{n: number}>(
|
||||
`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = '__fluxer_schema_migrations' AND row_key = 'default_ttl_expiry_v1'`,
|
||||
);
|
||||
return result.rows[0]!.n;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
startDockerContainer([
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
'postgres:16-alpine',
|
||||
'-c',
|
||||
'fsync=off',
|
||||
]);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({
|
||||
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
|
||||
maxConnections: 4,
|
||||
kvTable: KV_TABLE,
|
||||
});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
raw = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(raw);
|
||||
executor = new PostgresKvQueryExecutor(raw);
|
||||
}, 900_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await raw.query(`DELETE FROM ${KV_TABLE}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('gives every full-row write without a TTL the table default', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'insert', value: 'a', note: null}));
|
||||
await executor.executeQuery(DefaultTtlProbe.upsertAll({id: 'upsert', value: 'b', note: 'n'}));
|
||||
expect(
|
||||
await executor.executeQuery(DefaultTtlProbe.insertIfNotExists({id: 'claimed', value: 'c', note: null})),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbeRows.conditionalBatch([{action: 'insert', row: {owner: 'o', id: 'batched', value: 'd'}}]),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
|
||||
for (const id of ['insert', 'upsert', 'claimed']) {
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', id), DEFAULT_TTL_SECONDS);
|
||||
}
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'batched'), DEFAULT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('keeps an explicit TTL ahead of the default', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'short', value: 'a', note: null}, 60));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'short'), 60);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'patched', value: 'a', note: null}));
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPkWithTtl({id: 'patched'}, {value: Db.set('b')}, 60));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'patched'), 60);
|
||||
});
|
||||
|
||||
it('keeps an explicit TTL of zero as no expiry', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'forever', value: 'a', note: null}, 0));
|
||||
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbe.select({where: DefaultTtlProbe.where.eq('id')}).bind({id: 'forever'}),
|
||||
),
|
||||
).toEqual([{id: 'forever', value: 'a', note: null}]);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'forever'}, {note: Db.set('patched')}));
|
||||
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
|
||||
|
||||
await pruneExpiredPostgresKvRows(raw);
|
||||
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
|
||||
});
|
||||
|
||||
it('raises a patched row to the default but never lowers it', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'longer', value: 'a', note: null}, 3600));
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'longer'}, {note: Db.set('patched')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'longer'), 3600);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'soon', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'soon', "now() + interval '5 seconds'");
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'soon'}, {note: Db.set('patched')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'soon'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'missing'}, {note: Db.set('created')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'missing'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'unset', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'unset', 'NULL');
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'unset'}, {note: Db.set('patched')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'unset'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'expired', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'expired', "now() - interval '1 second'");
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'expired'}, {note: Db.set('patched')}));
|
||||
const revived = await stored('default_ttl_probe', 'expired');
|
||||
expect(revived.row_data).toEqual({id: 'expired', note: 'patched'});
|
||||
expectExpiresIn(revived.expires_at, DEFAULT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('raises conditional patches the same way', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'soon', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'soon', "now() + interval '5 seconds'");
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbe.conditionalPatchByPk({id: 'soon'}, {note: Db.set('patched')}, {value: 'a'}),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'soon'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'longer', value: 'a', note: null}, 3600));
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbe.conditionalPatchByPk({id: 'longer'}, {note: Db.set('patched')}, {value: 'a'}),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'longer'), 3600);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbeRows.insert({owner: 'o', id: 'existing', value: 'old'}));
|
||||
await setExpiry('default_ttl_probe_rows', 'existing', 'NULL');
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbeRows.conditionalBatch([
|
||||
{action: 'insert', row: {owner: 'o', id: 'added', value: 'new'}},
|
||||
{
|
||||
action: 'patch',
|
||||
pk: {owner: 'o', id: 'existing'},
|
||||
patch: {value: Db.set('updated')},
|
||||
expected: {value: 'old'},
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'added'), DEFAULT_TTL_SECONDS);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'existing'), DEFAULT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('leaves tables without a default untouched', async () => {
|
||||
await executor.executeQuery(NoTtlProbe.insert({id: 'plain', value: 'a', note: null}));
|
||||
expect(await expiresAt('no_ttl_probe', 'plain')).toBeNull();
|
||||
await executor.executeQuery(NoTtlProbe.patchByPk({id: 'plain'}, {note: Db.set('patched')}));
|
||||
expect(await expiresAt('no_ttl_probe', 'plain')).toBeNull();
|
||||
await executor.executeQuery(NoTtlProbe.insertWithTtl({id: 'zero', value: 'a', note: null}, 0));
|
||||
expect(await expiresAt('no_ttl_probe', 'zero')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives rows an older image wrote the expiry of their last write and deletes the ones past it', async () => {
|
||||
const mentionWrittenAt = await seed('recent_mentions', 'rm-day', '1 day');
|
||||
await seed('recent_mentions', 'rm-week', '8 days');
|
||||
await seed('attachment_upload_traces_by_key', 'at-31', '31 days');
|
||||
await seed('attachment_upload_traces_by_key', 'at-29', '29 days');
|
||||
await seed('phone_lookup_cache', 'pl-8', '8 days');
|
||||
await seed('donor_magic_link_tokens', 'dm-hour', '1 hour');
|
||||
await seed('ipinfo_requests_by_hour', 'ip-day', '1 day');
|
||||
await seed('jobs_by_id', 'job', '100 days');
|
||||
await seed('users', 'user', '100 days');
|
||||
await seed('recent_mentions', 'rm-forever', '1 day', 'infinity');
|
||||
await seed('recent_mentions', 'rm-hour', '30 days', new Date(Date.now() + 3_600_000));
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 4,
|
||||
expiring: 3,
|
||||
complete: true,
|
||||
});
|
||||
expect(await remaining()).toEqual([
|
||||
{table_name: 'attachment_upload_traces_by_key', row_key: 'at-29'},
|
||||
{table_name: 'ipinfo_requests_by_hour', row_key: 'ip-day'},
|
||||
{table_name: 'jobs_by_id', row_key: 'job'},
|
||||
{table_name: 'recent_mentions', row_key: 'rm-day'},
|
||||
{table_name: 'recent_mentions', row_key: 'rm-forever'},
|
||||
{table_name: 'recent_mentions', row_key: 'rm-hour'},
|
||||
{table_name: 'users', row_key: 'user'},
|
||||
]);
|
||||
|
||||
const exact = await raw.query<{row_key: string; exact: boolean; unchanged: boolean | null}>(
|
||||
`SELECT row_key,
|
||||
expires_at = updated_at + CASE table_name WHEN 'recent_mentions' THEN interval '7 days' WHEN 'attachment_upload_traces_by_key' THEN interval '30 days' ELSE interval '90 days' END AS exact,
|
||||
CASE WHEN row_key = 'rm-day' THEN updated_at = $1::timestamptz END AS unchanged
|
||||
FROM ${KV_TABLE}
|
||||
WHERE row_key IN ('rm-day', 'at-29', 'ip-day')
|
||||
ORDER BY row_key`,
|
||||
[mentionWrittenAt],
|
||||
);
|
||||
expect(exact.rows).toEqual([
|
||||
{row_key: 'at-29', exact: true, unchanged: null},
|
||||
{row_key: 'ip-day', exact: true, unchanged: null},
|
||||
{row_key: 'rm-day', exact: true, unchanged: true},
|
||||
]);
|
||||
const untouched = await raw.query<{row_key: string; state: string}>(
|
||||
`SELECT row_key, CASE WHEN expires_at IS NULL THEN 'unset' WHEN expires_at = 'infinity' THEN 'forever' ELSE 'set' END AS state
|
||||
FROM ${KV_TABLE}
|
||||
WHERE row_key IN ('job', 'user', 'rm-forever', 'rm-hour')
|
||||
ORDER BY row_key`,
|
||||
);
|
||||
expect(untouched.rows).toEqual([
|
||||
{row_key: 'job', state: 'unset'},
|
||||
{row_key: 'rm-forever', state: 'forever'},
|
||||
{row_key: 'rm-hour', state: 'set'},
|
||||
{row_key: 'user', state: 'unset'},
|
||||
]);
|
||||
|
||||
expect(await markerCount()).toBe(0);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await markerCount()).toBe(1);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('checks again a day after a clean pass', async () => {
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
|
||||
await seed('recent_mentions', 'rm-rolled-back', '1 day');
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
const before = await raw.query(`SELECT expires_at FROM ${KV_TABLE} WHERE row_key = 'rm-rolled-back'`);
|
||||
expect(before.rows).toEqual([{expires_at: null}]);
|
||||
|
||||
await ageMarker();
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 1,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('pages through more rows than one page holds and stops at its deadline', async () => {
|
||||
await raw.query(
|
||||
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data, updated_at)
|
||||
SELECT 'recent_mentions', 'rm-' || lpad(g::text, 5, '0'), 'rm-' || lpad(g::text, 5, '0'), '{}'::jsonb, now() - interval '1 day'
|
||||
FROM generate_series(1, 2300) g`,
|
||||
);
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: false,
|
||||
});
|
||||
expect(await markerCount()).toBe(0);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 2300,
|
||||
complete: true,
|
||||
});
|
||||
const unset = await raw.query<{n: number}>(
|
||||
`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = 'recent_mentions' AND expires_at IS NULL`,
|
||||
);
|
||||
expect(unset.rows[0]).toEqual({n: 0});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('saves where a run stopped and starts the next run there', async () => {
|
||||
const first = DEFAULT_TTL_TABLES[0]!.name;
|
||||
const last = DEFAULT_TTL_TABLES.at(-1)!.name;
|
||||
await seed(first, 'a', '1 hour');
|
||||
await seed(first, 'z', '1 hour');
|
||||
await seed(last, 'k', '1 hour');
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({deleted: 0, expiring: 0, complete: false});
|
||||
expect(await resumePoint()).toEqual({table: first, row_key: '', unset: 0});
|
||||
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('table', $1::text, 'row_key', 'm', 'unset', 0) WHERE table_name = '__fluxer_schema_migrations' AND row_key = $2`,
|
||||
[first, DEFAULT_TTL_EXPIRY_RESUME],
|
||||
);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 2,
|
||||
complete: true,
|
||||
});
|
||||
const untouched = await raw.query<{expires_at: Date | null}>(
|
||||
`SELECT expires_at FROM ${KV_TABLE} WHERE table_name = $1 AND row_key = 'a'`,
|
||||
[first],
|
||||
);
|
||||
expect(untouched.rows).toEqual([{expires_at: null}]);
|
||||
expect(await resumePoint()).toBeNull();
|
||||
expect(await markerCount()).toBe(0);
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 1,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await markerCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
POSTGRES_KV_MIGRATION_TABLE,
|
||||
postgresKvPassIsFresh,
|
||||
recordPostgresKvCleanPass,
|
||||
} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import * as DonationTables from '@app/api/donation/DonationTables';
|
||||
import * as Tables from '@app/api/Tables';
|
||||
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const DEFAULT_TTL_EXPIRY_MARKER = 'default_ttl_expiry_v1';
|
||||
export const DEFAULT_TTL_EXPIRY_RESUME = 'default_ttl_expiry_v1_resume';
|
||||
const PAGE_SIZE = 2000;
|
||||
const CLEAN_PASS_INTERVAL_MS = ms('1 day');
|
||||
const OWN_EXPIRY_PASS = new Set<string>([Tables.JobsById.name, Tables.JobsByDayBucket.name]);
|
||||
|
||||
export const DEFAULT_TTL_TABLES: ReadonlyArray<{name: string; defaultTtlSeconds: number}> = [
|
||||
...[...Object.values(Tables), ...Object.values(DonationTables)].flatMap((table) =>
|
||||
table.defaultTtlSeconds === undefined || OWN_EXPIRY_PASS.has(table.name)
|
||||
? []
|
||||
: [{name: table.name, defaultTtlSeconds: table.defaultTtlSeconds}],
|
||||
),
|
||||
{name: 'ipinfo_cache', defaultTtlSeconds: IPINFO_CACHE_TTL_SECONDS},
|
||||
{name: 'ipinfo_requests_by_hour', defaultTtlSeconds: IPINFO_REQUEST_AUDIT_TTL_SECONDS},
|
||||
];
|
||||
|
||||
export interface LegacyDefaultTtlExpiryResult {
|
||||
deleted: number;
|
||||
expiring: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
interface ResumePoint {
|
||||
table: string;
|
||||
rowKey: string;
|
||||
unset: number;
|
||||
}
|
||||
|
||||
async function readResumePoint(client: IPostgresClient, kvTable: string): Promise<ResumePoint | null> {
|
||||
const result = await client.query<{row_data: Record<string, unknown>}>(
|
||||
`SELECT row_data FROM ${kvTable} WHERE table_name = $1 AND row_key = $2`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, DEFAULT_TTL_EXPIRY_RESUME],
|
||||
);
|
||||
const data = result.rows[0]?.row_data;
|
||||
if (typeof data?.table !== 'string' || typeof data.row_key !== 'string' || typeof data.unset !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return {table: data.table, rowKey: data.row_key, unset: data.unset};
|
||||
}
|
||||
|
||||
async function writeResumePoint(client: IPostgresClient, kvTable: string, point: ResumePoint | null): Promise<void> {
|
||||
if (point === null) {
|
||||
await client.query(`DELETE FROM ${kvTable} WHERE table_name = $1 AND row_key = $2`, [
|
||||
POSTGRES_KV_MIGRATION_TABLE,
|
||||
DEFAULT_TTL_EXPIRY_RESUME,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO ${kvTable} (table_name, partition_key, row_key, row_data)
|
||||
VALUES ($1, $2, $2, jsonb_build_object('table', $3::text, 'row_key', $4::text, 'unset', $5::bigint))
|
||||
ON CONFLICT (table_name, row_key) DO UPDATE SET row_data = EXCLUDED.row_data, updated_at = now()`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, DEFAULT_TTL_EXPIRY_RESUME, point.table, point.rowKey, point.unset],
|
||||
);
|
||||
}
|
||||
|
||||
function pageSql(table: string): string {
|
||||
return `
|
||||
WITH page AS (
|
||||
SELECT kv.row_key, kv.expires_at IS NULL AS unset
|
||||
FROM ${table} kv
|
||||
WHERE kv.table_name = $1 AND kv.row_key > $2
|
||||
ORDER BY kv.row_key
|
||||
LIMIT $3
|
||||
), removed AS (
|
||||
DELETE FROM ${table} kv
|
||||
USING page
|
||||
WHERE kv.table_name = $1 AND kv.row_key = page.row_key AND kv.expires_at IS NULL
|
||||
AND kv.updated_at + make_interval(secs => $4::double precision) <= now()
|
||||
RETURNING 1
|
||||
), expiring AS (
|
||||
UPDATE ${table} kv
|
||||
SET expires_at = kv.updated_at + make_interval(secs => $4::double precision)
|
||||
FROM page
|
||||
WHERE kv.table_name = $1 AND kv.row_key = page.row_key AND kv.expires_at IS NULL
|
||||
AND kv.updated_at + make_interval(secs => $4::double precision) > now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT
|
||||
(SELECT max(row_key) FROM page) AS last_row_key,
|
||||
(SELECT count(*) FROM page WHERE unset) AS unset,
|
||||
(SELECT count(*) FROM removed) AS deleted,
|
||||
(SELECT count(*) FROM expiring) AS expiring`;
|
||||
}
|
||||
|
||||
export async function expireLegacyDefaultTtlRows(
|
||||
client: IPostgresClient,
|
||||
deadlineMs: number,
|
||||
): Promise<LegacyDefaultTtlExpiryResult | null> {
|
||||
if (await postgresKvPassIsFresh(client, DEFAULT_TTL_EXPIRY_MARKER, CLEAN_PASS_INTERVAL_MS)) {
|
||||
return null;
|
||||
}
|
||||
const kvTable = quoteIdentifier(client.kvTable());
|
||||
const sql = pageSql(kvTable);
|
||||
const resume = await readResumePoint(client, kvTable);
|
||||
const resumeIndex = resume === null ? -1 : DEFAULT_TTL_TABLES.findIndex((target) => target.name === resume.table);
|
||||
let unset = resumeIndex < 0 ? 0 : resume!.unset;
|
||||
let deleted = 0;
|
||||
let expiring = 0;
|
||||
for (let index = Math.max(resumeIndex, 0); index < DEFAULT_TTL_TABLES.length; index += 1) {
|
||||
const target = DEFAULT_TTL_TABLES[index]!;
|
||||
let cursor = index === resumeIndex ? resume!.rowKey : '';
|
||||
for (;;) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
await writeResumePoint(client, kvTable, {table: target.name, rowKey: cursor, unset});
|
||||
return {deleted, expiring, complete: false};
|
||||
}
|
||||
const result = await client.query<{
|
||||
last_row_key: string | null;
|
||||
unset: string;
|
||||
deleted: string;
|
||||
expiring: string;
|
||||
}>(sql, [target.name, cursor, PAGE_SIZE, target.defaultTtlSeconds]);
|
||||
const page = result.rows[0];
|
||||
if (!page || page.last_row_key === null) {
|
||||
break;
|
||||
}
|
||||
unset += Number(page.unset);
|
||||
deleted += Number(page.deleted);
|
||||
expiring += Number(page.expiring);
|
||||
cursor = page.last_row_key;
|
||||
}
|
||||
}
|
||||
await writeResumePoint(client, kvTable, null);
|
||||
if (unset === 0) {
|
||||
await recordPostgresKvCleanPass(client, DEFAULT_TTL_EXPIRY_MARKER);
|
||||
}
|
||||
return {deleted, expiring, complete: true};
|
||||
}
|
||||
@@ -89,6 +89,28 @@ const NUMERIC_ROW_KEY_NUMBER_PATTERN = '^(-?[0-9]+(?:\\.[0-9]+)?(?:[eE][-+]?[0-9
|
||||
const EXPIRED_STORED_ROW = 'kv.expires_at IS NOT NULL AND kv.expires_at <= now()';
|
||||
const MERGED_ROW_DATA = `CASE WHEN ${EXPIRED_STORED_ROW} THEN EXCLUDED.row_data ELSE kv.row_data || EXCLUDED.row_data END`;
|
||||
const KEPT_EXPIRES_AT = `CASE WHEN ${EXPIRED_STORED_ROW} THEN NULL ELSE kv.expires_at END`;
|
||||
const NO_EXPIRY = 'infinity';
|
||||
|
||||
export async function postgresKvPassIsFresh(
|
||||
client: IPostgresClient,
|
||||
marker: string,
|
||||
maxAgeMs: number,
|
||||
): Promise<boolean> {
|
||||
const result = await client.query(
|
||||
`SELECT 1 FROM ${quoteIdentifier(client.kvTable())} WHERE table_name = $1 AND row_key = $2 AND (row_data ->> 'applied_at')::timestamptz > now() - make_interval(secs => $3::double precision)`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, marker, maxAgeMs / 1000],
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
export async function recordPostgresKvCleanPass(client: IPostgresClient, marker: string): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO ${quoteIdentifier(client.kvTable())} (table_name, partition_key, row_key, row_data)
|
||||
VALUES ($1, $2, $2, jsonb_build_object('applied_at', now()))
|
||||
ON CONFLICT (table_name, row_key) DO UPDATE SET row_data = EXCLUDED.row_data, updated_at = now()`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, marker],
|
||||
);
|
||||
}
|
||||
|
||||
function numericRowKeyExpr(column: string): string {
|
||||
return `(COALESCE(substring(${column} from '${NUMERIC_ROW_KEY_BIGINT_PATTERN}'), substring(${column} from '${NUMERIC_ROW_KEY_NUMBER_PATTERN}'))::numeric)`;
|
||||
@@ -333,20 +355,21 @@ function projectRow(row: Row, columns: ReadonlyArray<string> | undefined): Row {
|
||||
return projected;
|
||||
}
|
||||
|
||||
function rowComparator(meta: KvQueryMeta): (left: Row, right: Row) => number {
|
||||
if (meta.orderBy) {
|
||||
const column = meta.orderBy.col as string;
|
||||
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
|
||||
return (left, right) => compareValues(left[column], right[column]) * direction;
|
||||
function compareColumns(columns: ReadonlyArray<string>, left: Row, right: Row): number {
|
||||
for (const column of columns) {
|
||||
const cmp = compareValues(left[column], right[column]);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
const columns = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
return (left, right) => {
|
||||
for (const column of columns) {
|
||||
const cmp = compareValues(left[column], right[column]);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
return 0;
|
||||
}
|
||||
|
||||
function rowComparator(meta: KvQueryMeta): (left: Row, right: Row) => number {
|
||||
const primaryKey = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
if (!meta.orderBy) return (left, right) => compareColumns(primaryKey, left, right);
|
||||
const column = meta.orderBy.col as string;
|
||||
const columns = [column, ...primaryKey.slice(primaryKey.indexOf(column) + 1)];
|
||||
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
|
||||
return (left, right) => compareColumns(columns, left, right) * direction;
|
||||
}
|
||||
|
||||
function sortRows(meta: KvQueryMeta, rows: Array<Row>): Array<Row> {
|
||||
@@ -679,7 +702,7 @@ function logFullScan(meta: KvQueryMeta): void {
|
||||
logWarn({table: meta.table.name, action: meta.action, where: shape.summary || 'none'}, 'Postgres KV full table scan');
|
||||
}
|
||||
|
||||
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null | undefined {
|
||||
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | typeof NO_EXPIRY | null | undefined {
|
||||
const ttlParam = meta.ttlParamName;
|
||||
if (!ttlParam) return undefined;
|
||||
const ttlRaw = params[ttlParam];
|
||||
@@ -687,7 +710,13 @@ function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null |
|
||||
throw new Error(`TTL parameter ${ttlParam} must be a number`);
|
||||
}
|
||||
const ttlSeconds = validateTtlSeconds(ttlRaw);
|
||||
return ttlSeconds === 0 ? null : new Date(Date.now() + ttlSeconds * 1000);
|
||||
if (ttlSeconds === 0) return meta.table.defaultTtlSeconds === undefined ? null : NO_EXPIRY;
|
||||
return new Date(Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
|
||||
function defaultExpiresAt(meta: KvQueryMeta): Date | undefined {
|
||||
const ttlSeconds = meta.table.defaultTtlSeconds;
|
||||
return ttlSeconds === undefined ? undefined : new Date(Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
|
||||
function encodePageState(pageState: PageState): string {
|
||||
@@ -1191,7 +1220,8 @@ export class PostgresKvQueryExecutor {
|
||||
'kv_del_expired',
|
||||
);
|
||||
}
|
||||
const expiresAt = ttlExpiresAt(meta, params) ?? null;
|
||||
const explicit = ttlExpiresAt(meta, params);
|
||||
const expiresAt = explicit === undefined ? (defaultExpiresAt(meta) ?? null) : explicit;
|
||||
const result = await db.query(
|
||||
`INSERT INTO ${this.table} AS kv (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
@@ -1244,10 +1274,14 @@ WHERE NOT $6`,
|
||||
}
|
||||
bindings.push(JSON.stringify(encodeRow(paramsRow(params, meta.patchKeys))));
|
||||
const assignments = [`row_data = kv.row_data || $${bindings.length}::jsonb`, 'updated_at = now()'];
|
||||
const expiresAt = ttlExpiresAt(meta, params);
|
||||
if (expiresAt !== undefined) {
|
||||
bindings.push(expiresAt);
|
||||
const explicit = ttlExpiresAt(meta, params);
|
||||
const fallback = explicit === undefined ? defaultExpiresAt(meta) : undefined;
|
||||
if (explicit !== undefined) {
|
||||
bindings.push(explicit);
|
||||
assignments.push(`expires_at = $${bindings.length}`);
|
||||
} else if (fallback !== undefined) {
|
||||
bindings.push(fallback);
|
||||
assignments.push(`expires_at = GREATEST(kv.expires_at, $${bindings.length}::timestamptz)`);
|
||||
}
|
||||
sql = `UPDATE ${this.table} kv SET ${assignments.join(', ')} WHERE ${where}`;
|
||||
}
|
||||
@@ -1346,15 +1380,27 @@ WHERE NOT $6`,
|
||||
for (const column of meta.patchKeys ?? []) {
|
||||
incoming[column] = column in params ? params[column] : null;
|
||||
}
|
||||
const ttl = ttlExpiresAt(meta, params);
|
||||
const expiresAtExpr = ttl === undefined ? KEPT_EXPIRES_AT : 'EXCLUDED.expires_at';
|
||||
const explicit = ttlExpiresAt(meta, params);
|
||||
const fallback = explicit === undefined ? defaultExpiresAt(meta) : undefined;
|
||||
const [expiresAtExpr, statementName] =
|
||||
explicit !== undefined
|
||||
? ['EXCLUDED.expires_at', 'kv_patch_set_ttl']
|
||||
: fallback !== undefined
|
||||
? ['GREATEST(kv.expires_at, EXCLUDED.expires_at)', 'kv_patch_default_ttl']
|
||||
: [KEPT_EXPIRES_AT, 'kv_patch_keep_ttl'];
|
||||
await db.query(
|
||||
`INSERT INTO ${this.table} AS kv (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
ON CONFLICT (table_name, row_key)
|
||||
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = ${MERGED_ROW_DATA}, expires_at = ${expiresAtExpr}, updated_at = now()`,
|
||||
[meta.table.name, partitionKey(meta, incoming), key, JSON.stringify(encodeRow(incoming)), ttl ?? null],
|
||||
ttl === undefined ? 'kv_patch_keep_ttl' : 'kv_patch_set_ttl',
|
||||
[
|
||||
meta.table.name,
|
||||
partitionKey(meta, incoming),
|
||||
key,
|
||||
JSON.stringify(encodeRow(incoming)),
|
||||
explicit ?? fallback ?? null,
|
||||
],
|
||||
statementName,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ const Composite: KvTableSpec<Row> = {
|
||||
partitionKey: ['owner_id'],
|
||||
};
|
||||
|
||||
const Expiring: KvTableSpec<Row> = {...Composite, name: 'stmt_expiring', defaultTtlSeconds: 600};
|
||||
|
||||
const Bucketed: KvTableSpec<Row> = {
|
||||
name: 'stmt_bucketed',
|
||||
columns: ['bucket', 'item_id', 'payload'],
|
||||
@@ -118,6 +120,7 @@ async function runShapes(): Promise<Array<Statement>> {
|
||||
meta(Composite, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload'], ttlParamName: 'ttl_'}),
|
||||
{...OWNER_ITEM, ttl_: 600} as CassandraParams,
|
||||
],
|
||||
[meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}), OWNER_ITEM],
|
||||
];
|
||||
for (const [kvMeta, params] of cases) {
|
||||
await executor.executeQuery({cql: `__stmt_${kvMeta.action}`, params, kvMeta: kvMeta as KvQueryMeta});
|
||||
@@ -142,6 +145,7 @@ describe('PostgresKvQueryExecutor statement names', () => {
|
||||
'kv_del_keys',
|
||||
'kv_del_rowkeys',
|
||||
'kv_get_row',
|
||||
'kv_patch_default_ttl',
|
||||
'kv_patch_keep_ttl',
|
||||
'kv_patch_set_ttl',
|
||||
'kv_sel_range',
|
||||
@@ -231,6 +235,17 @@ async function exerciseKvShapes(executor: PostgresKvQueryExecutor): Promise<void
|
||||
kvMeta: meta(Composite, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
||||
});
|
||||
expect(patched.map((row) => row.payload)).toEqual(['patched']);
|
||||
await executor.executeQuery({
|
||||
cql: '__stmt_patch_default_ttl',
|
||||
params: {owner_id: 'o5', item_id: 'i5', payload: 'defaulted'} as CassandraParams,
|
||||
kvMeta: meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}) as KvQueryMeta,
|
||||
});
|
||||
const defaulted = await executor.executeQuery<Row>({
|
||||
cql: '__stmt_point',
|
||||
params: {owner_id: 'o5', item_id: 'i5'} as CassandraParams,
|
||||
kvMeta: meta(Expiring, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
||||
});
|
||||
expect(defaulted.map((row) => row.payload)).toEqual(['defaulted']);
|
||||
await executor.executeQuery({
|
||||
cql: '__stmt_delete',
|
||||
params: {owner_id: 'o0', item_id: 'i0'} as CassandraParams,
|
||||
@@ -323,6 +338,7 @@ describe.skipIf(!dockerAvailable)('PostgresKvQueryExecutor statement names again
|
||||
'kv_del_expired',
|
||||
'kv_del_rowkeys',
|
||||
'kv_get_row',
|
||||
'kv_patch_default_ttl',
|
||||
'kv_patch_keep_ttl',
|
||||
'kv_patch_set_ttl',
|
||||
'kv_sel_range',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
|
||||
const CONTAINER = `fluxer-kvscram-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
const SCRAM_ITERATIONS = 200_000;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('postgres client against a server with raised SCRAM iterations', () => {
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await freePort();
|
||||
startDockerContainer([
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
'postgres:16-alpine',
|
||||
'-c',
|
||||
'fsync=off',
|
||||
'-c',
|
||||
`scram_iterations=${SCRAM_ITERATIONS}`,
|
||||
]);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync(
|
||||
'docker',
|
||||
['exec', CONTAINER, 'psql', '-h', '127.0.0.1', '-U', 'fluxer', '-d', 'fluxer', '-Atc', 'SELECT 1'],
|
||||
{stdio: 'ignore'},
|
||||
);
|
||||
ready = probe.status === 0;
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
const rehash = spawnSync(
|
||||
'docker',
|
||||
['exec', CONTAINER, 'psql', '-U', 'fluxer', '-d', 'fluxer', '-Atc', "ALTER ROLE fluxer PASSWORD 'fluxer'"],
|
||||
{
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
if (rehash.status !== 0) throw new Error('could not re-hash the role password');
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('connects when the role verifier uses more iterations than the driver default allows', async () => {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${port}/fluxer`, maxConnections: 1});
|
||||
const verifier = await getDefaultPostgresClient().query<{rolpassword: string}>(
|
||||
"SELECT rolpassword FROM pg_authid WHERE rolname = 'fluxer'",
|
||||
);
|
||||
expect(verifier.rows[0]?.rolpassword.startsWith(`SCRAM-SHA-256$${SCRAM_ITERATIONS}:`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type DonorMagicLinkTokenRow,
|
||||
type DonorRow,
|
||||
} from '@app/api/database/types/DonationTypes';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
export const Donors = defineTable<DonorRow, 'email'>({
|
||||
name: 'donors',
|
||||
@@ -43,9 +44,11 @@ export const DonorMagicLinkTokens = defineTable<DonorMagicLinkTokenRow, 'token_'
|
||||
name: 'donor_magic_link_tokens',
|
||||
columns: DONOR_MAGIC_LINK_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
export const DonorMagicLinkTokensByEmail = defineTable<DonorMagicLinkTokenByEmailRow, 'donor_email' | 'token_'>({
|
||||
name: 'donor_magic_link_tokens_by_email',
|
||||
columns: DONOR_MAGIC_LINK_TOKEN_BY_EMAIL_COLUMNS,
|
||||
primaryKey: ['donor_email', 'token_'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveScreenShareDeliveryAssignment} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
@@ -28,9 +29,10 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig] = await Promise.all([
|
||||
const [delivery, voiceConfig, screenShareConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getScreenShareDeliveryConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
@@ -38,6 +40,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
screen_share_delivery: resolveScreenShareDeliveryAssignment(screenShareConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -6,6 +6,10 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readScreenShareDeliveryAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -51,6 +56,7 @@ describe('GET /experiments', () => {
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
screen_share_delivery: INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -82,6 +88,52 @@ describe('GET /experiments', () => {
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the screen share assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'screen_share_delivery')).toBe(true);
|
||||
expect(readScreenShareDeliveryAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the screen share caller through the allowlist', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.screen_share_delivery).toEqual({enabled: true});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.screen_share_delivery).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('keeps the screen share exclusion ahead of a full rollout', async () => {
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [excluded.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.assignments.screen_share_delivery).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
@@ -196,6 +248,30 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the screen share config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.assignments.screen_share_delivery).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the delivery config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -252,6 +328,44 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('bumps the screen share config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({screen_share_delivery: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(afterFirst.screen_share_delivery).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({screen_share_delivery: {rollout_salt: 'screen-share-delivery-v2'}})
|
||||
.execute();
|
||||
expect(afterSecond.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
|
||||
|
||||
const afterEmpty = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({screen_share_delivery: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.screen_share_delivery).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('leaves the config version alone for an admin update that sets no field', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
|
||||
@@ -15,6 +15,8 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const MESSAGE_DELETE_BATCH_DELAY_MS = ms('30 seconds');
|
||||
|
||||
interface MessageDeleteBatchGroup {
|
||||
logs: Array<GuildAuditLog>;
|
||||
userId: UserID;
|
||||
@@ -81,14 +83,15 @@ export class GuildAuditLogService {
|
||||
}
|
||||
|
||||
async scheduleMessageDeleteBatchJob(guildId: GuildID): Promise<void> {
|
||||
const runAt = new Date(Date.now() + ms('30 seconds'));
|
||||
const batchWindow = Math.floor(Date.now() / MESSAGE_DELETE_BATCH_DELAY_MS);
|
||||
await this.workerService.addJob(
|
||||
'batchGuildAuditLogMessageDeletes',
|
||||
{guildId: guildId.toString()},
|
||||
{
|
||||
jobKey: `batch-audit-log-message-deletes:${guildId}`,
|
||||
runAt,
|
||||
jobKey: `batch-audit-log-message-deletes:${guildId}:${batchWindow}`,
|
||||
runAt: new Date((batchWindow + 2) * MESSAGE_DELETE_BATCH_DELAY_MS),
|
||||
maxAttempts: 3,
|
||||
skipLedger: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ function createService(roleNames: Map<string, string> = new Map()) {
|
||||
{addJob} as unknown as IWorkerService<WorkerTaskName>,
|
||||
{dispatchGuild} as unknown as IGatewayService,
|
||||
);
|
||||
return {service, createAuditLog, batchDeleteAndCreateAuditLogs, getRole, dispatchGuild};
|
||||
return {service, createAuditLog, batchDeleteAndCreateAuditLogs, getRole, dispatchGuild, addJob};
|
||||
}
|
||||
|
||||
function overwrites(
|
||||
@@ -376,3 +376,26 @@ describe('GuildAuditLogService.recordPermissionOverwriteDiff', () => {
|
||||
expect(dispatchGuild).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuildAuditLogService.scheduleMessageDeleteBatchJob', () => {
|
||||
it('gives every delete in one 30 second window a single batch job that runs after the window closes, without a ledger row', async () => {
|
||||
vi.useFakeTimers({toFake: ['Date']});
|
||||
try {
|
||||
const {service, addJob} = createService();
|
||||
for (const at of ['2026-09-21T12:00:00.000Z', '2026-09-21T12:00:29.999Z', '2026-09-21T12:00:40.000Z']) {
|
||||
vi.setSystemTime(new Date(at));
|
||||
await service.scheduleMessageDeleteBatchJob(GUILD_ID);
|
||||
}
|
||||
const options = addJob.mock.calls.map((call) => call[2] as {jobKey: string; runAt: Date; skipLedger: boolean});
|
||||
expect(options.every((option) => option.skipLedger)).toBe(true);
|
||||
expect(options[0]!.jobKey).toBe(options[1]!.jobKey);
|
||||
expect(options[2]!.jobKey).not.toBe(options[1]!.jobKey);
|
||||
expect(options[0]!.runAt.getTime()).toBeGreaterThan(new Date('2026-09-21T12:00:29.999Z').getTime());
|
||||
expect(options[1]!.runAt).toEqual(options[0]!.runAt);
|
||||
expect(options[2]!.runAt).toEqual(new Date('2026-09-21T12:01:30.000Z'));
|
||||
expect(options[2]!.runAt.getTime() - options[0]!.runAt.getTime()).toBe(30_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {
|
||||
DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
type ScreenShareDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
@@ -330,6 +335,70 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default screen share delivery config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'unparseable text', stored: 'not-json'},
|
||||
{name: 'a json array', stored: '[]'},
|
||||
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
|
||||
{name: 'a non-boolean enabled flag', stored: '{"enabled":"yes"}'},
|
||||
])('falls back to the default screen share delivery config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored screen share delivery config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: ScreenShareDeliveryConfig = {
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'screen-share-delivery-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
};
|
||||
await repository.setScreenShareDeliveryConfig(config);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added screen share delivery fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
SCREEN_SHARE_DELIVERY_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
|
||||
);
|
||||
|
||||
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default experiment delivery config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
@@ -402,6 +471,26 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a refresh so another repository observes the screen share delivery config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
|
||||
await expect(reader.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
|
||||
|
||||
await writer.setScreenShareDeliveryConfig({
|
||||
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await reader.getScreenShareDeliveryConfig()).toMatchObject({enabled: true, config_version: 1});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the registration URL id as the admin-visible registration code', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
type GatewayRolloutConfig,
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
type ScreenShareDeliveryConfig,
|
||||
ScreenShareDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
|
||||
import {
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
@@ -54,6 +58,7 @@ import {z} from 'zod';
|
||||
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -339,6 +344,7 @@ type StoredConfigSection =
|
||||
| 'app public'
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'screen share delivery'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -477,6 +483,10 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
|
||||
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
|
||||
}
|
||||
|
||||
function parseStoredScreenShareDeliveryConfig(raw: string | null): ScreenShareDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ScreenShareDeliveryConfigSchema, raw, 'screen share delivery');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -1004,6 +1014,7 @@ export class InstanceConfigRepository {
|
||||
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredScreenShareDeliveryConfig(snapshot.get(SCREEN_SHARE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1085,6 +1096,16 @@ export class InstanceConfigRepository {
|
||||
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(validated));
|
||||
}
|
||||
|
||||
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
|
||||
const raw = await this.getConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredScreenShareDeliveryConfig(raw);
|
||||
}
|
||||
|
||||
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(ScreenShareDeliveryConfigSchema, config, 'screen share delivery');
|
||||
await this.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
@@ -32,10 +32,12 @@ export interface ListJobsResult {
|
||||
}
|
||||
|
||||
export abstract class IJobLedgerRepository {
|
||||
abstract createJob(input: CreateJobInput): Promise<void>;
|
||||
abstract createJob(input: CreateJobInput): Promise<Date>;
|
||||
|
||||
abstract getJob(jobId: bigint): Promise<JobByIdRow | null>;
|
||||
|
||||
abstract discardJob(jobId: bigint, createdAt: Date): Promise<void>;
|
||||
|
||||
abstract markRunning(jobId: bigint, lane: string): Promise<void>;
|
||||
|
||||
abstract markSucceeded(jobId: bigint, result: Record<string, unknown> | null): Promise<void>;
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
type CassandraQueryExecutorForTesting,
|
||||
executeQuery,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
setCassandraQueryExecutorForTesting,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import type {JobStatus} from '@app/api/database/types/JobLedgerTypes';
|
||||
import {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
|
||||
import {
|
||||
EXPIRED_JOB_ERROR,
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
JOB_STALE_AFTER_MS,
|
||||
JobLedgerRepository,
|
||||
} from '@app/api/jobs/JobLedgerRepository';
|
||||
import {describeListJobsPaging} from '@app/api/jobs/ListJobsPagingSuite';
|
||||
import {JobsActive, JobsByDayBucket, JobsById} from '@app/api/Tables';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {createSnowflake} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
let executor: InMemoryCassandraQueryExecutor;
|
||||
@@ -22,6 +37,20 @@ async function createJob(repository: JobLedgerRepository, jobId: bigint, taskTyp
|
||||
});
|
||||
}
|
||||
|
||||
async function createJobAt(repository: JobLedgerRepository, jobId: bigint): Promise<Date> {
|
||||
return repository.createJob({
|
||||
jobId,
|
||||
taskType: 'batchGuildAuditLogMessageDeletes',
|
||||
payload: {},
|
||||
requestedByUserId: null,
|
||||
auditLogReason: null,
|
||||
maxAttempts: 3,
|
||||
runAt: null,
|
||||
jetStreamLane: null,
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function listJobIdsByStatus(repository: JobLedgerRepository, status: JobStatus): Promise<Array<bigint>> {
|
||||
const result = await repository.listJobs({limit: 50, cursor: null, filters: {status}, maxLookbackDays: 1});
|
||||
return result.jobs.map((job) => job.job_id);
|
||||
@@ -130,6 +159,197 @@ describe('JobLedgerRepository listJobs pagination', () => {
|
||||
maxLookbackDays: 1,
|
||||
});
|
||||
|
||||
expect(result.jobs.map((job) => job.job_id)).toEqual([1_000n, 1_001n, 1_002n, 1_003n, 1_004n]);
|
||||
expect(result.jobs.map((job) => job.job_id)).toEqual([1_004n, 1_003n, 1_002n, 1_001n, 1_000n]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JobLedgerRepository listJobs on the in-memory executor', () => {
|
||||
beforeEach(() => {
|
||||
executor = new InMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
executor.reset();
|
||||
setCassandraQueryExecutorForTesting(null);
|
||||
});
|
||||
|
||||
describeListJobsPaging();
|
||||
});
|
||||
|
||||
let staleSequence = 0;
|
||||
|
||||
function jobIdAgedDays(days: number): bigint {
|
||||
staleSequence += 1;
|
||||
return createSnowflake({timestamp: Date.now() - days * 86_400_000, sequence: staleSequence % 4096, workerId: 1});
|
||||
}
|
||||
|
||||
async function createAgedJob(repository: JobLedgerRepository, days: number): Promise<bigint> {
|
||||
const jobId = jobIdAgedDays(days);
|
||||
await createJob(repository, jobId, 'syncUrlBlocklists');
|
||||
return jobId;
|
||||
}
|
||||
|
||||
async function activeJobIds(repository: JobLedgerRepository): Promise<Array<bigint>> {
|
||||
return (await repository.listActiveJobs()).map((job) => job.job_id).sort((a, b) => (a < b ? -1 : 1));
|
||||
}
|
||||
|
||||
function sweep(repository: JobLedgerRepository, maxCleared = 100) {
|
||||
return repository.expireStaleActiveJobs({
|
||||
staleBeforeMs: Date.now() - JOB_STALE_AFTER_MS,
|
||||
pageSize: 100,
|
||||
maxCleared,
|
||||
});
|
||||
}
|
||||
|
||||
describe('JobLedgerRepository expireStaleActiveJobs', () => {
|
||||
beforeEach(() => {
|
||||
executor = new InMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
executor.reset();
|
||||
setCassandraQueryExecutorForTesting(null);
|
||||
});
|
||||
|
||||
it('dead-letters queued and running jobs the jobs stream has outlived and keeps younger ones active', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const staleQueued = await createAgedJob(repository, 9);
|
||||
const staleRunning = await createAgedJob(repository, 9);
|
||||
await repository.markRunning(staleRunning, 'batch');
|
||||
const weekOld = await createAgedJob(repository, 7);
|
||||
const fresh = await createAgedJob(repository, 0);
|
||||
|
||||
expect(await sweep(repository)).toEqual({cleared: 2, expired: 2, complete: true});
|
||||
|
||||
for (const jobId of [staleQueued, staleRunning]) {
|
||||
const job = await repository.getJob(jobId);
|
||||
expect(job?.status).toBe('deadletter');
|
||||
expect(job?.error_message).toBe(EXPIRED_JOB_ERROR);
|
||||
expect(job?.completed_at).toBeInstanceOf(Date);
|
||||
}
|
||||
expect(await activeJobIds(repository)).toEqual([weekOld, fresh].sort((a, b) => (a < b ? -1 : 1)));
|
||||
expect((await repository.getJob(weekOld))?.status).toBe('queued');
|
||||
});
|
||||
|
||||
it('drops a stale active row without touching a finished or missing job', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const finished = await createAgedJob(repository, 9);
|
||||
await repository.markSucceeded(finished, null);
|
||||
const orphan = jobIdAgedDays(9);
|
||||
for (const jobId of [finished, orphan]) {
|
||||
await executeQuery(
|
||||
JobsActive.patchByPkWithTtl({job_id: jobId}, {status: Db.set('running')}, JOB_LEDGER_TTL_SECONDS),
|
||||
);
|
||||
}
|
||||
|
||||
expect(await sweep(repository)).toEqual({cleared: 2, expired: 0, complete: true});
|
||||
|
||||
const job = await repository.getJob(finished);
|
||||
expect(job?.status).toBe('succeeded');
|
||||
expect(job?.error_message).toBeNull();
|
||||
expect(await fetchOne(JobsById.select({where: JobsById.where.eq('job_id')}).bind({job_id: orphan}))).toBeNull();
|
||||
expect(await fetchMany(JobsActive.select().bind({}))).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops at its per-run cap and picks up the rest on the next run', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await createAgedJob(repository, 9);
|
||||
}
|
||||
|
||||
expect(await sweep(repository, 2)).toEqual({cleared: 2, expired: 2, complete: false});
|
||||
expect(await activeJobIds(repository)).toHaveLength(1);
|
||||
expect(await sweep(repository, 2)).toEqual({cleared: 1, expired: 1, complete: true});
|
||||
expect(await activeJobIds(repository)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JobLedgerRepository getJob', () => {
|
||||
beforeEach(() => {
|
||||
executor = new InMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
executor.reset();
|
||||
setCassandraQueryExecutorForTesting(null);
|
||||
});
|
||||
|
||||
it('hides a job row that lost its status, creation time or task type', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
await createJob(repository, 9n, 'syncUrlBlocklists');
|
||||
expect((await repository.getJob(9n))?.status).toBe('queued');
|
||||
for (const column of ['status', 'created_at', 'task_type'] as const) {
|
||||
await createJob(repository, 9n, 'syncUrlBlocklists');
|
||||
await executeQuery(JobsById.patchByPk({job_id: 9n}, {[column]: Db.clear()}));
|
||||
expect(await repository.getJob(9n)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('JobLedgerRepository discardJob', () => {
|
||||
let inner: InMemoryCassandraQueryExecutor;
|
||||
let log: Array<string>;
|
||||
let failDeleteOn: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
inner = new InMemoryCassandraQueryExecutor();
|
||||
log = [];
|
||||
failDeleteOn = null;
|
||||
const wrapper: CassandraQueryExecutorForTesting = {
|
||||
async executeQuery<T>(query: PreparedQuery) {
|
||||
const meta = query.kvMeta;
|
||||
if (meta) log.push(`${meta.action} ${meta.table.name}`);
|
||||
if (meta?.action === 'delete' && meta.table.name === failDeleteOn) {
|
||||
throw new Error('write timeout');
|
||||
}
|
||||
return inner.executeQuery<T>(query);
|
||||
},
|
||||
executeBatch: (queries, atomic) => inner.executeBatch(queries, atomic),
|
||||
};
|
||||
setCassandraQueryExecutorForTesting(wrapper);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
inner.reset();
|
||||
setCassandraQueryExecutorForTesting(null);
|
||||
});
|
||||
|
||||
it('removes every ledger row of a duplicate with three deletes and no read', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const createdAt = await createJobAt(repository, 7n);
|
||||
log.length = 0;
|
||||
|
||||
await repository.discardJob(7n, createdAt);
|
||||
|
||||
expect([...log].sort()).toEqual(['delete jobs_active', 'delete jobs_by_day_bucket', 'delete jobs_by_id']);
|
||||
expect(await repository.getJob(7n)).toBeNull();
|
||||
expect(await repository.listActiveJobs()).toEqual([]);
|
||||
expect(
|
||||
await fetchMany(
|
||||
JobsByDayBucket.select({where: JobsByDayBucket.where.eq('bucket_day')}).bind({
|
||||
bucket_day: createdAt.toISOString().slice(0, 10),
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps deleting the other ledger rows when one delete fails', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const createdAt = await createJobAt(repository, 8n);
|
||||
failDeleteOn = 'jobs_by_id';
|
||||
|
||||
await expect(repository.discardJob(8n, createdAt)).rejects.toThrow('write timeout');
|
||||
|
||||
expect(await repository.listActiveJobs()).toEqual([]);
|
||||
expect(
|
||||
await fetchMany(
|
||||
JobsByDayBucket.select({where: JobsByDayBucket.where.eq('bucket_day')}).bind({
|
||||
bucket_day: createdAt.toISOString().slice(0, 10),
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
BatchBuilder,
|
||||
deleteOneOrMany,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
fetchPage,
|
||||
type PagedQueryResult,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import type {JobActiveRow, JobByDayBucketRow, JobByIdRow, JobStatus} from '@app/api/database/types/JobLedgerTypes';
|
||||
import {
|
||||
@@ -11,6 +19,17 @@ import {
|
||||
type ListJobsResult,
|
||||
} from '@app/api/jobs/IJobLedgerRepository';
|
||||
import {JobsActive, JobsByDayBucket, JobsById} from '@app/api/Tables';
|
||||
import {awaitAll} from '@app/api/utils/ConcurrencyUtils';
|
||||
import {JOBS_STREAM_MAX_AGE_MS} from '@app/api/worker/JetStreamWorkerQueue';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
export const JOB_LEDGER_TTL_SECONDS = seconds('90 days');
|
||||
export const JOB_STALE_AFTER_MS = JOBS_STREAM_MAX_AGE_MS + ms('1 day');
|
||||
export const EXPIRED_JOB_ERROR = 'Expired from the job queue';
|
||||
|
||||
const JOB_LEDGER_RETENTION_DAYS = JOB_LEDGER_TTL_SECONDS / seconds('1 day');
|
||||
const NEWEST_FIRST = {col: 'created_at', direction: 'DESC'} as const;
|
||||
|
||||
const FETCH_JOB_BY_ID_QUERY = JobsById.select({
|
||||
where: JobsById.where.eq('job_id'),
|
||||
@@ -19,13 +38,58 @@ const FETCH_CANCEL_REQUESTED_QUERY = JobsById.select({
|
||||
where: JobsById.where.eq('job_id'),
|
||||
});
|
||||
const ACTIVE_JOBS_QUERY = JobsActive.select();
|
||||
const ACTIVE_JOB_IDS_QUERY = JobsActive.select({columns: ['job_id']});
|
||||
const JOBS_AFTER_IN_TIE_QUERY = JobsByDayBucket.select({
|
||||
where: [
|
||||
JobsByDayBucket.where.eq('bucket_day'),
|
||||
JobsByDayBucket.where.eq('created_at'),
|
||||
JobsByDayBucket.where.lt('job_id'),
|
||||
],
|
||||
orderBy: NEWEST_FIRST,
|
||||
});
|
||||
|
||||
type LedgerPosition = Pick<ListJobsCursor, 'createdAt' | 'jobId'>;
|
||||
|
||||
function bucketDayFor(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function previousBucketDay(day: string): string {
|
||||
const date = new Date(`${day}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() - 1);
|
||||
return bucketDayFor(date);
|
||||
}
|
||||
|
||||
function dayJobsQuery(olderThanPosition: boolean, limit: number | null) {
|
||||
return JobsByDayBucket.select({
|
||||
where: olderThanPosition
|
||||
? [JobsByDayBucket.where.eq('bucket_day'), JobsByDayBucket.where.lt('created_at')]
|
||||
: JobsByDayBucket.where.eq('bucket_day'),
|
||||
orderBy: NEWEST_FIRST,
|
||||
...(limit === null ? {} : {limit}),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDayAfter(
|
||||
bucketDay: string,
|
||||
after: LedgerPosition | null,
|
||||
limit: number | null,
|
||||
): Promise<{rows: Array<JobByDayBucketRow>; exhausted: boolean}> {
|
||||
if (after === null) {
|
||||
const rows = await fetchMany<JobByDayBucketRow>(dayJobsQuery(false, limit).bind({bucket_day: bucketDay}));
|
||||
return {rows, exhausted: limit === null || rows.length < limit};
|
||||
}
|
||||
const ties = await fetchMany<JobByDayBucketRow>(
|
||||
JOBS_AFTER_IN_TIE_QUERY.bind({bucket_day: bucketDay, created_at: after.createdAt, job_id: after.jobId}),
|
||||
);
|
||||
const older = await fetchMany<JobByDayBucketRow>(
|
||||
dayJobsQuery(true, limit).bind({bucket_day: bucketDay, created_at: after.createdAt}),
|
||||
);
|
||||
return {rows: [...ties, ...older], exhausted: limit === null || older.length < limit};
|
||||
}
|
||||
|
||||
export class JobLedgerRepository extends IJobLedgerRepository {
|
||||
async createJob(input: CreateJobInput): Promise<void> {
|
||||
async createJob(input: CreateJobInput): Promise<Date> {
|
||||
const now = new Date();
|
||||
const status: JobStatus = 'queued';
|
||||
const idRow: JobByIdRow = {
|
||||
@@ -68,39 +132,62 @@ export class JobLedgerRepository extends IJobLedgerRepository {
|
||||
started_at: null,
|
||||
};
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(JobsById.insert(idRow));
|
||||
batch.addPrepared(JobsByDayBucket.insert(bucketRow));
|
||||
batch.addPrepared(JobsActive.insert(activeRow));
|
||||
batch.addPrepared(JobsById.insertWithTtl(idRow, JOB_LEDGER_TTL_SECONDS));
|
||||
batch.addPrepared(JobsByDayBucket.insertWithTtl(bucketRow, JOB_LEDGER_TTL_SECONDS));
|
||||
batch.addPrepared(JobsActive.insertWithTtl(activeRow, JOB_LEDGER_TTL_SECONDS));
|
||||
await batch.executeChunked(10, false);
|
||||
return now;
|
||||
}
|
||||
|
||||
async getJob(jobId: bigint): Promise<JobByIdRow | null> {
|
||||
return fetchOne<JobByIdRow>(FETCH_JOB_BY_ID_QUERY.bind({job_id: jobId}));
|
||||
const row = await fetchOne<JobByIdRow>(FETCH_JOB_BY_ID_QUERY.bind({job_id: jobId}));
|
||||
return row?.created_at && row.task_type && row.status ? row : null;
|
||||
}
|
||||
|
||||
async discardJob(jobId: bigint, createdAt: Date): Promise<void> {
|
||||
await awaitAll(
|
||||
[
|
||||
deleteOneOrMany(
|
||||
JobsByDayBucket.deleteByPk({bucket_day: bucketDayFor(createdAt), created_at: createdAt, job_id: jobId}),
|
||||
),
|
||||
deleteOneOrMany(JobsById.deleteByPk({job_id: jobId})),
|
||||
deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId})),
|
||||
],
|
||||
'Ledger discard left rows behind',
|
||||
);
|
||||
}
|
||||
|
||||
async markRunning(jobId: bigint, lane: string): Promise<void> {
|
||||
const startedAt = new Date();
|
||||
const status: JobStatus = 'running';
|
||||
await upsertOne(
|
||||
JobsById.patchByPk(
|
||||
JobsById.patchByPkWithTtl(
|
||||
{job_id: jobId},
|
||||
{status: Db.set(status), started_at: Db.set(startedAt), jet_stream_lane: Db.set(lane)},
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
),
|
||||
);
|
||||
await upsertOne(
|
||||
JobsActive.patchByPkWithTtl(
|
||||
{job_id: jobId},
|
||||
{status: Db.set(status), started_at: Db.set(startedAt)},
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
),
|
||||
);
|
||||
await upsertOne(JobsActive.patchByPk({job_id: jobId}, {status: Db.set(status), started_at: Db.set(startedAt)}));
|
||||
}
|
||||
|
||||
async markSucceeded(jobId: bigint, result: Record<string, unknown> | null): Promise<void> {
|
||||
const completedAt = new Date();
|
||||
const status: JobStatus = 'succeeded';
|
||||
await upsertOne(
|
||||
JobsById.patchByPk(
|
||||
JobsById.patchByPkWithTtl(
|
||||
{job_id: jobId},
|
||||
{
|
||||
status: Db.set(status),
|
||||
completed_at: Db.set(completedAt),
|
||||
result: result === null ? Db.clear() : Db.set(JSON.stringify(result)),
|
||||
},
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
),
|
||||
);
|
||||
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
|
||||
@@ -109,7 +196,13 @@ export class JobLedgerRepository extends IJobLedgerRepository {
|
||||
async markCancelled(jobId: bigint): Promise<void> {
|
||||
const completedAt = new Date();
|
||||
const status: JobStatus = 'cancelled';
|
||||
await upsertOne(JobsById.patchByPk({job_id: jobId}, {status: Db.set(status), completed_at: Db.set(completedAt)}));
|
||||
await upsertOne(
|
||||
JobsById.patchByPkWithTtl(
|
||||
{job_id: jobId},
|
||||
{status: Db.set(status), completed_at: Db.set(completedAt)},
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
),
|
||||
);
|
||||
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
|
||||
}
|
||||
|
||||
@@ -117,9 +210,10 @@ export class JobLedgerRepository extends IJobLedgerRepository {
|
||||
const completedAt = new Date();
|
||||
const status: JobStatus = 'deadletter';
|
||||
await upsertOne(
|
||||
JobsById.patchByPk(
|
||||
JobsById.patchByPkWithTtl(
|
||||
{job_id: jobId},
|
||||
{status: Db.set(status), completed_at: Db.set(completedAt), error_message: Db.set(errorMessage)},
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
),
|
||||
);
|
||||
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
|
||||
@@ -127,27 +221,30 @@ export class JobLedgerRepository extends IJobLedgerRepository {
|
||||
|
||||
async reportProgress(jobId: bigint, current: number, total: number | null, message: string | null): Promise<void> {
|
||||
await upsertOne(
|
||||
JobsById.patchByPk(
|
||||
JobsById.patchByPkWithTtl(
|
||||
{job_id: jobId},
|
||||
{
|
||||
progress_current: Db.set(BigInt(current)),
|
||||
progress_total: total === null ? Db.clear() : Db.set(BigInt(total)),
|
||||
progress_message: message === null ? Db.clear() : Db.set(message),
|
||||
},
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async setContextLink(jobId: bigint, link: string): Promise<void> {
|
||||
await upsertOne(JobsById.patchByPk({job_id: jobId}, {context_link: Db.set(link)}));
|
||||
await upsertOne(JobsById.patchByPkWithTtl({job_id: jobId}, {context_link: Db.set(link)}, JOB_LEDGER_TTL_SECONDS));
|
||||
}
|
||||
|
||||
async setJetStreamSeq(jobId: bigint, seq: string): Promise<void> {
|
||||
await upsertOne(JobsById.patchByPk({job_id: jobId}, {jet_stream_seq: Db.set(seq)}));
|
||||
await upsertOne(JobsById.patchByPkWithTtl({job_id: jobId}, {jet_stream_seq: Db.set(seq)}, JOB_LEDGER_TTL_SECONDS));
|
||||
}
|
||||
|
||||
async requestCancel(jobId: bigint): Promise<void> {
|
||||
await upsertOne(JobsById.patchByPk({job_id: jobId}, {cancel_requested: Db.set(true)}));
|
||||
await upsertOne(
|
||||
JobsById.patchByPkWithTtl({job_id: jobId}, {cancel_requested: Db.set(true)}, JOB_LEDGER_TTL_SECONDS),
|
||||
);
|
||||
}
|
||||
|
||||
async isCancelRequested(jobId: bigint): Promise<boolean> {
|
||||
@@ -160,7 +257,9 @@ export class JobLedgerRepository extends IJobLedgerRepository {
|
||||
async incrementAttempts(jobId: bigint): Promise<void> {
|
||||
const row = await this.getJob(jobId);
|
||||
if (!row) return;
|
||||
await upsertOne(JobsById.patchByPk({job_id: jobId}, {attempts: Db.set(row.attempts + 1)}));
|
||||
await upsertOne(
|
||||
JobsById.patchByPkWithTtl({job_id: jobId}, {attempts: Db.set(row.attempts + 1)}, JOB_LEDGER_TTL_SECONDS),
|
||||
);
|
||||
}
|
||||
|
||||
async listJobs(opts: {
|
||||
@@ -169,59 +268,74 @@ export class JobLedgerRepository extends IJobLedgerRepository {
|
||||
filters: ListJobsFilters;
|
||||
maxLookbackDays: number;
|
||||
}): Promise<ListJobsResult> {
|
||||
const {limit, cursor, filters, maxLookbackDays} = opts;
|
||||
const startBucket = cursor ? new Date(`${cursor.bucketDay}T00:00:00Z`) : new Date();
|
||||
const hasFilters = Boolean(
|
||||
filters.status ||
|
||||
filters.taskType ||
|
||||
(filters.requestedByUserId !== undefined && filters.requestedByUserId !== null),
|
||||
);
|
||||
const collected: Array<JobByIdRow> = [];
|
||||
let nextCursor: ListJobsCursor | null = null;
|
||||
for (let dayOffset = 0; dayOffset <= maxLookbackDays && collected.length < limit; dayOffset++) {
|
||||
const bucketDate = new Date(startBucket);
|
||||
bucketDate.setUTCDate(bucketDate.getUTCDate() - dayOffset);
|
||||
const bucketDay = bucketDayFor(bucketDate);
|
||||
const remaining = limit - collected.length + 1;
|
||||
const bucketLimit = hasFilters ? {} : {limit: remaining};
|
||||
const useCursor = dayOffset === 0 && cursor !== null;
|
||||
let bucketRows: Array<JobByDayBucketRow>;
|
||||
if (useCursor && cursor) {
|
||||
const query = JobsByDayBucket.select({
|
||||
where: [JobsByDayBucket.where.eq('bucket_day'), JobsByDayBucket.where.lt('created_at')],
|
||||
...bucketLimit,
|
||||
});
|
||||
bucketRows = await fetchMany<JobByDayBucketRow>(
|
||||
query.bind({bucket_day: bucketDay, created_at: cursor.createdAt}),
|
||||
);
|
||||
} else {
|
||||
const query = JobsByDayBucket.select({
|
||||
where: JobsByDayBucket.where.eq('bucket_day'),
|
||||
...bucketLimit,
|
||||
});
|
||||
bucketRows = await fetchMany<JobByDayBucketRow>(query.bind({bucket_day: bucketDay}));
|
||||
const {limit, cursor, filters} = opts;
|
||||
const now = Date.now();
|
||||
const lookbackDays = Math.min(opts.maxLookbackDays, JOB_LEDGER_RETENTION_DAYS);
|
||||
const oldestDay = bucketDayFor(new Date(now - lookbackDays * ms('1 day')));
|
||||
const requestedBy = filters.requestedByUserId ?? null;
|
||||
const wholeDays = Boolean(filters.status || filters.taskType || requestedBy !== null);
|
||||
const jobs: Array<JobByIdRow> = [];
|
||||
let lastRow: JobByDayBucketRow | null = null;
|
||||
let day = bucketDayFor(new Date(Math.min(cursor ? cursor.createdAt.getTime() : now, now)));
|
||||
let after: LedgerPosition | null = cursor;
|
||||
while (jobs.length < limit && day >= oldestDay) {
|
||||
const {rows, exhausted} = await fetchDayAfter(day, after, wholeDays ? null : limit - jobs.length);
|
||||
for (const row of rows) {
|
||||
after = {createdAt: row.created_at, jobId: row.job_id};
|
||||
if (filters.taskType && row.task_type !== filters.taskType) continue;
|
||||
if (requestedBy !== null && row.requested_by_user_id !== requestedBy) continue;
|
||||
const job = await this.getJob(row.job_id);
|
||||
if (!job || (filters.status && job.status !== filters.status)) continue;
|
||||
jobs.push(job);
|
||||
lastRow = row;
|
||||
if (jobs.length === limit) break;
|
||||
}
|
||||
for (const r of bucketRows) {
|
||||
if (filters.taskType && r.task_type !== filters.taskType) continue;
|
||||
if (filters.requestedByUserId !== undefined && filters.requestedByUserId !== null) {
|
||||
if (r.requested_by_user_id !== filters.requestedByUserId) continue;
|
||||
}
|
||||
const fullRow = await this.getJob(r.job_id);
|
||||
if (!fullRow) continue;
|
||||
if (filters.status && fullRow.status !== filters.status) continue;
|
||||
if (collected.length >= limit) {
|
||||
nextCursor = {bucketDay, createdAt: r.created_at, jobId: r.job_id};
|
||||
break;
|
||||
}
|
||||
collected.push(fullRow);
|
||||
if (exhausted) {
|
||||
day = previousBucketDay(day);
|
||||
after = null;
|
||||
}
|
||||
if (nextCursor) break;
|
||||
}
|
||||
if (nextCursor === null && collected.length >= limit) {
|
||||
const last = collected[collected.length - 1];
|
||||
nextCursor = {bucketDay: bucketDayFor(last.created_at), createdAt: last.created_at, jobId: last.job_id};
|
||||
}
|
||||
return {jobs: collected, nextCursor};
|
||||
return {
|
||||
jobs,
|
||||
nextCursor:
|
||||
lastRow && jobs.length === limit
|
||||
? {bucketDay: lastRow.bucket_day, createdAt: lastRow.created_at, jobId: lastRow.job_id}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async expireStaleActiveJobs(opts: {
|
||||
staleBeforeMs: number;
|
||||
pageSize: number;
|
||||
maxCleared: number;
|
||||
}): Promise<{cleared: number; expired: number; complete: boolean}> {
|
||||
let cleared = 0;
|
||||
let expired = 0;
|
||||
let pageState: string | null = null;
|
||||
do {
|
||||
const page: PagedQueryResult<Pick<JobActiveRow, 'job_id'>> = await fetchPage(
|
||||
ACTIVE_JOB_IDS_QUERY.bind({}),
|
||||
undefined,
|
||||
{
|
||||
pageSize: opts.pageSize,
|
||||
pageState,
|
||||
},
|
||||
);
|
||||
for (const {job_id: jobId} of page.rows) {
|
||||
if (snowflakeToDate(jobId).getTime() >= opts.staleBeforeMs) continue;
|
||||
if (cleared >= opts.maxCleared) return {cleared, expired, complete: false};
|
||||
const job = await this.getJob(jobId);
|
||||
if (job?.status === 'queued' || job?.status === 'running') {
|
||||
await this.markDeadletter(jobId, EXPIRED_JOB_ERROR);
|
||||
expired += 1;
|
||||
} else {
|
||||
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
|
||||
}
|
||||
cleared += 1;
|
||||
}
|
||||
pageState = page.pageState;
|
||||
} while (pageState !== null);
|
||||
return {cleared, expired, complete: true};
|
||||
}
|
||||
|
||||
async listActiveJobs(): Promise<Array<JobByIdRow>> {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {JobByIdRow, JobStatus} from '@app/api/database/types/JobLedgerTypes';
|
||||
import type {ListJobsCursor, ListJobsFilters} from '@app/api/jobs/IJobLedgerRepository';
|
||||
import {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
|
||||
import {JobsByDayBucket, JobsById} from '@app/api/Tables';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const HOUR_MS = 3_600_000;
|
||||
const TODAY_NOON = new Date(`${new Date().toISOString().slice(0, 10)}T12:00:00.000Z`);
|
||||
const ADMIN_USER_ID = 4_242n;
|
||||
|
||||
interface ListedPage {
|
||||
ids: Array<bigint>;
|
||||
cursor: ListJobsCursor | null;
|
||||
}
|
||||
|
||||
function at(daysAgo: number, hour: number): Date {
|
||||
return new Date(TODAY_NOON.getTime() - daysAgo * DAY_MS + (hour - 12) * HOUR_MS);
|
||||
}
|
||||
|
||||
async function seedJob(
|
||||
jobId: bigint,
|
||||
createdAt: Date,
|
||||
opts: {taskType?: string; requestedBy?: bigint | null; status?: JobStatus; bucketOnly?: boolean} = {},
|
||||
): Promise<void> {
|
||||
const taskType = opts.taskType ?? 'A';
|
||||
const requestedBy = opts.requestedBy ?? null;
|
||||
await upsertOne(
|
||||
JobsByDayBucket.insert({
|
||||
bucket_day: createdAt.toISOString().slice(0, 10),
|
||||
created_at: createdAt,
|
||||
job_id: jobId,
|
||||
task_type: taskType,
|
||||
status: 'queued',
|
||||
requested_by_user_id: requestedBy,
|
||||
}),
|
||||
);
|
||||
if (opts.bucketOnly) return;
|
||||
const row: JobByIdRow = {
|
||||
job_id: jobId,
|
||||
task_type: taskType,
|
||||
status: opts.status ?? 'queued',
|
||||
progress_current: null,
|
||||
progress_total: null,
|
||||
progress_message: null,
|
||||
payload: '{}',
|
||||
result: null,
|
||||
error_message: null,
|
||||
created_at: createdAt,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
requested_by_user_id: requestedBy,
|
||||
audit_log_reason: null,
|
||||
jet_stream_seq: null,
|
||||
jet_stream_lane: 'batch',
|
||||
attempts: 0,
|
||||
max_attempts: 5,
|
||||
run_at: null,
|
||||
cancel_requested: false,
|
||||
context_link: null,
|
||||
};
|
||||
await upsertOne(JobsById.insert(row));
|
||||
}
|
||||
|
||||
async function listPages(opts: {
|
||||
limit: number;
|
||||
filters?: ListJobsFilters;
|
||||
maxLookbackDays?: number;
|
||||
cursor?: ListJobsCursor | null;
|
||||
}): Promise<Array<ListedPage>> {
|
||||
const repository = new JobLedgerRepository();
|
||||
const pages: Array<ListedPage> = [];
|
||||
let cursor = opts.cursor ?? null;
|
||||
for (let page = 0; page < 50; page += 1) {
|
||||
const result = await repository.listJobs({
|
||||
limit: opts.limit,
|
||||
cursor,
|
||||
filters: opts.filters ?? {},
|
||||
maxLookbackDays: opts.maxLookbackDays ?? 14,
|
||||
});
|
||||
pages.push({ids: result.jobs.map((job) => job.job_id), cursor: result.nextCursor});
|
||||
if (result.nextCursor === null) return pages;
|
||||
cursor = {
|
||||
bucketDay: result.nextCursor.bucketDay,
|
||||
createdAt: new Date(result.nextCursor.createdAt.toISOString()),
|
||||
jobId: BigInt(result.nextCursor.jobId.toString()),
|
||||
};
|
||||
}
|
||||
throw new Error('listJobs never stopped paging');
|
||||
}
|
||||
|
||||
function expectPages(pages: Array<ListedPage>, limit: number, expected: Array<Array<bigint>>): void {
|
||||
expect(pages.map((page) => page.ids)).toEqual(expected);
|
||||
const ids = pages.flatMap((page) => page.ids);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const page of pages.slice(0, -1)) {
|
||||
expect(page.ids).toHaveLength(limit);
|
||||
expect(page.cursor?.jobId).toBe(page.ids.at(-1));
|
||||
}
|
||||
expect(pages.at(-1)?.cursor).toBeNull();
|
||||
}
|
||||
|
||||
export function describeListJobsPaging(): void {
|
||||
describe('listJobs paging', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({toFake: ['Date']});
|
||||
vi.setSystemTime(TODAY_NOON);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('walks one day newest first through a tie group larger than the page', async () => {
|
||||
await seedJob(20n, at(0, 11));
|
||||
for (const jobId of [11n, 12n, 13n, 14n, 15n]) {
|
||||
await seedJob(jobId, at(0, 10));
|
||||
}
|
||||
await seedJob(9n, at(0, 9));
|
||||
await seedJob(5n, at(0, 8));
|
||||
await seedJob(6n, at(0, 8));
|
||||
|
||||
const pages = await listPages({limit: 2});
|
||||
|
||||
expectPages(pages, 2, [[20n, 15n], [14n, 13n], [12n, 11n], [9n, 6n], [5n]]);
|
||||
expect(pages[0]?.cursor).toEqual({
|
||||
bucketDay: at(0, 10).toISOString().slice(0, 10),
|
||||
createdAt: at(0, 10),
|
||||
jobId: 15n,
|
||||
});
|
||||
});
|
||||
|
||||
it('crosses days and keeps the lookback window anchored on today', async () => {
|
||||
await seedJob(41n, at(0, 11));
|
||||
await seedJob(40n, at(0, 10));
|
||||
await seedJob(32n, at(1, 11));
|
||||
await seedJob(31n, at(1, 10));
|
||||
await seedJob(30n, at(1, 9));
|
||||
await seedJob(21n, at(2, 11));
|
||||
await seedJob(20n, at(2, 10));
|
||||
await seedJob(10n, at(3, 11));
|
||||
|
||||
expectPages(await listPages({limit: 2, maxLookbackDays: 2}), 2, [[41n, 40n], [32n, 31n], [30n, 21n], [20n]]);
|
||||
expectPages(await listPages({limit: 2, maxLookbackDays: 3}), 2, [
|
||||
[41n, 40n],
|
||||
[32n, 31n],
|
||||
[30n, 21n],
|
||||
[20n, 10n],
|
||||
[],
|
||||
]);
|
||||
const outside = await new JobLedgerRepository().listJobs({
|
||||
limit: 2,
|
||||
cursor: {bucketDay: at(3, 12).toISOString().slice(0, 10), createdAt: at(3, 12), jobId: 1n},
|
||||
filters: {},
|
||||
maxLookbackDays: 2,
|
||||
});
|
||||
expect(outside).toEqual({jobs: [], nextCursor: null});
|
||||
});
|
||||
|
||||
it('starts a cursor dated in the future at today', async () => {
|
||||
await seedJob(2n, at(0, 2));
|
||||
await seedJob(1n, at(1, 2));
|
||||
const farFuture = new Date('9999-12-31T00:00:00.000Z');
|
||||
|
||||
expectPages(await listPages({limit: 5, cursor: {bucketDay: '9999-12-31', createdAt: farFuture, jobId: 1n}}), 5, [
|
||||
[2n, 1n],
|
||||
]);
|
||||
}, 2_000);
|
||||
|
||||
it('never lists a day past the 90-day retention', async () => {
|
||||
await seedJob(890n, at(89, 11));
|
||||
await seedJob(910n, at(91, 11));
|
||||
|
||||
expectPages(await listPages({limit: 10, maxLookbackDays: 120}), 10, [[890n]]);
|
||||
});
|
||||
|
||||
it('fills a page past rows whose job record is missing instead of leaving the day', async () => {
|
||||
for (let hour = 1; hour <= 8; hour += 1) {
|
||||
await seedJob(BigInt(hour), at(0, hour), {bucketOnly: [3, 6, 7].includes(hour)});
|
||||
}
|
||||
|
||||
expectPages(await listPages({limit: 3}), 3, [
|
||||
[8n, 5n, 4n],
|
||||
[2n, 1n],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills pages through task type, requester and status filters across days', async () => {
|
||||
await seedJob(60n, at(0, 11));
|
||||
await seedJob(59n, at(0, 10.5), {taskType: 'B'});
|
||||
await seedJob(58n, at(0, 10));
|
||||
await seedJob(57n, at(0, 9.5), {taskType: 'B', requestedBy: ADMIN_USER_ID});
|
||||
await seedJob(56n, at(0, 9), {status: 'succeeded'});
|
||||
await seedJob(55n, at(0, 8.5), {taskType: 'B'});
|
||||
await seedJob(50n, at(1, 11));
|
||||
await seedJob(49n, at(1, 10.5), {taskType: 'B', requestedBy: ADMIN_USER_ID});
|
||||
await seedJob(48n, at(1, 10), {bucketOnly: true});
|
||||
await seedJob(47n, at(1, 9.5));
|
||||
|
||||
expectPages(await listPages({limit: 2, filters: {taskType: 'A'}}), 2, [[60n, 58n], [56n, 50n], [47n]]);
|
||||
expectPages(await listPages({limit: 1, filters: {status: 'succeeded'}}), 1, [[56n], []]);
|
||||
expectPages(await listPages({limit: 5, filters: {requestedByUserId: ADMIN_USER_ID}}), 5, [[57n, 49n]]);
|
||||
});
|
||||
|
||||
it('resumes after a cursor whose job was discarded between pages', async () => {
|
||||
for (let hour = 1; hour <= 5; hour += 1) {
|
||||
await seedJob(BigInt(hour), at(0, hour));
|
||||
}
|
||||
const repository = new JobLedgerRepository();
|
||||
const first = await repository.listJobs({limit: 2, cursor: null, filters: {}, maxLookbackDays: 14});
|
||||
expect(first.jobs.map((job) => job.job_id)).toEqual([5n, 4n]);
|
||||
|
||||
await repository.discardJob(4n, at(0, 4));
|
||||
|
||||
expectPages(await listPages({limit: 2, cursor: first.nextCursor}), 2, [[3n, 2n], [1n]]);
|
||||
});
|
||||
|
||||
it('returns an empty page after a page that filled exactly', async () => {
|
||||
await seedJob(1n, at(0, 1));
|
||||
await seedJob(2n, at(0, 2));
|
||||
|
||||
expectPages(await listPages({limit: 2}), 2, [[2n, 1n], []]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {BatchBuilder, setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import type {JobActiveRow, JobByDayBucketRow, JobByIdRow, JobStatus} from '@app/api/database/types/JobLedgerTypes';
|
||||
import {
|
||||
EXPIRED_JOB_ERROR,
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
JOB_STALE_AFTER_MS,
|
||||
JobLedgerRepository,
|
||||
} from '@app/api/jobs/JobLedgerRepository';
|
||||
import {describeListJobsPaging} from '@app/api/jobs/ListJobsPagingSuite';
|
||||
import {expireLegacyJobLedgerRows} from '@app/api/jobs/PostgresJobLedgerExpiry';
|
||||
import {JobsActive, JobsByDayBucket, JobsById} from '@app/api/Tables';
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {createSnowflake} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const KV_TABLE = 'kv_job_ledger_expiry';
|
||||
const CONTAINER = `fluxer-kvjobs-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
const DAY_MS = 86_400_000;
|
||||
const ADMIN_USER_ID = 1_234_567_890_123n;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
function jobIdAgedDays(days: number): bigint {
|
||||
sequence += 1;
|
||||
return createSnowflake({timestamp: Date.now() - days * DAY_MS, sequence: sequence % 4096, workerId: 1});
|
||||
}
|
||||
|
||||
interface LegacyJob {
|
||||
jobId: bigint;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
async function seedLegacyJob(
|
||||
executor: PostgresKvQueryExecutor,
|
||||
opts: {ageDays: number; status: JobStatus; requestedBy: bigint | null; active: boolean},
|
||||
): Promise<LegacyJob> {
|
||||
const jobId = jobIdAgedDays(opts.ageDays);
|
||||
const createdAt = new Date(Date.now() - opts.ageDays * DAY_MS);
|
||||
const idRow: JobByIdRow = {
|
||||
job_id: jobId,
|
||||
task_type: opts.requestedBy === null ? 'flushUserActivityBuffer' : 'refreshSearchIndex',
|
||||
status: opts.status,
|
||||
progress_current: null,
|
||||
progress_total: null,
|
||||
progress_message: null,
|
||||
payload: '{}',
|
||||
result: null,
|
||||
error_message: null,
|
||||
created_at: createdAt,
|
||||
started_at: null,
|
||||
completed_at: opts.status === 'succeeded' ? createdAt : null,
|
||||
requested_by_user_id: opts.requestedBy,
|
||||
audit_log_reason: null,
|
||||
jet_stream_seq: '1',
|
||||
jet_stream_lane: 'batch',
|
||||
attempts: 0,
|
||||
max_attempts: 5,
|
||||
run_at: null,
|
||||
cancel_requested: false,
|
||||
context_link: null,
|
||||
};
|
||||
const bucketRow: JobByDayBucketRow = {
|
||||
bucket_day: createdAt.toISOString().slice(0, 10),
|
||||
created_at: createdAt,
|
||||
job_id: jobId,
|
||||
task_type: idRow.task_type,
|
||||
status: 'queued',
|
||||
requested_by_user_id: opts.requestedBy,
|
||||
};
|
||||
await executor.executeQuery(JobsById.insert(idRow));
|
||||
await executor.executeQuery(JobsByDayBucket.insert(bucketRow));
|
||||
if (opts.active) {
|
||||
const activeRow: JobActiveRow = {
|
||||
job_id: jobId,
|
||||
task_type: idRow.task_type,
|
||||
status: opts.status,
|
||||
requested_by_user_id: opts.requestedBy,
|
||||
created_at: createdAt,
|
||||
started_at: null,
|
||||
};
|
||||
await executor.executeQuery(JobsActive.insert(activeRow));
|
||||
}
|
||||
return {jobId, createdAt};
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('job ledger expiry against postgres', () => {
|
||||
let raw: IPostgresClient;
|
||||
let executor: PostgresKvQueryExecutor;
|
||||
|
||||
async function jobRows(
|
||||
jobId: bigint,
|
||||
): Promise<Array<{table_name: string; expires_at: Date | null; row_data: never}>> {
|
||||
const result = await raw.query<{table_name: string; expires_at: Date | null; row_data: never}>(
|
||||
`SELECT table_name, expires_at, row_data FROM ${KV_TABLE}
|
||||
WHERE table_name IN ('jobs_by_id', 'jobs_active', 'jobs_by_day_bucket')
|
||||
AND (row_key = $1 OR split_part(row_key, chr(31), 3) = $1)
|
||||
ORDER BY table_name`,
|
||||
[JSON.stringify({__fluxer_type: 'bigint', value: jobId.toString()})],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function waitForLockWait(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 400; attempt += 1) {
|
||||
const waiting = await raw.query<{n: number}>(
|
||||
`SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'`,
|
||||
);
|
||||
if (waiting.rows[0]!.n > 0) return;
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error('the pass never waited on the writer');
|
||||
}
|
||||
|
||||
async function forgetLedgerExpiry(): Promise<void> {
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET expires_at = NULL WHERE table_name IN ('jobs_by_id', 'jobs_by_day_bucket')`,
|
||||
);
|
||||
}
|
||||
|
||||
async function forgetExpiryOf(...jobIds: Array<bigint>): Promise<void> {
|
||||
const keys = jobIds.map((jobId) => JSON.stringify({__fluxer_type: 'bigint', value: jobId.toString()}));
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET expires_at = NULL
|
||||
WHERE table_name IN ('jobs_by_id', 'jobs_by_day_bucket')
|
||||
AND (row_key = ANY($1::text[]) OR split_part(row_key, chr(31), 3) = ANY($1::text[]))`,
|
||||
[keys],
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
startDockerContainer([
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
'postgres:16-alpine',
|
||||
'-c',
|
||||
'fsync=off',
|
||||
]);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({
|
||||
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
|
||||
maxConnections: 4,
|
||||
kvTable: KV_TABLE,
|
||||
});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
raw = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(raw);
|
||||
executor = new PostgresKvQueryExecutor(raw);
|
||||
}, 900_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await raw.query(`DELETE FROM ${KV_TABLE}`);
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('writes every ledger row with an expiry and keeps it through the job lifecycle', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const jobId = jobIdAgedDays(0);
|
||||
await repository.createJob({
|
||||
jobId,
|
||||
taskType: 'refreshSearchIndex',
|
||||
payload: {},
|
||||
requestedByUserId: ADMIN_USER_ID,
|
||||
auditLogReason: null,
|
||||
maxAttempts: 5,
|
||||
runAt: null,
|
||||
jetStreamLane: 'batch',
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
await repository.setJetStreamSeq(jobId, '7');
|
||||
await repository.markRunning(jobId, 'batch');
|
||||
await repository.reportProgress(jobId, 1, 2, 'half');
|
||||
|
||||
const running = await jobRows(jobId);
|
||||
expect(running.map((row) => row.table_name)).toEqual(['jobs_active', 'jobs_by_day_bucket', 'jobs_by_id']);
|
||||
for (const row of running) {
|
||||
expect(row.expires_at).not.toBeNull();
|
||||
const remainingSeconds = (row.expires_at!.getTime() - Date.now()) / 1000;
|
||||
expect(remainingSeconds).toBeGreaterThan(JOB_LEDGER_TTL_SECONDS - 60);
|
||||
expect(remainingSeconds).toBeLessThanOrEqual(JOB_LEDGER_TTL_SECONDS);
|
||||
}
|
||||
|
||||
await repository.markSucceeded(jobId, {ok: true});
|
||||
const done = await jobRows(jobId);
|
||||
expect(done.map((row) => row.table_name)).toEqual(['jobs_by_day_bucket', 'jobs_by_id']);
|
||||
expect(done.every((row) => row.expires_at !== null)).toBe(true);
|
||||
expect((await repository.getJob(jobId))?.status).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('never leaves a row without an expiry when a patch lands on a job that is gone', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const patches: Array<(jobId: bigint) => Promise<void>> = [
|
||||
(jobId) => repository.markRunning(jobId, 'batch'),
|
||||
(jobId) => repository.markSucceeded(jobId, null),
|
||||
(jobId) => repository.markCancelled(jobId),
|
||||
(jobId) => repository.markDeadletter(jobId, 'boom'),
|
||||
(jobId) => repository.reportProgress(jobId, 1, null, null),
|
||||
(jobId) => repository.setContextLink(jobId, '/admin/jobs'),
|
||||
(jobId) => repository.setJetStreamSeq(jobId, '1'),
|
||||
(jobId) => repository.requestCancel(jobId),
|
||||
];
|
||||
for (const patch of patches) {
|
||||
const jobId = jobIdAgedDays(0);
|
||||
await patch(jobId);
|
||||
const rows = await jobRows(jobId);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.every((row) => row.expires_at !== null)).toBe(true);
|
||||
expect(await repository.getJob(jobId)).toBeNull();
|
||||
}
|
||||
expect(await repository.listActiveJobs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('discards every row of a job that never reached the stream', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const jobId = jobIdAgedDays(0);
|
||||
const createdAt = await repository.createJob({
|
||||
jobId,
|
||||
taskType: 'batchGuildAuditLogMessageDeletes',
|
||||
payload: {guildId: '1'},
|
||||
requestedByUserId: null,
|
||||
auditLogReason: null,
|
||||
maxAttempts: 3,
|
||||
runAt: new Date(Date.now() + 30_000),
|
||||
jetStreamLane: 'batch',
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
expect(await jobRows(jobId)).toHaveLength(3);
|
||||
|
||||
await repository.discardJob(jobId, createdAt);
|
||||
|
||||
expect(await jobRows(jobId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears legacy rows by the same rules the expiry now enforces', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const cronDone = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'succeeded',
|
||||
requestedBy: null,
|
||||
active: false,
|
||||
});
|
||||
const cronStuck = await seedLegacyJob(executor, {ageDays: 20, status: 'queued', requestedBy: null, active: true});
|
||||
const partialId = jobIdAgedDays(20);
|
||||
await executor.executeQuery(
|
||||
JobsById.patchByPk({job_id: partialId}, {status: Db.set('succeeded'), completed_at: Db.set(new Date())}),
|
||||
);
|
||||
const adminDone = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'succeeded',
|
||||
requestedBy: ADMIN_USER_ID,
|
||||
active: false,
|
||||
});
|
||||
const adminStuck = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'queued',
|
||||
requestedBy: ADMIN_USER_ID,
|
||||
active: true,
|
||||
});
|
||||
const adminRunning = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'running',
|
||||
requestedBy: ADMIN_USER_ID,
|
||||
active: true,
|
||||
});
|
||||
const adminAncient = await seedLegacyJob(executor, {
|
||||
ageDays: 100,
|
||||
status: 'succeeded',
|
||||
requestedBy: ADMIN_USER_ID,
|
||||
active: false,
|
||||
});
|
||||
const cronInFlight = await seedLegacyJob(executor, {
|
||||
ageDays: 2,
|
||||
status: 'queued',
|
||||
requestedBy: null,
|
||||
active: true,
|
||||
});
|
||||
await forgetLedgerExpiry();
|
||||
const fresh = jobIdAgedDays(0);
|
||||
await repository.createJob({
|
||||
jobId: fresh,
|
||||
taskType: 'syncUrlBlocklists',
|
||||
payload: {},
|
||||
requestedByUserId: null,
|
||||
auditLogReason: null,
|
||||
maxAttempts: 5,
|
||||
runAt: null,
|
||||
jetStreamLane: 'batch',
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
const freshBefore = await jobRows(fresh);
|
||||
await raw.query(
|
||||
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) VALUES ('users', 'u1', 'u1', '{}'::jsonb)`,
|
||||
);
|
||||
|
||||
expect(await repository.getJob(partialId)).toBeNull();
|
||||
|
||||
const first = await expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
|
||||
expect(first).toEqual({deleted: 10, expiring: 6, complete: true});
|
||||
|
||||
expect(await jobRows(cronDone.jobId)).toEqual([]);
|
||||
expect(await jobRows(cronStuck.jobId)).toEqual([]);
|
||||
expect(await jobRows(partialId)).toEqual([]);
|
||||
expect(await jobRows(adminAncient.jobId)).toEqual([]);
|
||||
|
||||
for (const kept of [adminDone, adminStuck, adminRunning]) {
|
||||
const rows = await jobRows(kept.jobId);
|
||||
expect(rows.map((row) => row.table_name)).toEqual(['jobs_by_day_bucket', 'jobs_by_id']);
|
||||
for (const row of rows) {
|
||||
const expected = kept.createdAt.getTime() + JOB_LEDGER_TTL_SECONDS * 1000;
|
||||
expect(Math.abs(row.expires_at!.getTime() - expected)).toBeLessThan(2000);
|
||||
}
|
||||
}
|
||||
expect((await repository.getJob(adminDone.jobId))?.status).toBe('succeeded');
|
||||
for (const stuck of [adminStuck, adminRunning]) {
|
||||
const expired = await repository.getJob(stuck.jobId);
|
||||
expect(expired?.status).toBe('deadletter');
|
||||
expect(expired?.error_message).toBe('Expired from the job queue');
|
||||
expect(expired?.completed_at).toBeInstanceOf(Date);
|
||||
}
|
||||
|
||||
const inFlight = await jobRows(cronInFlight.jobId);
|
||||
expect(inFlight.map((row) => row.table_name)).toEqual(['jobs_active', 'jobs_by_day_bucket', 'jobs_by_id']);
|
||||
expect(inFlight.every((row) => row.expires_at === null)).toBe(true);
|
||||
expect((await repository.getJob(cronInFlight.jobId))?.status).toBe('queued');
|
||||
|
||||
expect(await jobRows(fresh)).toEqual(freshBefore);
|
||||
const users = await raw.query(`SELECT expires_at FROM ${KV_TABLE} WHERE table_name = 'users'`);
|
||||
expect(users.rows).toEqual([{expires_at: null}]);
|
||||
|
||||
const listed = await repository.listJobs({limit: 50, cursor: null, filters: {}, maxLookbackDays: 30});
|
||||
expect(listed.jobs.map((job) => job.job_id).sort()).toEqual(
|
||||
[adminDone.jobId, adminStuck.jobId, adminRunning.jobId, cronInFlight.jobId, fresh].sort(),
|
||||
);
|
||||
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('runs again a day after a clean pass and clears rows an older image wrote in between', async () => {
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
const rolledBack = await seedLegacyJob(executor, {ageDays: 30, status: 'queued', requestedBy: null, active: true});
|
||||
await forgetLedgerExpiry();
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('applied_at', now() - interval '2 days') WHERE row_key = 'job_ledger_expiry_v1'`,
|
||||
);
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 3,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await jobRows(rolledBack.jobId)).toEqual([]);
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('expires stale active jobs page by page without fighting the legacy pass', async () => {
|
||||
const repository = new JobLedgerRepository();
|
||||
const legacyFirst = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'queued',
|
||||
requestedBy: ADMIN_USER_ID,
|
||||
active: true,
|
||||
});
|
||||
await forgetExpiryOf(legacyFirst.jobId);
|
||||
await expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
|
||||
const legacyFirstRows = await jobRows(legacyFirst.jobId);
|
||||
|
||||
const stale: Array<bigint> = [];
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const jobId = jobIdAgedDays(9);
|
||||
await repository.createJob({
|
||||
jobId,
|
||||
taskType: 'syncUrlBlocklists',
|
||||
payload: {},
|
||||
requestedByUserId: null,
|
||||
auditLogReason: null,
|
||||
maxAttempts: 5,
|
||||
runAt: null,
|
||||
jetStreamLane: 'batch',
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
stale.push(jobId);
|
||||
}
|
||||
await repository.markRunning(stale[0]!, 'batch');
|
||||
const fresh = jobIdAgedDays(0);
|
||||
await repository.createJob({
|
||||
jobId: fresh,
|
||||
taskType: 'syncUrlBlocklists',
|
||||
payload: {},
|
||||
requestedByUserId: null,
|
||||
auditLogReason: null,
|
||||
maxAttempts: 5,
|
||||
runAt: null,
|
||||
jetStreamLane: 'batch',
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
const legacyStale = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'running',
|
||||
requestedBy: ADMIN_USER_ID,
|
||||
active: true,
|
||||
});
|
||||
const legacyYoung = await seedLegacyJob(executor, {ageDays: 2, status: 'queued', requestedBy: null, active: true});
|
||||
await forgetExpiryOf(legacyStale.jobId, legacyYoung.jobId);
|
||||
const orphan = jobIdAgedDays(9);
|
||||
await executor.executeQuery(
|
||||
JobsActive.patchByPkWithTtl({job_id: orphan}, {status: Db.set('running')}, JOB_LEDGER_TTL_SECONDS),
|
||||
);
|
||||
|
||||
const sweep = () =>
|
||||
repository.expireStaleActiveJobs({staleBeforeMs: Date.now() - JOB_STALE_AFTER_MS, pageSize: 2, maxCleared: 100});
|
||||
expect(await sweep()).toEqual({cleared: 6, expired: 5, complete: true});
|
||||
|
||||
const active = (await repository.listActiveJobs()).map((job) => job.job_id).sort();
|
||||
expect(active).toEqual([fresh, legacyYoung.jobId].sort());
|
||||
for (const jobId of [...stale, legacyStale.jobId]) {
|
||||
const job = await repository.getJob(jobId);
|
||||
expect(job?.status).toBe('deadletter');
|
||||
expect(job?.error_message).toBe(EXPIRED_JOB_ERROR);
|
||||
const byId = (await jobRows(jobId)).find((row) => row.table_name === 'jobs_by_id');
|
||||
expect(Math.abs(byId!.expires_at!.getTime() - (Date.now() + JOB_LEDGER_TTL_SECONDS * 1000))).toBeLessThan(60_000);
|
||||
}
|
||||
expect(await jobRows(orphan)).toEqual([]);
|
||||
expect(await jobRows(legacyFirst.jobId)).toEqual(legacyFirstRows);
|
||||
|
||||
const legacyStaleById = (await jobRows(legacyStale.jobId)).find((row) => row.table_name === 'jobs_by_id');
|
||||
await expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
|
||||
expect((await jobRows(legacyStale.jobId)).find((row) => row.table_name === 'jobs_by_id')).toEqual(legacyStaleById);
|
||||
expect((await repository.listActiveJobs()).map((job) => job.job_id)).toContain(legacyYoung.jobId);
|
||||
expect(await sweep()).toEqual({cleared: 0, expired: 0, complete: true});
|
||||
});
|
||||
|
||||
it('leaves rows alone when a live writer gives them an expiry while the pass waits on them', async () => {
|
||||
const cronDone = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'succeeded',
|
||||
requestedBy: null,
|
||||
active: false,
|
||||
});
|
||||
const adminDone = await seedLegacyJob(executor, {
|
||||
ageDays: 20,
|
||||
status: 'succeeded',
|
||||
requestedBy: ADMIN_USER_ID,
|
||||
active: false,
|
||||
});
|
||||
await forgetLedgerExpiry();
|
||||
const liveKeys = [cronDone.jobId, adminDone.jobId].map((jobId) =>
|
||||
JSON.stringify({__fluxer_type: 'bigint', value: jobId.toString()}),
|
||||
);
|
||||
let written!: () => void;
|
||||
const writerHoldsRows = new Promise<void>((resolve) => {
|
||||
written = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const writer = raw.transaction(async (db) => {
|
||||
await db.query(
|
||||
`UPDATE ${KV_TABLE} SET expires_at = now() + interval '1 hour', updated_at = now() WHERE table_name = 'jobs_by_id' AND row_key = ANY($1::text[])`,
|
||||
[liveKeys],
|
||||
);
|
||||
written();
|
||||
await released;
|
||||
});
|
||||
await writerHoldsRows;
|
||||
const pass = expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
|
||||
await waitForLockWait();
|
||||
release();
|
||||
await writer;
|
||||
|
||||
expect(await pass).toEqual({deleted: 1, expiring: 1, complete: true});
|
||||
for (const job of [cronDone, adminDone]) {
|
||||
const byId = (await jobRows(job.jobId)).find((row) => row.table_name === 'jobs_by_id');
|
||||
expect(byId).toBeDefined();
|
||||
const remainingSeconds = (byId!.expires_at!.getTime() - Date.now()) / 1000;
|
||||
expect(remainingSeconds).toBeGreaterThan(3000);
|
||||
expect(remainingSeconds).toBeLessThanOrEqual(3660);
|
||||
}
|
||||
expect((await new JobLedgerRepository().getJob(adminDone.jobId))?.status).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('pages through more legacy rows than one page holds and stops at its deadline', async () => {
|
||||
const batch = new BatchBuilder();
|
||||
for (let index = 0; index < 2300; index += 1) {
|
||||
const jobId = jobIdAgedDays(30);
|
||||
const createdAt = new Date(Date.now() - 30 * DAY_MS);
|
||||
batch.addPrepared(
|
||||
JobsByDayBucket.insert({
|
||||
bucket_day: createdAt.toISOString().slice(0, 10),
|
||||
created_at: createdAt,
|
||||
job_id: jobId,
|
||||
task_type: 'flushUserActivityBuffer',
|
||||
status: 'queued',
|
||||
requested_by_user_id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.executeChunked(500, false);
|
||||
await forgetLedgerExpiry();
|
||||
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() - 1)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: false,
|
||||
});
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 2300,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
const left = await raw.query(`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = 'jobs_by_day_bucket'`);
|
||||
expect(left.rows[0]).toEqual({n: 0});
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
describeListJobsPaging();
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {postgresKvPassIsFresh, recordPostgresKvCleanPass} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import {EXPIRED_JOB_ERROR, JOB_LEDGER_TTL_SECONDS, JOB_STALE_AFTER_MS} from '@app/api/jobs/JobLedgerRepository';
|
||||
import {FLUXER_EPOCH} from '@fluxer/constants/src/Core';
|
||||
import {TIMESTAMP_SHIFT} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const LEGACY_JOB_LEDGER_MARKER = 'job_ledger_expiry_v1';
|
||||
const PAGE_SIZE = 2000;
|
||||
const CLEAN_PASS_INTERVAL_MS = ms('1 day');
|
||||
const BIGINT_KEY_PREFIX = '{"__fluxer_type":"bigint","value":"';
|
||||
const BIGINT_KEY_SUFFIX = '"}';
|
||||
|
||||
function bigintKeyExpr(key: string): string {
|
||||
const prefixLength = BIGINT_KEY_PREFIX.length;
|
||||
const affixLength = prefixLength + BIGINT_KEY_SUFFIX.length;
|
||||
return `(CASE WHEN left(${key}, ${prefixLength}) = '${BIGINT_KEY_PREFIX}' AND right(${key}, ${BIGINT_KEY_SUFFIX.length}) = '${BIGINT_KEY_SUFFIX}' THEN substr(${key}, ${prefixLength + 1}, length(${key}) - ${affixLength})::numeric END)`;
|
||||
}
|
||||
|
||||
interface LedgerTable {
|
||||
name: string;
|
||||
jobIdExpr: string;
|
||||
deleteStale: boolean;
|
||||
markStaleDeadletter: boolean;
|
||||
}
|
||||
|
||||
const LEDGER_TABLES: ReadonlyArray<LedgerTable> = [
|
||||
{
|
||||
name: 'jobs_active',
|
||||
jobIdExpr: bigintKeyExpr('kv.row_key'),
|
||||
deleteStale: true,
|
||||
markStaleDeadletter: false,
|
||||
},
|
||||
{
|
||||
name: 'jobs_by_id',
|
||||
jobIdExpr: bigintKeyExpr('kv.row_key'),
|
||||
deleteStale: false,
|
||||
markStaleDeadletter: true,
|
||||
},
|
||||
{
|
||||
name: 'jobs_by_day_bucket',
|
||||
jobIdExpr: bigintKeyExpr('split_part(kv.row_key, chr(31), 3)'),
|
||||
deleteStale: false,
|
||||
markStaleDeadletter: false,
|
||||
},
|
||||
];
|
||||
|
||||
export interface LegacyJobLedgerExpiryResult {
|
||||
deleted: number;
|
||||
expiring: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
function pageSql(table: string, target: LedgerTable): string {
|
||||
const staleRemovable = target.deleteStale ? 'c.created_at < $7' : '(c.created_at < $7 AND c.system_job)';
|
||||
const removable = `c.job_id IS NULL OR c.created_at < $6 OR ${staleRemovable}`;
|
||||
const rowData = target.markStaleDeadletter
|
||||
? `CASE WHEN c.unfinished THEN kv.row_data || jsonb_build_object('status', 'deadletter', 'error_message', $9::text, 'completed_at', jsonb_build_object('__fluxer_type', 'date', 'value', $10::text)) ELSE kv.row_data END`
|
||||
: 'kv.row_data';
|
||||
return `
|
||||
WITH page AS (
|
||||
SELECT kv.row_key, kv.row_data, ${target.jobIdExpr} AS job_id
|
||||
FROM ${table} kv
|
||||
WHERE kv.table_name = $1 AND kv.expires_at IS NULL AND kv.row_key > $2
|
||||
ORDER BY kv.row_key
|
||||
LIMIT $3
|
||||
), classified AS (
|
||||
SELECT
|
||||
page.row_key,
|
||||
page.job_id,
|
||||
to_timestamp(((div(page.job_id, $4::numeric) + $5::numeric) / 1000)::double precision) AS created_at,
|
||||
COALESCE(page.row_data -> 'requested_by_user_id', 'null'::jsonb) = 'null'::jsonb AS system_job,
|
||||
COALESCE(page.row_data ->> 'status' IN ('queued', 'running'), false) AS unfinished
|
||||
FROM page
|
||||
), removed AS (
|
||||
DELETE FROM ${table} kv
|
||||
USING classified c
|
||||
WHERE kv.table_name = $1 AND kv.row_key = c.row_key AND kv.expires_at IS NULL AND (${removable})
|
||||
RETURNING kv.row_key
|
||||
), expiring AS (
|
||||
UPDATE ${table} kv
|
||||
SET expires_at = c.created_at + make_interval(secs => $8::double precision), updated_at = now(), row_data = ${rowData}
|
||||
FROM classified c
|
||||
WHERE kv.table_name = $1 AND kv.row_key = c.row_key AND kv.expires_at IS NULL AND c.created_at < $7 AND NOT (${removable})
|
||||
RETURNING kv.row_key
|
||||
)
|
||||
SELECT
|
||||
(SELECT max(row_key) FROM page) AS last_row_key,
|
||||
(SELECT count(*) FROM page) AS scanned,
|
||||
(SELECT count(*) FROM removed) AS deleted,
|
||||
(SELECT count(*) FROM expiring) AS expiring`;
|
||||
}
|
||||
|
||||
export async function expireLegacyJobLedgerRows(
|
||||
client: IPostgresClient,
|
||||
deadlineMs: number,
|
||||
): Promise<LegacyJobLedgerExpiryResult | null> {
|
||||
const table = quoteIdentifier(client.kvTable());
|
||||
if (await postgresKvPassIsFresh(client, LEGACY_JOB_LEDGER_MARKER, CLEAN_PASS_INTERVAL_MS)) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const retentionCutoff = new Date(now - JOB_LEDGER_TTL_SECONDS * 1000);
|
||||
const staleCutoff = new Date(now - JOB_STALE_AFTER_MS);
|
||||
const completedAt = new Date(now).toISOString();
|
||||
let scanned = 0;
|
||||
let deleted = 0;
|
||||
let expiring = 0;
|
||||
for (const target of LEDGER_TABLES) {
|
||||
const sql = pageSql(table, target);
|
||||
const deadletterValues = target.markStaleDeadletter ? [EXPIRED_JOB_ERROR, completedAt] : [];
|
||||
let cursor = '';
|
||||
for (;;) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
return {deleted, expiring, complete: false};
|
||||
}
|
||||
const result = await client.query<{
|
||||
last_row_key: string | null;
|
||||
scanned: string;
|
||||
deleted: string;
|
||||
expiring: string;
|
||||
}>(sql, [
|
||||
target.name,
|
||||
cursor,
|
||||
PAGE_SIZE,
|
||||
(1n << TIMESTAMP_SHIFT).toString(),
|
||||
FLUXER_EPOCH.toString(),
|
||||
retentionCutoff,
|
||||
staleCutoff,
|
||||
JOB_LEDGER_TTL_SECONDS,
|
||||
...deadletterValues,
|
||||
]);
|
||||
const page = result.rows[0];
|
||||
if (!page || page.last_row_key === null) {
|
||||
break;
|
||||
}
|
||||
scanned += Number(page.scanned);
|
||||
deleted += Number(page.deleted);
|
||||
expiring += Number(page.expiring);
|
||||
cursor = page.last_row_key;
|
||||
}
|
||||
}
|
||||
if (scanned === 0) {
|
||||
await recordPostgresKvCleanPass(client, LEGACY_JOB_LEDGER_MARKER);
|
||||
}
|
||||
return {deleted, expiring, complete: true};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {BANNED_FILE_SHAS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {BANNED_FILE_SHAS_REFRESH_CHANNEL, isBlocklistFeedFileSha} from '@app/api/constants/ContentModeration';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {RefreshSubscription} from '@app/api/utils/RefreshSubscription';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
@@ -38,8 +39,11 @@ class FileShaCache {
|
||||
async refresh(): Promise<void> {
|
||||
const rows = await this.adminRepository.loadAllBannedFileShas();
|
||||
const next = new Set<string>();
|
||||
const includeFeedRows = Config.blocklistFeeds.enabled;
|
||||
for (const row of rows) {
|
||||
if (row.sha256_hex) next.add(row.sha256_hex.toLowerCase());
|
||||
if (!row.sha256_hex) continue;
|
||||
if (!includeFeedRows && isBlocklistFeedFileSha(row)) continue;
|
||||
next.add(row.sha256_hex.toLowerCase());
|
||||
}
|
||||
this.banned = next;
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {BANNED_URL_DOMAINS_REFRESH_CHANNEL, BANNED_URLS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
@@ -68,7 +69,7 @@ class UrlBlocklistCache {
|
||||
}
|
||||
|
||||
private async loadFeedUrls(): Promise<Set<string>> {
|
||||
if (!this.storageService) return new Set();
|
||||
if (!this.storageService || !Config.blocklistFeeds.enabled) return new Set();
|
||||
const lines = await readLinesFromS3(this.storageService, RISK_S3_KEYS.feedUrls);
|
||||
return new Set(lines);
|
||||
}
|
||||
|
||||
@@ -24224,7 +24224,7 @@
|
||||
"description": "The ID of the guild this channel belongs to",
|
||||
"$ref": "#/components/schemas/SnowflakeStringType"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": "string"},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"topic": {"description": "The topic of the channel", "type": ["string", "null"]},
|
||||
"url": {
|
||||
"description": "The URL associated with the channel",
|
||||
@@ -27182,7 +27182,8 @@
|
||||
"assignments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"}
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -30686,6 +30687,12 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"ScreenShareDeliveryAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -47,3 +47,7 @@ export async function readLinesFromS3(storage: IStorageService, key: string): Pr
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRiskS3Object(storage: IStorageService, key: string): Promise<void> {
|
||||
await storage.deleteObject(RISK_S3_BUCKET, key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoRequestAuditEvent} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {
|
||||
createPostgresIpInfoCache,
|
||||
createPostgresIpInfoRequestAuditLogger,
|
||||
IPINFO_CACHE_TTL_SECONDS,
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function recordingClient(writes: Array<Array<unknown>>): IPostgresClient {
|
||||
return {
|
||||
async query(_text: string, values?: Array<unknown>) {
|
||||
writes.push(values ?? []);
|
||||
return {rows: [], rowCount: 1};
|
||||
},
|
||||
kvTable() {
|
||||
return 'kv';
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function expectExpiresIn(values: Array<unknown> | undefined, ttlSeconds: number): void {
|
||||
const expiresAt = values?.[4];
|
||||
expect(expiresAt).toBeInstanceOf(Date);
|
||||
const remainingSeconds = ((expiresAt as Date).getTime() - Date.now()) / 1000;
|
||||
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 10);
|
||||
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
|
||||
}
|
||||
|
||||
const EVENT: IpInfoRequestAuditEvent = {
|
||||
requestedAt: new Date('2026-09-21T12:00:00.000Z'),
|
||||
ip: '192.0.2.1',
|
||||
cacheKey: 'ip:192.0.2.1',
|
||||
source: 'test',
|
||||
reason: null,
|
||||
outcome: 'http_success',
|
||||
httpStatus: 200,
|
||||
available: true,
|
||||
riskNote: 'none',
|
||||
latencyMs: 12,
|
||||
requestUrl: 'https://ipinfo.test/192.0.2.1',
|
||||
responseIp: '192.0.2.1',
|
||||
countryCode: 'SE',
|
||||
asnNumber: 64500,
|
||||
isAnonymous: false,
|
||||
isTor: false,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
};
|
||||
|
||||
describe('Postgres ipinfo KV expiry', () => {
|
||||
it('expires request audit rows after 90 days', async () => {
|
||||
const writes: Array<Array<unknown>> = [];
|
||||
await createPostgresIpInfoRequestAuditLogger({client: recordingClient(writes)}).record(EVENT);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]?.[0]).toBe('ipinfo_requests_by_hour');
|
||||
expectExpiresIn(writes[0], IPINFO_REQUEST_AUDIT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('falls back to the 14-day cache default', async () => {
|
||||
const writes: Array<Array<unknown>> = [];
|
||||
const cache = createPostgresIpInfoCache({client: recordingClient(writes)});
|
||||
await cache.set('fallback', {ok: true});
|
||||
await cache.set('zero', {ok: true}, 0);
|
||||
await cache.set('short', {ok: true}, 60);
|
||||
expect(writes.map((values) => values[0])).toEqual(['ipinfo_cache', 'ipinfo_cache', 'ipinfo_cache']);
|
||||
expectExpiresIn(writes[0], IPINFO_CACHE_TTL_SECONDS);
|
||||
expectExpiresIn(writes[1], IPINFO_CACHE_TTL_SECONDS);
|
||||
expectExpiresIn(writes[2], 60);
|
||||
});
|
||||
});
|
||||
@@ -864,7 +864,7 @@ export class RpcService {
|
||||
if (!queueAllowed) {
|
||||
return;
|
||||
}
|
||||
await this.workerService.addJob('reconcileUserPayments', {userId: userIdString});
|
||||
await this.workerService.addJob('reconcileUserPayments', {userId: userIdString}, {skipLedger: true});
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestUserWithPremium} from '@app/api/stripe/tests/StripeWebhookTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
describe('RpcService session payment reconciliation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('queues payment reconciliation without a job record', async () => {
|
||||
const account = await createTestUserWithPremium(harness, UserPremiumTypes.SUBSCRIPTION, {
|
||||
stripeCustomerId: 'cus_rpc_session_reconcile',
|
||||
});
|
||||
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
|
||||
try {
|
||||
await createBuilder(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await vi.waitFor(() =>
|
||||
expect(addJob).toHaveBeenCalledWith('reconcileUserPayments', {userId: account.userId}, {skipLedger: true}),
|
||||
);
|
||||
} finally {
|
||||
addJob.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -352,7 +352,16 @@ export class InMemoryCassandraQueryExecutor implements CassandraQueryExecutorFor
|
||||
let rows = [...this.table(meta).values()].filter((row) => matchesWhere(row, meta.where, params));
|
||||
if (meta.orderBy) {
|
||||
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
|
||||
rows = rows.sort((a, b) => compareValues(a[meta.orderBy!.col], b[meta.orderBy!.col]) * direction);
|
||||
const column = meta.orderBy.col as string;
|
||||
const primaryKey = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
const columns = [column, ...primaryKey.slice(primaryKey.indexOf(column) + 1)];
|
||||
rows = rows.sort((a, b) => {
|
||||
for (const c of columns) {
|
||||
const cmp = compareValues(a[c], b[c]);
|
||||
if (cmp !== 0) return cmp * direction;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
if (typeof meta.limit === 'number') {
|
||||
rows = rows.slice(0, meta.limit);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const BOT_OVERWRITE = {
|
||||
allow: Permissions.ADD_REACTIONS | Permissions.SEND_MESSAGES | Permissions.MANAGE_MESSAGES | Permissions.PIN_MESSAGES,
|
||||
deny: 0n,
|
||||
};
|
||||
|
||||
describe('overwriteGrantedBits', () => {
|
||||
it('grants nothing when an overwrite is resubmitted unmodified', () => {
|
||||
expect(overwriteGrantedBits(BOT_OVERWRITE, {...BOT_OVERWRITE})).toBe(0n);
|
||||
});
|
||||
|
||||
it('ignores allow bits that were already set when another bit is flipped', () => {
|
||||
const before = {allow: Permissions.PIN_MESSAGES, deny: 0n};
|
||||
const after = {allow: Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES, deny: 0n};
|
||||
expect(overwriteGrantedBits(before, after)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
it('grants nothing when an allow bit is withdrawn', () => {
|
||||
const before = {allow: Permissions.SEND_MESSAGES | Permissions.MANAGE_MESSAGES, deny: 0n};
|
||||
const after = {allow: Permissions.SEND_MESSAGES, deny: 0n};
|
||||
expect(overwriteGrantedBits(before, after)).toBe(0n);
|
||||
});
|
||||
|
||||
it('grants nothing when a deny is added for a permission the editor lacks', () => {
|
||||
const after = {allow: Permissions.VIEW_CHANNEL, deny: Permissions.MANAGE_MESSAGES};
|
||||
expect(overwriteGrantedBits(null, after)).toBe(Permissions.VIEW_CHANNEL);
|
||||
});
|
||||
|
||||
it('grants the bits lifted out of deny', () => {
|
||||
const before = {allow: 0n, deny: Permissions.ADD_REACTIONS | Permissions.SEND_MESSAGES};
|
||||
const after = {allow: 0n, deny: Permissions.ADD_REACTIONS};
|
||||
expect(overwriteGrantedBits(before, after)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
it('treats a removed overwrite as granting everything it denied', () => {
|
||||
const before = {allow: Permissions.SEND_MESSAGES, deny: Permissions.ADD_REACTIONS};
|
||||
expect(overwriteGrantedBits(before, undefined)).toBe(Permissions.ADD_REACTIONS);
|
||||
});
|
||||
});
|
||||
@@ -51,3 +51,14 @@ export async function hasPermission(
|
||||
): Promise<boolean> {
|
||||
return await gatewayService.checkPermission(params);
|
||||
}
|
||||
|
||||
export function overwriteGrantedBits(
|
||||
before: {allow: bigint; deny: bigint} | null | undefined,
|
||||
after: {allow: bigint; deny: bigint} | null | undefined,
|
||||
): bigint {
|
||||
const beforeAllow = before?.allow ?? 0n;
|
||||
const beforeDeny = before?.deny ?? 0n;
|
||||
const afterAllow = after?.allow ?? 0n;
|
||||
const afterDeny = after?.deny ?? 0n;
|
||||
return (afterAllow & ~beforeAllow) | (beforeDeny & ~afterDeny);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
|
||||
import type {WorkerService} from '@app/api/worker/WorkerService';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const INITIAL_SYNC_KEY = 'sync:email_domains:initialized';
|
||||
const PURGE_KEY = 'sync:blocklist_feeds:purged';
|
||||
const CLAIM_TTL_SECONDS = ms('6 hours') / 1000;
|
||||
const FEED_TASKS = [
|
||||
'syncDisposableEmailDomains',
|
||||
'syncUrlBlocklists',
|
||||
'syncFileShaBlocklists',
|
||||
] as const satisfies ReadonlyArray<WorkerTaskName>;
|
||||
|
||||
export async function queueBlocklistFeedStartupJobs(
|
||||
kvClient: Pick<IKVProvider, 'setnx' | 'del'>,
|
||||
workerService: Pick<WorkerService, 'addJob'>,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
if (enabled) {
|
||||
if (await kvClient.setnx(INITIAL_SYNC_KEY, '1', CLAIM_TTL_SECONDS)) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await queueJobs(workerService, ['syncDisposableEmailDomains']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wasEnabled = (await kvClient.del(INITIAL_SYNC_KEY)) > 0;
|
||||
const claimed = await kvClient.setnx(PURGE_KEY, '1', CLAIM_TTL_SECONDS);
|
||||
if (!wasEnabled && !claimed) return;
|
||||
Logger.info('Removing blocklist feed data, blocklist feeds are disabled');
|
||||
await queueJobs(workerService, FEED_TASKS);
|
||||
}
|
||||
|
||||
async function queueJobs(
|
||||
workerService: Pick<WorkerService, 'addJob'>,
|
||||
tasks: ReadonlyArray<WorkerTaskName>,
|
||||
): Promise<void> {
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
await workerService.addJob(task, {});
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkerQueueOverflowError)) {
|
||||
throw error;
|
||||
}
|
||||
Logger.warn({task}, 'Dropped blocklist feed job, jobs stream is at its limit');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
StorageType,
|
||||
type StreamConfig,
|
||||
} from '@nats-io/jetstream';
|
||||
import {nanos} from '@nats-io/transport-node';
|
||||
import {millis, nanos} from '@nats-io/transport-node';
|
||||
import type {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
|
||||
const STREAM_NAME = 'JOBS';
|
||||
const SUBJECT_PREFIX = 'jobs.';
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const JOBS_STREAM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const LEGACY_CONSUMER_NAME = 'workers';
|
||||
const DLQ_STREAM_NAME = 'JOBS_DLQ';
|
||||
const DLQ_SUBJECT_PREFIX = 'dlq.';
|
||||
@@ -59,7 +59,7 @@ const JOBS_STREAM: WorkerStreamDefinition = {
|
||||
name: STREAM_NAME,
|
||||
subject: `${SUBJECT_PREFIX}>`,
|
||||
retention: RetentionPolicy.Workqueue,
|
||||
maxAgeMs: MAX_AGE_MS,
|
||||
maxAgeMs: JOBS_STREAM_MAX_AGE_MS,
|
||||
minBytes: STREAM_MIN_BYTES,
|
||||
maxMessages: STREAM_MAX_MSGS,
|
||||
maxMessagesPerSubject: STREAM_MAX_MSGS_PER_SUBJECT,
|
||||
@@ -106,6 +106,7 @@ export class JetStreamWorkerQueue {
|
||||
private consumersReady = false;
|
||||
private streamSetup: Promise<void> | null = null;
|
||||
private dlqStreamSetup: Promise<void> | null = null;
|
||||
private jobsStreamMaxAgeMs = JOBS_STREAM_MAX_AGE_MS;
|
||||
|
||||
constructor(connectionManager: JetStreamConnectionManager) {
|
||||
this.connectionManager = connectionManager;
|
||||
@@ -125,11 +126,16 @@ export class JetStreamWorkerQueue {
|
||||
if (existingConfig === null) {
|
||||
await this.addStream(jsm);
|
||||
} else {
|
||||
this.jobsStreamMaxAgeMs = millis(existingConfig.max_age);
|
||||
await this.applyStreamLimits(jsm, existingConfig);
|
||||
}
|
||||
this.streamReady = true;
|
||||
}
|
||||
|
||||
getJobsStreamMaxAgeMs(): number {
|
||||
return this.jobsStreamMaxAgeMs;
|
||||
}
|
||||
|
||||
private async oversizedSubjects(jsm: JetStreamManager): Promise<Array<[string, number]> | null> {
|
||||
try {
|
||||
const info = await jsm.streams.info(STREAM_NAME, {subjects_filter: JOBS_STREAM.subject});
|
||||
@@ -462,7 +468,7 @@ export class JetStreamWorkerQueue {
|
||||
priority?: number;
|
||||
jobKey?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
): Promise<{seq: string; duplicate: boolean}> {
|
||||
const js = this.connectionManager.getJetStreamClient();
|
||||
const subject = `${SUBJECT_PREFIX}${taskType}`;
|
||||
const body = JSON.stringify({
|
||||
@@ -477,8 +483,7 @@ export class JetStreamWorkerQueue {
|
||||
const ack = await js.publish(subject, body, {
|
||||
msgID,
|
||||
});
|
||||
const jobId = `${ack.seq}`;
|
||||
return jobId;
|
||||
return {seq: `${ack.seq}`, duplicate: ack.duplicate === true};
|
||||
} catch (error) {
|
||||
const rejection = describeStreamRejection(error);
|
||||
if (rejection === null) {
|
||||
|
||||
@@ -67,6 +67,7 @@ const LANE_CONFIG = {
|
||||
consumerName: 'workers_batch',
|
||||
tasks: [
|
||||
'expireAttachments',
|
||||
'expireStaleJobs',
|
||||
'indexChannelMessages',
|
||||
'indexGuildMembers',
|
||||
'processAssetDeletionQueue',
|
||||
|
||||
@@ -23,8 +23,9 @@ import {
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {initializeSearch, shutdownSearch} from '@app/api/SearchFactory';
|
||||
import {awaitAll} from '@app/api/utils/ConcurrencyUtils';
|
||||
import {queueBlocklistFeedStartupJobs} from '@app/api/worker/BlocklistFeedStartup';
|
||||
import {CronScheduler} from '@app/api/worker/CronScheduler';
|
||||
import {JetStreamWorkerQueue} from '@app/api/worker/JetStreamWorkerQueue';
|
||||
import {JetStreamWorkerQueue, JOBS_STREAM_MAX_AGE_MS} from '@app/api/worker/JetStreamWorkerQueue';
|
||||
import {clearWorkerDependencies, setWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {initializeWorkerDependencies, type WorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
||||
import {WorkerHeartbeat} from '@app/api/worker/WorkerHeartbeat';
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
validateLaneCompleteness,
|
||||
} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {createWorkerProcessErrorHandler} from '@app/api/worker/WorkerProcessErrorHandler';
|
||||
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import {WorkerService} from '@app/api/worker/WorkerService';
|
||||
import {workerTasks} from '@app/api/worker/WorkerTaskRegistry';
|
||||
@@ -43,9 +43,8 @@ import {BACKGROUND_READ_TIMEOUT_MS, initCassandra, shutdownCassandra} from '@pkg
|
||||
import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
|
||||
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
function registerCronJobs(cron: CronScheduler): void {
|
||||
function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void {
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
|
||||
if (Config.cachePurge.adapter !== 'none') {
|
||||
cron.upsert('processCachePurgeQueue', 'processCachePurgeQueue', {}, '*/10 * * * * *', {ledger: false});
|
||||
@@ -62,6 +61,14 @@ function registerCronJobs(cron: CronScheduler): void {
|
||||
}
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false});
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false});
|
||||
if (jobsStreamMaxAgeMs > 0 && jobsStreamMaxAgeMs <= JOBS_STREAM_MAX_AGE_MS) {
|
||||
cron.upsert('expireStaleJobs', 'expireStaleJobs', {}, '0 45 3 * * *', {ledger: false});
|
||||
} else {
|
||||
Logger.warn(
|
||||
{jobsStreamMaxAgeMs},
|
||||
'Jobs stream keeps jobs past 7 days, stale jobs stay active until their ledger rows expire',
|
||||
);
|
||||
}
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *', {ledger: false});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
@@ -240,26 +247,9 @@ export async function startWorkerMain(): Promise<void> {
|
||||
}
|
||||
dependencies = await initializeWorkerDependencies(snowflakeService);
|
||||
setWorkerDependencies(dependencies);
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
const didClaimEmailSync = await dependencies.kvClient.setnx(
|
||||
'sync:email_domains:initialized',
|
||||
'1',
|
||||
ms('6 hours') / 1000,
|
||||
);
|
||||
if (didClaimEmailSync) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
try {
|
||||
await workerService.addJob('syncDisposableEmailDomains', {});
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkerQueueOverflowError)) {
|
||||
throw error;
|
||||
}
|
||||
Logger.warn('Dropped initial disposable email domain sync, jobs stream is at its limit');
|
||||
}
|
||||
}
|
||||
}
|
||||
await queueBlocklistFeedStartupJobs(dependencies.kvClient, workerService, Config.blocklistFeeds.enabled);
|
||||
cron = new CronScheduler(workerService, Logger, dependencies.kvClient, heartbeat);
|
||||
registerCronJobs(cron);
|
||||
registerCronJobs(cron, queue.getJobsStreamMaxAgeMs());
|
||||
for (const lane of activeWorkerLanes) {
|
||||
const laneTasks: Record<string, WorkerTaskHandler> = {};
|
||||
for (const taskType of lane.taskTypes) {
|
||||
|
||||
@@ -29,10 +29,10 @@ export class WorkerService implements IWorkerService<WorkerTaskName> {
|
||||
const skipLedger = options?.skipLedger === true;
|
||||
const requireLedger = options?.requireLedger === true;
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
let ledgerWritten = false;
|
||||
let ledgerCreatedAt: Date | null = null;
|
||||
if (!skipLedger) {
|
||||
try {
|
||||
await this.ledger.createJob({
|
||||
ledgerCreatedAt = await this.ledger.createJob({
|
||||
jobId,
|
||||
taskType,
|
||||
payload: payloadRecord,
|
||||
@@ -43,29 +43,32 @@ export class WorkerService implements IWorkerService<WorkerTaskName> {
|
||||
jetStreamLane: findLaneForTask(taskType),
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
ledgerWritten = true;
|
||||
} catch (ledgerErr) {
|
||||
Logger.error({err: ledgerErr, jobId: jobId.toString(), taskType}, 'Failed to write ledger row for job');
|
||||
if (requireLedger) throw ledgerErr;
|
||||
}
|
||||
}
|
||||
const enrichedPayload = ledgerWritten ? {...payloadRecord, __jobId: jobId.toString()} : payloadRecord;
|
||||
const enrichedPayload = ledgerCreatedAt !== null ? {...payloadRecord, __jobId: jobId.toString()} : payloadRecord;
|
||||
try {
|
||||
const seq = await this.queue.enqueue(taskType, enrichedPayload, {
|
||||
const {seq, duplicate} = await this.queue.enqueue(taskType, enrichedPayload, {
|
||||
...(options?.runAt !== undefined && {runAt: options.runAt}),
|
||||
...(options?.maxAttempts !== undefined && {maxAttempts: options.maxAttempts}),
|
||||
...(options?.priority !== undefined && {priority: options.priority}),
|
||||
...(options?.jobKey !== undefined && {jobKey: options.jobKey}),
|
||||
});
|
||||
if (ledgerWritten) {
|
||||
if (ledgerCreatedAt !== null && duplicate) {
|
||||
await this.ledger
|
||||
.discardJob(jobId, ledgerCreatedAt)
|
||||
.catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger discardJob failed'));
|
||||
} else if (ledgerCreatedAt !== null) {
|
||||
await this.ledger
|
||||
.setJetStreamSeq(jobId, seq)
|
||||
.catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger setJetStreamSeq failed'));
|
||||
}
|
||||
Logger.debug({taskType, jobId: jobId.toString(), seq}, 'Job queued successfully');
|
||||
Logger.debug({taskType, jobId: jobId.toString(), seq, duplicate}, 'Job queued successfully');
|
||||
return jobId;
|
||||
} catch (error) {
|
||||
if (ledgerWritten) {
|
||||
if (ledgerCreatedAt !== null) {
|
||||
await this.ledger
|
||||
.markDeadletter(jobId, error instanceof Error ? error.message : String(error))
|
||||
.catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger markDeadletter failed'));
|
||||
|
||||
@@ -14,6 +14,7 @@ import bulkDeleteUserMessages from '@app/api/worker/tasks/BulkDeleteUserMessages
|
||||
import bulkDeleteUserMessagesScoped from '@app/api/worker/tasks/BulkDeleteUserMessagesScoped';
|
||||
import deleteUserMessagesInGuildByTime from '@app/api/worker/tasks/DeleteUserMessagesInGuildByTime';
|
||||
import expireAttachments from '@app/api/worker/tasks/ExpireAttachments';
|
||||
import expireStaleJobs from '@app/api/worker/tasks/ExpireStaleJobs';
|
||||
import extractEmbeds from '@app/api/worker/tasks/ExtractEmbeds';
|
||||
import finalizeNcmecAttachmentReport from '@app/api/worker/tasks/FinalizeNcmecAttachmentReport';
|
||||
import flushUserActivityBuffer from '@app/api/worker/tasks/FlushUserActivityBuffer';
|
||||
@@ -59,6 +60,7 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
|
||||
bulkUpdateUserFlags: bulkUpdateUserFlags,
|
||||
deleteUserMessagesInGuildByTime,
|
||||
expireAttachments,
|
||||
expireStaleJobs,
|
||||
extractEmbeds,
|
||||
finalizeNcmecAttachmentReport,
|
||||
handleMentions,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {JOB_STALE_AFTER_MS, JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
const MAX_CLEARED_PER_RUN = 1000;
|
||||
|
||||
const expireStaleJobs: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
const result = await new JobLedgerRepository().expireStaleActiveJobs({
|
||||
staleBeforeMs: Date.now() - JOB_STALE_AFTER_MS,
|
||||
pageSize: PAGE_SIZE,
|
||||
maxCleared: MAX_CLEARED_PER_RUN,
|
||||
});
|
||||
if (!result.complete) {
|
||||
helpers.logger.warn({...result}, 'Stale job sweep reached its per-run cap');
|
||||
} else if (result.cleared > 0) {
|
||||
helpers.logger.info({...result}, 'Expired stale jobs');
|
||||
}
|
||||
};
|
||||
|
||||
export default expireStaleJobs;
|
||||
@@ -8,6 +8,7 @@ import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
|
||||
import {
|
||||
getInvoiceLatestLinePeriodEnd,
|
||||
getPrimarySubscriptionItem,
|
||||
getSubscriptionPremiumPeriodEnd,
|
||||
getSubscriptionStartDate,
|
||||
@@ -142,6 +143,39 @@ function trackMostRecentTerminalSubscription(
|
||||
return current;
|
||||
}
|
||||
|
||||
const PAID_INVOICE_LOOKUP_LIMIT = 10;
|
||||
|
||||
async function getPaidThroughFromSubscriptionInvoices(
|
||||
stripe: Stripe,
|
||||
subscription: Stripe.Subscription | null,
|
||||
): Promise<Date | null> {
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
let invoices: Stripe.ApiList<Stripe.Invoice>;
|
||||
try {
|
||||
invoices = await stripe.invoices.list({
|
||||
subscription: subscription.id,
|
||||
status: 'paid',
|
||||
limit: PAID_INVOICE_LOOKUP_LIMIT,
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{subscriptionId: subscription.id, error},
|
||||
'Failed to list paid invoices while reconciling premium state; not stripping on incomplete data',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
let paidThrough: Date | null = null;
|
||||
for (const invoice of invoices.data) {
|
||||
const periodEnd = getInvoiceLatestLinePeriodEnd(invoice);
|
||||
if (periodEnd && (paidThrough === null || periodEnd.getTime() > paidThrough.getTime())) {
|
||||
paidThrough = periodEnd;
|
||||
}
|
||||
}
|
||||
return paidThrough;
|
||||
}
|
||||
|
||||
async function getEffectiveActiveStripeSubscription(
|
||||
stripe: Stripe,
|
||||
user: User,
|
||||
@@ -240,17 +274,28 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
|
||||
if (hasStalePremium && !hasNonStripePremium) {
|
||||
const patch: Partial<UserRow> = {};
|
||||
let effectivePremiumUntil = getEffectivePremiumUntil(user);
|
||||
const paidThrough = await getPaidThroughFromSubscriptionInvoices(stripe, mostRecentTerminalSubscription);
|
||||
if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) {
|
||||
const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000);
|
||||
if (subscriptionEndedAt.getTime() < user.premiumUntil.getTime()) {
|
||||
patch.premium_until = subscriptionEndedAt;
|
||||
patch.premium_grace_ends_at = subscriptionEndedAt;
|
||||
const entitlementEnd =
|
||||
paidThrough && paidThrough.getTime() > subscriptionEndedAt.getTime() ? paidThrough : subscriptionEndedAt;
|
||||
if (entitlementEnd.getTime() < user.premiumUntil.getTime()) {
|
||||
patch.premium_until = entitlementEnd;
|
||||
patch.premium_grace_ends_at = entitlementEnd;
|
||||
effectivePremiumUntil = getEffectivePremiumUntil({
|
||||
premiumUntil: subscriptionEndedAt,
|
||||
premiumUntil: entitlementEnd,
|
||||
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (paidThrough && (effectivePremiumUntil == null || paidThrough.getTime() > effectivePremiumUntil.getTime())) {
|
||||
patch.premium_until = paidThrough;
|
||||
patch.premium_grace_ends_at = paidThrough;
|
||||
effectivePremiumUntil = getEffectivePremiumUntil({
|
||||
premiumUntil: paidThrough,
|
||||
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
|
||||
});
|
||||
}
|
||||
const hasFutureLocalEntitlement = effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime();
|
||||
if (hasFutureLocalEntitlement) {
|
||||
if (user.premiumWillCancel !== true) {
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {expireLegacyDefaultTtlRows} from '@app/api/database/PostgresKvDefaultTtlExpiry';
|
||||
import {pruneExpiredPostgresKvRows} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import {expireLegacyJobLedgerRows} from '@app/api/jobs/PostgresJobLedgerExpiry';
|
||||
import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const PRUNE_BATCH_SIZE = 5000;
|
||||
const MAX_PRUNE_BATCHES_PER_RUN = 20;
|
||||
const LEGACY_EXPIRY_BUDGET_MS = ms('2 minutes');
|
||||
|
||||
const prunePostgresKvTtl: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
if (Config.database.backend !== 'postgres') {
|
||||
return;
|
||||
}
|
||||
const client = getDefaultPostgresClient();
|
||||
const deadlineMs = Date.now() + LEGACY_EXPIRY_BUDGET_MS;
|
||||
const legacyJobs = await expireLegacyJobLedgerRows(client, deadlineMs);
|
||||
if (legacyJobs !== null && (legacyJobs.deleted > 0 || legacyJobs.expiring > 0 || !legacyJobs.complete)) {
|
||||
helpers.logger.info({...legacyJobs}, 'Expired legacy job ledger rows');
|
||||
}
|
||||
const legacyDefaults = await expireLegacyDefaultTtlRows(client, deadlineMs);
|
||||
if (
|
||||
legacyDefaults !== null &&
|
||||
(legacyDefaults.deleted > 0 || legacyDefaults.expiring > 0 || !legacyDefaults.complete)
|
||||
) {
|
||||
helpers.logger.info({...legacyDefaults}, 'Expired rows written without their table default TTL');
|
||||
}
|
||||
let deleted = 0;
|
||||
for (let batch = 0; batch < MAX_PRUNE_BATCHES_PER_RUN; batch += 1) {
|
||||
const batchDeleted = await pruneExpiredPostgresKvRows(client, PRUNE_BATCH_SIZE);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {domainToASCII} from 'node:url';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {isAccountPolicyContactDomainReputationExempt} from '@app/api/risk/AccountPolicyService';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
@@ -131,10 +132,7 @@ async function throwIfCancelled(helpers: WorkerTaskHelpers): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const syncDisposableEmailDomains: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.info('Starting disposable email domain sync');
|
||||
await helpers.setContextLink('/suspicious-email-domains');
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
async function fetchFeedDomains(helpers: WorkerTaskHelpers): Promise<Set<string>> {
|
||||
const freshSet = new Set<string>();
|
||||
const perSourceCounts: Record<string, number> = {};
|
||||
const perSourceRawCounts: Record<string, number> = {};
|
||||
@@ -164,6 +162,14 @@ const syncDisposableEmailDomains: WorkerTaskHandler = async (_payload, helpers)
|
||||
},
|
||||
'Fetched disposable email domains from all sources',
|
||||
);
|
||||
return freshSet;
|
||||
}
|
||||
|
||||
const syncDisposableEmailDomains: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.info('Starting disposable email domain sync');
|
||||
await helpers.setContextLink('/suspicious-email-domains');
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
const freshSet = Config.blocklistFeeds.enabled ? await fetchFeedDomains(helpers) : new Set<string>();
|
||||
const currentSet = await loadCurrentDisposableEmailDomains();
|
||||
let addCount = 0;
|
||||
for (const domain of freshSet) {
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BANNED_FILE_SHAS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
BANNED_FILE_SHAS_REFRESH_CHANNEL,
|
||||
ContentBlocklistCategory,
|
||||
isBlocklistFeedFileSha,
|
||||
} from '@app/api/constants/ContentModeration';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
const MALWARE_BAZAAR_SHA256_URL = 'https://bazaar.abuse.ch/export/txt/sha256/recent/';
|
||||
const SHA256_RE = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
async function removeFeedFileShas(helpers: WorkerTaskHelpers): Promise<void> {
|
||||
const {adminRepository, kvClient} = getWorkerDependencies();
|
||||
let removed = 0;
|
||||
for (const row of await adminRepository.loadAllBannedFileShas()) {
|
||||
if (!isBlocklistFeedFileSha(row)) continue;
|
||||
if (await adminRepository.unbanFeedFileSha(row.sha256_hex)) removed++;
|
||||
}
|
||||
if (removed > 0) {
|
||||
await kvClient.publish(BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh');
|
||||
}
|
||||
helpers.logger.info({removed}, 'Removed file-SHA blocklist feed rows');
|
||||
}
|
||||
|
||||
const syncFileShaBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.info('Starting file-SHA blocklist sync');
|
||||
await helpers.setContextLink('/file-sha-bans');
|
||||
if (!Config.blocklistFeeds.enabled) {
|
||||
await removeFeedFileShas(helpers);
|
||||
return;
|
||||
}
|
||||
const {adminRepository, kvClient} = getWorkerDependencies();
|
||||
let added = 0;
|
||||
try {
|
||||
@@ -38,7 +61,7 @@ const syncFileShaBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
if (existingSet.has(sha)) continue;
|
||||
await adminRepository.banFileSha({
|
||||
sha256_hex: sha,
|
||||
category: 'malware_bazaar',
|
||||
category: ContentBlocklistCategory.MALWARE_BAZAAR,
|
||||
severity: 2,
|
||||
content_type: null,
|
||||
source_url: MALWARE_BAZAAR_SHA256_URL,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {BANNED_URLS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
|
||||
import {RISK_S3_KEYS, writeLinesToS3} from '@app/api/risk/RiskBlocklistS3';
|
||||
import {deleteRiskS3Object, RISK_S3_KEYS, writeLinesToS3} from '@app/api/risk/RiskBlocklistS3';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
@@ -62,10 +63,23 @@ async function fetchFeed(source: FeedSource): Promise<Array<string>> {
|
||||
return source.parse(text);
|
||||
}
|
||||
|
||||
const MISSING_FEED_FILE_ERRORS = new Set(['NoSuchBucket', 'NoSuchKey', 'NotFound']);
|
||||
|
||||
const syncUrlBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.info('Starting URL blocklist sync');
|
||||
await helpers.setContextLink('/url-domain-bans');
|
||||
const {storageService, kvClient} = getWorkerDependencies();
|
||||
if (!Config.blocklistFeeds.enabled) {
|
||||
try {
|
||||
await deleteRiskS3Object(storageService, RISK_S3_KEYS.feedUrls);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && MISSING_FEED_FILE_ERRORS.has(error.name))) {
|
||||
helpers.logger.warn({error}, 'Failed to delete the URL blocklist feed file');
|
||||
}
|
||||
}
|
||||
await kvClient.publish(BANNED_URLS_REFRESH_CHANNEL, 'refresh');
|
||||
return;
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
FEED_SOURCES.map(async (source) => ({source, rawUrls: await fetchFeed(source)})),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {queueBlocklistFeedStartupJobs} from '@app/api/worker/BlocklistFeedStartup';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const INITIAL_SYNC_KEY = 'sync:email_domains:initialized';
|
||||
const FEED_TASKS = ['syncDisposableEmailDomains', 'syncUrlBlocklists', 'syncFileShaBlocklists'];
|
||||
|
||||
function createWorkerService() {
|
||||
return {addJob: vi.fn(async (_task: WorkerTaskName, _payload: WorkerJobPayload) => 1n)};
|
||||
}
|
||||
|
||||
function queuedTasks(workerService: ReturnType<typeof createWorkerService>): Array<string> {
|
||||
return workerService.addJob.mock.calls.map(([task]) => task);
|
||||
}
|
||||
|
||||
describe('queueBlocklistFeedStartupJobs', () => {
|
||||
it('with feeds on, a fresh start queues only the disposable sync and claims it for six hours', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const workerService = createWorkerService();
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
|
||||
expect(workerService.addJob.mock.calls).toEqual([['syncDisposableEmailDomains', {}]]);
|
||||
expect(kv.setnxSpy.mock.calls).toEqual([[INITIAL_SYNC_KEY, '1', 21600]]);
|
||||
expect(kv.delSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with feeds on, a start inside the claim queues nothing', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const workerService = createWorkerService();
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
|
||||
expect(queuedTasks(workerService)).toEqual(['syncDisposableEmailDomains']);
|
||||
});
|
||||
|
||||
it('with feeds off, the first start queues all three feed tasks and a second start queues none', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const workerService = createWorkerService();
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, false);
|
||||
expect(workerService.addJob.mock.calls).toEqual(FEED_TASKS.map((task) => [task, {}]));
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, false);
|
||||
expect(queuedTasks(workerService)).toEqual(FEED_TASKS);
|
||||
});
|
||||
|
||||
it('with feeds off, a start after a feeds-on start cleans up again inside the claim', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const workerService = createWorkerService();
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, false);
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, false);
|
||||
|
||||
expect(queuedTasks(workerService)).toEqual([...FEED_TASKS, 'syncDisposableEmailDomains', ...FEED_TASKS]);
|
||||
expect(await kv.exists(INITIAL_SYNC_KEY)).toBe(0);
|
||||
});
|
||||
|
||||
it('a legacy initial sync key without expiry is cleared, so re-enabling runs the initial sync', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
await kv.set(INITIAL_SYNC_KEY, '1');
|
||||
expect(await kv.ttl(INITIAL_SYNC_KEY)).toBe(-1);
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, createWorkerService(), false);
|
||||
expect(await kv.exists(INITIAL_SYNC_KEY)).toBe(0);
|
||||
|
||||
const workerService = createWorkerService();
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
expect(queuedTasks(workerService)).toEqual(['syncDisposableEmailDomains']);
|
||||
});
|
||||
|
||||
it('a full jobs stream drops the job without failing startup', async () => {
|
||||
const overflowing = createWorkerService();
|
||||
overflowing.addJob.mockImplementation(async (task) => {
|
||||
throw new WorkerQueueOverflowError(task, 'maximum messages exceeded');
|
||||
});
|
||||
await expect(queueBlocklistFeedStartupJobs(new MockKVProvider(), overflowing, false)).resolves.toBeUndefined();
|
||||
expect(queuedTasks(overflowing)).toEqual(FEED_TASKS);
|
||||
|
||||
const failing = createWorkerService();
|
||||
failing.addJob.mockRejectedValue(new Error('jetstream unavailable'));
|
||||
await expect(queueBlocklistFeedStartupJobs(new MockKVProvider(), failing, false)).rejects.toThrow(
|
||||
'jetstream unavailable',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
BANNED_FILE_SHAS_REFRESH_CHANNEL,
|
||||
BANNED_URLS_REFRESH_CHANNEL,
|
||||
ContentBlocklistCategory,
|
||||
} from '@app/api/constants/ContentModeration';
|
||||
import type {BannedFileShaRow} from '@app/api/database/types/AdminArchiveTypes';
|
||||
import {fileShaCache} from '@app/api/middleware/FileShaCache';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
import syncDisposableEmailDomains from '@app/api/worker/tasks/SyncDisposableEmailDomains';
|
||||
import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists';
|
||||
import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
|
||||
|
||||
const FEED_BUCKET = 'fluxer-geoip';
|
||||
const FEED_KEY = 'blocklists/feed-urls.txt';
|
||||
const FEED_SHA = 'a1'.repeat(32);
|
||||
const ADMIN_BAZAAR_SHA = 'b2'.repeat(32);
|
||||
const ADMIN_MANUAL_SHA = 'c3'.repeat(32);
|
||||
const UNOWNED_NCMEC_SHA = 'd4'.repeat(32);
|
||||
const ADMIN_USER_ID = 42n;
|
||||
|
||||
function createHelpers(overrides: Partial<WorkerTaskHelpers> = {}): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 4242n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fileShaRow(sha256Hex: string, category: string, addedBy: bigint | null): BannedFileShaRow {
|
||||
return {
|
||||
sha256_hex: sha256Hex,
|
||||
category,
|
||||
severity: 2,
|
||||
content_type: null,
|
||||
source_url: null,
|
||||
added_at: new Date(),
|
||||
added_by: addedBy,
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedFileShas(): Promise<void> {
|
||||
const adminRepository = getAdminRepository();
|
||||
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
|
||||
await adminRepository.banFileSha(
|
||||
fileShaRow(ADMIN_BAZAAR_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID),
|
||||
);
|
||||
await adminRepository.banFileSha(fileShaRow(ADMIN_MANUAL_SHA, ContentBlocklistCategory.MANUAL, ADMIN_USER_ID));
|
||||
await adminRepository.banFileSha(fileShaRow(UNOWNED_NCMEC_SHA, ContentBlocklistCategory.NCMEC, null));
|
||||
}
|
||||
|
||||
describe('blocklist feeds turned off', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let previousFeedsEnabled: boolean;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
previousFeedsEnabled = Config.blocklistFeeds.enabled;
|
||||
await harness.reset();
|
||||
harness.storageService.reset();
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: getAdminRepository(),
|
||||
kvClient: harness.kvProvider,
|
||||
storageService: harness.storageService,
|
||||
});
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.blocklistFeeds.enabled = previousFeedsEnabled;
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
function publishCalls(): Array<Array<string>> {
|
||||
return (harness.kvProvider as MockKVProvider).publishSpy.mock.calls;
|
||||
}
|
||||
|
||||
it('removes every stored disposable domain without fetching a feed', async () => {
|
||||
const adminRepository = getAdminRepository();
|
||||
for (const domain of ['mailinator.com', 'guerrillamail.com', 'tempmail.dev']) {
|
||||
await adminRepository.addDisposableEmailDomain(domain);
|
||||
}
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
const reportProgress = vi.fn(async () => {});
|
||||
|
||||
await syncDisposableEmailDomains({}, createHelpers({reportProgress}));
|
||||
|
||||
expect(await adminRepository.listDisposableEmailDomains()).toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(reportProgress).toHaveBeenLastCalledWith(3, 3, '+0 added, -3 removed');
|
||||
});
|
||||
|
||||
it('reports a stored domain as disposable only while feeds are on', async () => {
|
||||
const adminRepository = getAdminRepository();
|
||||
await adminRepository.addDisposableEmailDomain('mailinator.com');
|
||||
|
||||
Config.blocklistFeeds.enabled = true;
|
||||
expect(await adminRepository.isEmailDomainDisposable('mailinator.com')).toBe(true);
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
expect(await adminRepository.isEmailDomainDisposable('mailinator.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes MalwareBazaar feed rows and keeps every other file-SHA ban', async () => {
|
||||
await seedFileShas();
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
|
||||
await syncFileShaBlocklists({}, createHelpers());
|
||||
|
||||
const remaining = (await getAdminRepository().loadAllBannedFileShas()).map((row) => row.sha256_hex).sort();
|
||||
expect(remaining).toEqual([ADMIN_BAZAAR_SHA, ADMIN_MANUAL_SHA, UNOWNED_NCMEC_SHA]);
|
||||
expect(publishCalls()).toEqual([[BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh']]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a file-SHA ban an Admin took over after the purge read the table', async () => {
|
||||
const adminRepository = getAdminRepository();
|
||||
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
|
||||
const staleRows = await adminRepository.loadAllBannedFileShas();
|
||||
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID));
|
||||
const staleRead = vi.spyOn(adminRepository, 'loadAllBannedFileShas').mockResolvedValueOnce(staleRows);
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
|
||||
await syncFileShaBlocklists({}, createHelpers());
|
||||
|
||||
staleRead.mockRestore();
|
||||
expect(await adminRepository.isFileShaBanned(FEED_SHA)).toBe(true);
|
||||
});
|
||||
|
||||
it('file-SHA cache skips feed rows only while feeds are off', async () => {
|
||||
await seedFileShas();
|
||||
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
await fileShaCache.refresh();
|
||||
expect(fileShaCache.isBanned(FEED_SHA)).toBe(false);
|
||||
expect(fileShaCache.isBanned(ADMIN_BAZAAR_SHA)).toBe(true);
|
||||
expect(fileShaCache.isBanned(ADMIN_MANUAL_SHA)).toBe(true);
|
||||
expect(fileShaCache.isBanned(UNOWNED_NCMEC_SHA)).toBe(true);
|
||||
|
||||
Config.blocklistFeeds.enabled = true;
|
||||
await fileShaCache.refresh();
|
||||
expect(fileShaCache.isBanned(FEED_SHA)).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes the URL feed file and never fetches while feeds are off', async () => {
|
||||
await harness.storageService.uploadObject({
|
||||
bucket: FEED_BUCKET,
|
||||
key: FEED_KEY,
|
||||
body: Buffer.from('https://feed.example/x\n'),
|
||||
});
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
|
||||
await syncUrlBlocklists({}, createHelpers());
|
||||
|
||||
expect(harness.storageService.deleteObjectSpy).toHaveBeenCalledWith(FEED_BUCKET, FEED_KEY);
|
||||
expect(harness.storageService.hasObject(FEED_BUCKET, FEED_KEY)).toBe(false);
|
||||
expect(publishCalls()).toEqual([[BANNED_URLS_REFRESH_CHANNEL, 'refresh']]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
const quietLogger = new NoopLogger();
|
||||
const quietWarn = vi.spyOn(quietLogger, 'warn');
|
||||
vi.spyOn(harness.storageService, 'deleteObject').mockRejectedValueOnce(
|
||||
Object.assign(new Error('The specified bucket does not exist'), {name: 'NoSuchBucket'}),
|
||||
);
|
||||
await expect(syncUrlBlocklists({}, createHelpers({logger: quietLogger}))).resolves.toBeUndefined();
|
||||
expect(quietWarn).not.toHaveBeenCalled();
|
||||
|
||||
const loudLogger = new NoopLogger();
|
||||
const loudWarn = vi.spyOn(loudLogger, 'warn');
|
||||
harness.storageService.configure({shouldFailDelete: true});
|
||||
await expect(syncUrlBlocklists({}, createHelpers({logger: loudLogger}))).resolves.toBeUndefined();
|
||||
expect(loudWarn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('URL cache ignores the feed file only while feeds are off', async () => {
|
||||
const feedUrl = canonicalizeUrl('https://feed.example/x');
|
||||
const adminUrl = canonicalizeUrl('https://admin.example/y');
|
||||
expect(feedUrl).not.toBeNull();
|
||||
expect(adminUrl).not.toBeNull();
|
||||
await harness.storageService.uploadObject({
|
||||
bucket: FEED_BUCKET,
|
||||
key: FEED_KEY,
|
||||
body: Buffer.from(`${feedUrl}\n`),
|
||||
});
|
||||
await getAdminRepository().banUrl({
|
||||
url_canonical: adminUrl!,
|
||||
category: ContentBlocklistCategory.MANUAL,
|
||||
severity: 2,
|
||||
source_url: null,
|
||||
added_at: new Date(),
|
||||
added_by: ADMIN_USER_ID,
|
||||
notes: null,
|
||||
});
|
||||
urlBlocklistCache.setStorageService(harness.storageService);
|
||||
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
await urlBlocklistCache.refresh();
|
||||
expect(urlBlocklistCache.isUrlBanned('https://feed.example/x')).toBe(false);
|
||||
expect(urlBlocklistCache.isUrlBanned('https://admin.example/y')).toBe(true);
|
||||
|
||||
Config.blocklistFeeds.enabled = true;
|
||||
await urlBlocklistCache.refresh();
|
||||
expect(urlBlocklistCache.isUrlBanned('https://feed.example/x')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import type {BannedFileShaRow} from '@app/api/database/types/AdminArchiveTypes';
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import syncDisposableEmailDomains from '@app/api/worker/tasks/SyncDisposableEmailDomains';
|
||||
import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists';
|
||||
import {
|
||||
clearWorkerDependencies,
|
||||
getWorkerDependencies,
|
||||
setWorkerDependenciesForTest,
|
||||
} from '@app/api/worker/WorkerContext';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const KV_TABLE = 'kv_blocklist_feeds_disabled';
|
||||
const CONTAINER = `fluxer-feeds-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
const FEED_SHA = 'a1'.repeat(32);
|
||||
const ADMIN_BAZAAR_SHA = 'b2'.repeat(32);
|
||||
const ADMIN_MANUAL_SHA = 'c3'.repeat(32);
|
||||
const ADMIN_USER_ID = 1_234_567_890_123n;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 4242n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function fileShaRow(sha256Hex: string, category: string, addedBy: bigint | null): BannedFileShaRow {
|
||||
return {
|
||||
sha256_hex: sha256Hex,
|
||||
category,
|
||||
severity: 2,
|
||||
content_type: null,
|
||||
source_url: null,
|
||||
added_at: new Date(),
|
||||
added_by: addedBy,
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('blocklist feeds turned off against postgres', () => {
|
||||
let raw: IPostgresClient;
|
||||
let executor: PostgresKvQueryExecutor;
|
||||
let previousFeedsEnabled: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
startDockerContainer([
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
'postgres:16-alpine',
|
||||
'-c',
|
||||
'fsync=off',
|
||||
]);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({
|
||||
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
|
||||
maxConnections: 4,
|
||||
kvTable: KV_TABLE,
|
||||
});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
raw = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(raw);
|
||||
executor = new PostgresKvQueryExecutor(raw);
|
||||
}, 900_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
previousFeedsEnabled = Config.blocklistFeeds.enabled;
|
||||
await raw.query(`DELETE FROM ${KV_TABLE}`);
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: new AdminRepository(),
|
||||
kvClient: new MockKVProvider(),
|
||||
storageService: new MockStorageService(),
|
||||
});
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.blocklistFeeds.enabled = previousFeedsEnabled;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
clearWorkerDependencies();
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('removes every disposable_email_domains row from the KV table', async () => {
|
||||
const repository = new AdminRepository();
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
await repository.addDisposableEmailDomain(`disposable-${i}.example`);
|
||||
}
|
||||
const seeded = await raw.query<{count: number}>(
|
||||
`SELECT count(*)::int AS count FROM ${KV_TABLE} WHERE table_name = 'disposable_email_domains'`,
|
||||
);
|
||||
expect(seeded.rows[0]?.count).toBe(1200);
|
||||
|
||||
await syncDisposableEmailDomains({}, createHelpers());
|
||||
|
||||
const remaining = await raw.query<{count: number}>(
|
||||
`SELECT count(*)::int AS count FROM ${KV_TABLE} WHERE table_name = 'disposable_email_domains'`,
|
||||
);
|
||||
expect(remaining.rows[0]?.count).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps file-SHA rows with added_by set and removes feed rows stored with a JSON null added_by', async () => {
|
||||
const repository = new AdminRepository();
|
||||
await repository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
|
||||
await repository.banFileSha(fileShaRow(ADMIN_BAZAAR_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID));
|
||||
await repository.banFileSha(fileShaRow(ADMIN_MANUAL_SHA, ContentBlocklistCategory.MANUAL, ADMIN_USER_ID));
|
||||
const feedRow = await raw.query<{added_by_type: string}>(
|
||||
`SELECT jsonb_typeof(row_data->'added_by') AS added_by_type FROM ${KV_TABLE}
|
||||
WHERE table_name = 'banned_file_shas' AND row_data->>'sha256_hex' = $1`,
|
||||
[FEED_SHA],
|
||||
);
|
||||
expect(feedRow.rows).toEqual([{added_by_type: 'null'}]);
|
||||
|
||||
await syncFileShaBlocklists({}, createHelpers());
|
||||
|
||||
const remaining = await raw.query<{sha: string}>(
|
||||
`SELECT row_data->>'sha256_hex' AS sha FROM ${KV_TABLE} WHERE table_name = 'banned_file_shas' ORDER BY 1`,
|
||||
);
|
||||
expect(remaining.rows.map((row) => row.sha)).toEqual([ADMIN_BAZAAR_SHA, ADMIN_MANUAL_SHA]);
|
||||
});
|
||||
|
||||
it('keeps a file-SHA ban an Admin took over after the purge read the table', async () => {
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
|
||||
const staleRows = await adminRepository.loadAllBannedFileShas();
|
||||
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID));
|
||||
const staleRead = vi.spyOn(adminRepository, 'loadAllBannedFileShas').mockResolvedValueOnce(staleRows);
|
||||
|
||||
await syncFileShaBlocklists({}, createHelpers());
|
||||
|
||||
staleRead.mockRestore();
|
||||
expect(await adminRepository.isFileShaBanned(FEED_SHA)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ function createQueue(params: {
|
||||
dlqExists?: boolean;
|
||||
reject?: (config: Partial<StreamConfig>) => Error | null;
|
||||
updateError?: Error;
|
||||
publish?: (subject: string) => {seq: number};
|
||||
publish?: (subject: string, body: string, options: {msgID: string}) => {seq: number; duplicate?: boolean};
|
||||
subjectCounts?: Record<string, number>;
|
||||
subjectCountsError?: Error;
|
||||
}): {
|
||||
@@ -171,9 +171,9 @@ function createQueue(params: {
|
||||
},
|
||||
}),
|
||||
getJetStreamClient: () => ({
|
||||
publish: (subject: string) => {
|
||||
publish: (subject: string, body: string, options: {msgID: string}) => {
|
||||
const publish = params.publish ?? (() => ({seq: 1}));
|
||||
return Promise.resolve(publish(subject));
|
||||
return Promise.resolve(publish(subject, body, options));
|
||||
},
|
||||
}),
|
||||
} as unknown as JetStreamConnectionManager;
|
||||
@@ -281,6 +281,27 @@ describe('jobs stream limits', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('jobs stream max age', () => {
|
||||
it('reports the max age of a jobs stream it creates', async () => {
|
||||
const {queue, added} = createQueue({existing: null});
|
||||
await queue.ensureStream();
|
||||
expect(queue.getJobsStreamMaxAgeMs()).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
expect(added[0]?.max_age).toBe(queue.getJobsStreamMaxAgeMs() * 1_000_000);
|
||||
});
|
||||
|
||||
it('reports the max age an existing jobs stream really has and never changes it', async () => {
|
||||
for (const [maxAgeNanos, expectedMs] of [
|
||||
[30 * 24 * 60 * 60 * 1_000_000_000, 30 * 24 * 60 * 60 * 1000],
|
||||
[0, 0],
|
||||
] as const) {
|
||||
const {queue, updated} = createQueue({existing: {...LEGACY_CONFIG, max_age: maxAgeNanos} as StreamConfig});
|
||||
await queue.ensureStream();
|
||||
expect(queue.getJobsStreamMaxAgeMs()).toBe(expectedMs);
|
||||
expect(updated.some((config) => 'max_age' in config)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dead-letter stream', () => {
|
||||
it('keeps startup alive when the dead-letter stream does not fit', async () => {
|
||||
const {queue, dlqAdded} = createQueue({dlqExists: false, reject: () => noStorageError()});
|
||||
@@ -298,16 +319,16 @@ describe('dead-letter stream', () => {
|
||||
describe('jobs stream enqueue shedding', () => {
|
||||
it('rejects enqueues once the stream is at its cap', async () => {
|
||||
const {queue} = createQueue({existing: LEGACY_CONFIG, publish: boundedPublisher(2)});
|
||||
await expect(queue.enqueue('extractEmbeds', {})).resolves.toBe('1');
|
||||
await expect(queue.enqueue('extractEmbeds', {})).resolves.toBe('2');
|
||||
await expect(queue.enqueue('extractEmbeds', {})).resolves.toEqual({seq: '1', duplicate: false});
|
||||
await expect(queue.enqueue('extractEmbeds', {})).resolves.toEqual({seq: '2', duplicate: false});
|
||||
await expect(queue.enqueue('extractEmbeds', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
|
||||
});
|
||||
|
||||
it('caps each task type independently', async () => {
|
||||
const {queue} = createQueue({existing: LEGACY_CONFIG, publish: boundedPublisher(1)});
|
||||
await expect(queue.enqueue('extractEmbeds', {})).resolves.toBe('1');
|
||||
await expect(queue.enqueue('extractEmbeds', {})).resolves.toEqual({seq: '1', duplicate: false});
|
||||
await expect(queue.enqueue('extractEmbeds', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
|
||||
await expect(queue.enqueue('handleMentions', {})).resolves.toBe('2');
|
||||
await expect(queue.enqueue('handleMentions', {})).resolves.toEqual({seq: '2', duplicate: false});
|
||||
});
|
||||
|
||||
it('sheds enqueues the server refuses for lack of resources', async () => {
|
||||
@@ -320,6 +341,29 @@ describe('jobs stream enqueue shedding', () => {
|
||||
await expect(queue.enqueue('handleMentions', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
|
||||
});
|
||||
|
||||
it('reports a publish the stream deduplicated under the same job key', async () => {
|
||||
const seen = new Map<string, number>();
|
||||
const {queue} = createQueue({
|
||||
existing: LEGACY_CONFIG,
|
||||
publish: (_subject, _body, options) => {
|
||||
const existing = seen.get(options.msgID);
|
||||
if (existing !== undefined) return {seq: existing, duplicate: true};
|
||||
seen.set(options.msgID, seen.size + 1);
|
||||
return {seq: seen.size, duplicate: false};
|
||||
},
|
||||
});
|
||||
const options = {jobKey: 'batch-audit-log-message-deletes:1'};
|
||||
await expect(queue.enqueue('batchGuildAuditLogMessageDeletes', {}, options)).resolves.toEqual({
|
||||
seq: '1',
|
||||
duplicate: false,
|
||||
});
|
||||
await expect(queue.enqueue('batchGuildAuditLogMessageDeletes', {}, options)).resolves.toEqual({
|
||||
seq: '1',
|
||||
duplicate: true,
|
||||
});
|
||||
await expect(queue.enqueue('batchGuildAuditLogMessageDeletes', {})).resolves.toEqual({seq: '2', duplicate: false});
|
||||
});
|
||||
|
||||
it('rethrows publish failures that are not stream limits', async () => {
|
||||
const failure = new Error('no responders');
|
||||
const {queue} = createQueue({
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
|
||||
import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
|
||||
import {User} from '@app/api/models/User';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
@@ -13,6 +16,77 @@ import {afterEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const USER_ID = createUserID(834271905123471361n);
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
|
||||
|
||||
function createPremiumUser(overrides: Partial<UserRow> = {}): User {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: USER_ID,
|
||||
username: 'paidlate',
|
||||
discriminator: 1,
|
||||
premium_type: 1,
|
||||
premium_since: new Date(Date.now() - 200 * ONE_DAY_MS),
|
||||
premium_until: new Date(Date.now() + 20 * ONE_DAY_MS),
|
||||
stripe_customer_id: 'cus_test',
|
||||
stripe_subscription_id: 'sub_test',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createCancelledSubscription(endedAtMs: number): Stripe.Subscription {
|
||||
return {
|
||||
id: 'sub_test',
|
||||
status: 'canceled',
|
||||
customer: 'cus_test',
|
||||
ended_at: Math.floor(endedAtMs / 1000),
|
||||
canceled_at: Math.floor(endedAtMs / 1000),
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
trial_end: null,
|
||||
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
|
||||
items: {data: []},
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
|
||||
return {
|
||||
id: 'in_test',
|
||||
status: 'paid',
|
||||
lines: {data: [{period: {start: 0, end: Math.floor(periodEndMs / 1000)}}]},
|
||||
} as unknown as Stripe.Invoice;
|
||||
}
|
||||
|
||||
function createStripeStub(subscription: Stripe.Subscription, invoices: Array<Stripe.Invoice>): Stripe {
|
||||
return {
|
||||
subscriptions: {
|
||||
retrieve: async () => subscription,
|
||||
list: async () => ({data: [subscription]}),
|
||||
},
|
||||
invoices: {
|
||||
list: async () => ({data: invoices}),
|
||||
},
|
||||
} as unknown as Stripe;
|
||||
}
|
||||
|
||||
function createCapturingDeps(user: User): {
|
||||
userRepository: UserRepository;
|
||||
patches: Array<Partial<UserRow>>;
|
||||
extras: Record<string, unknown>;
|
||||
} {
|
||||
const patches: Array<Partial<UserRow>> = [];
|
||||
const userRepository = {
|
||||
findUnique: async () => user,
|
||||
patchUpsert: async (_id: unknown, patch: Partial<UserRow>) => {
|
||||
patches.push(patch);
|
||||
return new User({...user.toRow(), ...patch});
|
||||
},
|
||||
} as unknown as UserRepository;
|
||||
const extras = {
|
||||
userCacheService: {setUserPartialResponseFromUserInBackground: () => {}},
|
||||
gatewayService: {dispatchPresence: async () => {}},
|
||||
};
|
||||
return {userRepository, patches, extras};
|
||||
}
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
@@ -151,4 +225,78 @@ describe('processPremiumStateReconciliationQueue', () => {
|
||||
expect(findUniqueCalls).toBe(1);
|
||||
expect(await queueService.getQueueSize()).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps premium when a paid invoice covers a period beyond the cancellation', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const paidThroughMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000;
|
||||
const user = createPremiumUser({premium_until: new Date(paidThroughMs)});
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
const patch = patches[0];
|
||||
expect(patch.premium_type).toBeUndefined();
|
||||
expect(patch.premium_since).toBeUndefined();
|
||||
expect(patch.premium_until).toBeUndefined();
|
||||
expect(patch.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('extends premium to the paid period instead of the cancellation date', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const paidThroughMs = Date.now() + 20 * ONE_DAY_MS;
|
||||
const user = createPremiumUser({premium_until: new Date(Date.now() + 5 * ONE_DAY_MS)});
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].premium_until).toEqual(new Date(Math.floor(paidThroughMs / 1000) * 1000));
|
||||
expect(patches[0].premium_will_cancel).toBe(true);
|
||||
expect(patches[0].premium_type).toBeUndefined();
|
||||
});
|
||||
|
||||
test('still strips premium when no paid invoice covers a future period', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const paidThroughMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const user = createPremiumUser();
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].premium_type).toBeNull();
|
||||
expect(patches[0].premium_until).toBeNull();
|
||||
expect(patches[0].premium_since).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {WorkerService} from '@app/api/worker/WorkerService';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const JOB_ID = 4242n;
|
||||
const CREATED_AT = new Date('2026-09-21T12:00:00.000Z');
|
||||
|
||||
function createSnowflake(): ISnowflakeService {
|
||||
return {
|
||||
@@ -14,17 +15,24 @@ function createSnowflake(): ISnowflakeService {
|
||||
} as unknown as ISnowflakeService;
|
||||
}
|
||||
|
||||
function createHarness(options?: {createJobError?: Error; enqueueError?: Error}) {
|
||||
function createHarness(options?: {
|
||||
createJobError?: Error;
|
||||
enqueueError?: Error;
|
||||
duplicate?: boolean;
|
||||
discardError?: Error;
|
||||
}) {
|
||||
const calls: Array<string> = [];
|
||||
const createdJobs: Array<CreateJobInput> = [];
|
||||
const enqueued: Array<{taskType: string; payload: Record<string, unknown>}> = [];
|
||||
const seqUpdates: Array<{jobId: bigint; seq: string}> = [];
|
||||
const deadletters: Array<{jobId: bigint; errorMessage: string}> = [];
|
||||
const discarded: Array<{jobId: bigint; createdAt: Date}> = [];
|
||||
const ledger = {
|
||||
createJob: async (input: CreateJobInput) => {
|
||||
calls.push('createJob');
|
||||
if (options?.createJobError) throw options.createJobError;
|
||||
createdJobs.push(input);
|
||||
return CREATED_AT;
|
||||
},
|
||||
setJetStreamSeq: async (jobId: bigint, seq: string) => {
|
||||
calls.push('setJetStreamSeq');
|
||||
@@ -34,17 +42,22 @@ function createHarness(options?: {createJobError?: Error; enqueueError?: Error})
|
||||
calls.push('markDeadletter');
|
||||
deadletters.push({jobId, errorMessage});
|
||||
},
|
||||
discardJob: async (jobId: bigint, createdAt: Date) => {
|
||||
calls.push('discardJob');
|
||||
if (options?.discardError) throw options.discardError;
|
||||
discarded.push({jobId, createdAt});
|
||||
},
|
||||
} as unknown as IJobLedgerRepository;
|
||||
const queue = {
|
||||
enqueue: async (taskType: string, payload: Record<string, unknown>) => {
|
||||
calls.push('enqueue');
|
||||
if (options?.enqueueError) throw options.enqueueError;
|
||||
enqueued.push({taskType, payload});
|
||||
return 'seq-9';
|
||||
return {seq: 'seq-9', duplicate: options?.duplicate === true};
|
||||
},
|
||||
} as unknown as JetStreamWorkerQueue;
|
||||
const service = new WorkerService(queue, createSnowflake(), ledger);
|
||||
return {service, calls, createdJobs, enqueued, seqUpdates, deadletters};
|
||||
return {service, calls, createdJobs, enqueued, seqUpdates, deadletters, discarded};
|
||||
}
|
||||
|
||||
describe('WorkerService ledger ordering', () => {
|
||||
@@ -88,6 +101,42 @@ describe('WorkerService ledger ordering', () => {
|
||||
expect(harness.deadletters).toEqual([{jobId: JOB_ID, errorMessage: 'stream unreachable'}]);
|
||||
});
|
||||
|
||||
test('discards the ledger row when the stream already holds a job under the same key', async () => {
|
||||
const harness = createHarness({duplicate: true});
|
||||
|
||||
const jobId = await harness.service.addJob(
|
||||
'batchGuildAuditLogMessageDeletes',
|
||||
{guildId: '1'},
|
||||
{jobKey: 'batch-audit-log-message-deletes:1'},
|
||||
);
|
||||
|
||||
expect(jobId).toBe(JOB_ID);
|
||||
expect(harness.calls).toEqual(['createJob', 'enqueue', 'discardJob']);
|
||||
expect(harness.discarded).toEqual([{jobId: JOB_ID, createdAt: CREATED_AT}]);
|
||||
expect(harness.seqUpdates).toEqual([]);
|
||||
});
|
||||
|
||||
test('still returns the job id when discarding the duplicate ledger row fails', async () => {
|
||||
const harness = createHarness({duplicate: true, discardError: new Error('write timeout')});
|
||||
|
||||
const jobId = await harness.service.addJob(
|
||||
'batchGuildAuditLogMessageDeletes',
|
||||
{guildId: '1'},
|
||||
{jobKey: 'batch-audit-log-message-deletes:1'},
|
||||
);
|
||||
|
||||
expect(jobId).toBe(JOB_ID);
|
||||
expect(harness.calls).toEqual(['createJob', 'enqueue', 'discardJob']);
|
||||
});
|
||||
|
||||
test('does not touch the ledger for a duplicate the caller never ledgered', async () => {
|
||||
const harness = createHarness({duplicate: true});
|
||||
|
||||
await harness.service.addJob('handleMentions', {}, {skipLedger: true, jobKey: 'mentions:1'});
|
||||
|
||||
expect(harness.calls).toEqual(['enqueue']);
|
||||
});
|
||||
|
||||
test('never touches the ledger when the caller skips it', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
|
||||
@@ -282,5 +282,5 @@
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
"packageManager": "pnpm@11.27.0"
|
||||
}
|
||||
|
||||
@@ -14,84 +14,6 @@ Every file went through the same mechanical steps, so a fresh upstream tree can
|
||||
6. The tree is made to pass `tsgo --noEmit` under `fluxer_app/tsconfig.json`. That means `override` modifiers, explicit `return undefined`, typed `let` declarations, no async promise executors, literal enum members and no unused `@ts-expect-error` directives.
|
||||
7. `@livekit/throws-transformer/throws` imports point at `src/utils/throws.ts`, which copies its type definitions, so the dev-only package is not a dependency.
|
||||
|
||||
## Fluxer modifications
|
||||
|
||||
1. **AV1 end-to-end encryption** (`src/e2ee/worker/av1Utils.ts`, `FrameCryptor.ts`, `src/room/track/utils.ts`)
|
||||
|
||||
OBU-level AV1 encryption and decryption with an E2EE metadata OBU. `video/AV1X` maps to `av1`.
|
||||
|
||||
2. **Cryptor context for reused senders** (`src/e2ee/E2eeManager.ts`, `src/e2ee/worker/FrameCryptor.ts`)
|
||||
|
||||
Upstream 2.22 tracks the id of a reused receiver in `E2EE_TRACK_ID` and sends `updateCodec` with `previousTrackId`. Fluxer applies the same to senders, passes the codec and track id from `LocalSenderCreated`, and resets codec detection whenever a cryptor moves to another track id or codec. This replaces the old `updateTrackContext` message and `get/setE2EETransformState`.
|
||||
|
||||
3. **E2EE frame guards** (`src/e2ee/worker/FrameCryptor.ts`, `naluUtils.ts`)
|
||||
|
||||
Trailer, IV, tag and clear-prefix bounds are checked before any view is built, and malformed frames are dropped without ending the transform. NALU clear bytes are clamped to the frame length. H.265 slices keep 3 clear bytes where upstream keeps 2. The SIF trailer check is bounds safe.
|
||||
|
||||
4. **No plaintext before the encryption state is known** (`src/e2ee/worker/FrameCryptor.ts`)
|
||||
|
||||
The encoder drops frames while the participant's encryption state is unknown. Upstream passes them through. Upstream 2.22 already drops them on decode.
|
||||
|
||||
5. **Screen share scalability mode** (`src/room/participant/LocalParticipant.ts`)
|
||||
|
||||
A caller-supplied `scalabilityMode` is kept for screen shares. Upstream forces `L1T3` and `contentHint = 'motion'`. `L3T3_KEY` is only defaulted for cameras, and SVC layers are only sent to the server when an encoding has a scalability mode.
|
||||
|
||||
6. **Encrypted backup codec publishing** (`src/room/participant/LocalParticipant.ts`, `src/e2ee/E2eeManager.ts`)
|
||||
|
||||
Backup codecs are advertised and published while E2EE is on. The simulcast sender emits `LocalSenderCreated` with its codec and track id so it gets its own transform. A failed backup codec publish stops the cloned track and is logged instead of thrown.
|
||||
|
||||
7. **Publisher codec preferences** (`src/room/RTCEngine.ts`)
|
||||
|
||||
`setCodecPreferences()` is applied to publisher transceivers so the browser's SDP follows the selected primary or backup codec. H.264 profiles rank Baseline `42001f` first, then Constrained Baseline `42e01f`, then everything else. Main, High and Constrained High rank last on purpose.
|
||||
|
||||
livekit-server registers H.264 High `640032` on the publisher peer connection but filters it off the subscriber peer connection, and its `CodecParametersFuzzySearch` falls back to a mime-only match. A High publication therefore reaches subscribers under their `42e01f` payload type and decodes to nothing on a Constrained-Baseline-only decoder such as Firefox's OpenH264 GMP. `42001f` is the one profile Chromium's accelerated encoder factory advertises that such a decoder can still handle, because Chromium's VAAPI encoder and OpenH264 both write a Constrained Baseline SPS for `H264PROFILE_BASELINE`.
|
||||
|
||||
The trade is that livekit-server does not register `42001f` either, so Windows and macOS negotiate `42e01f`, which Chromium's accelerated encoder factory does not advertise there (`kPlatformH264CbpEncoding` is off by default on Windows, and `IsH264ConstrainedBaselineProfileAvailableForAcceleratedEncoder` returns false on Apple). Those publishers fall back to software H.264, which is what an unpatched browser does anyway. Linux, ChromeOS and Android keep hardware encoding.
|
||||
|
||||
8. **Media publishing defaults** (`src/room/defaults.ts`, `src/room/utils.ts`, `src/room/track/options.ts`)
|
||||
|
||||
Codec fallback follows actual sender capabilities in the order H.264, VP9, VP8, AV1, HEVC through `supportsVideoCodec()` and `selectPreferredVideoCodec()`. HEVC is never reported on Firefox. Advanced codecs get an H.264 backup simulcast, dynacast is on, DTX is off, degradation preference is `maintain-resolution`, and screen shares default to the `original` preset at 20 Mbps and 60 fps. `musicHighQualityStereo` is 510 kbps. AV1 and HEVC come last because both are opt-in in Fluxer, so a fallback inside `publishTrack` must not land on a codec the user did not enable.
|
||||
|
||||
9. **High-fidelity Opus SDP munging** (`src/room/PCTransport.ts`, `src/room/participant/LocalParticipant.ts`)
|
||||
|
||||
Local offers and remote answers force Opus RED and FEC, 10 ms packet time, no DTX and a 510 kbps maximum average bitrate. `stereo=1` and `sprop-stereo=1` are only added for publications whose `TrackBitrateInfo.stereo` is set and for the subscriber mids the server advertised as stereo. Audio bitrates are registered on every browser, not only Firefox. The initial offer sent with the join request is not munged, as upstream does not munge it either.
|
||||
|
||||
10. **Remote audio volume held at zero** (`src/room/track/RemoteAudioTrack.ts`)
|
||||
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` test `elementVolume !== undefined`, so a track held at `0` stays silent after re-attach. Remote gains above `1.0` rely on the Web Audio `gainNode` branch, so `webAudioMix` must stay on.
|
||||
|
||||
11. **Processor teardown before source stop** (`src/room/track/LocalTrack.ts`)
|
||||
|
||||
`stop()` detaches and destroys the processor before `super.stop()` ends the source track.
|
||||
|
||||
12. **Transactional source and processor swaps** (`src/room/track/LocalTrack.ts`, `LocalVideoTrack.ts`, `LocalAudioTrack.ts`)
|
||||
|
||||
`setMediaStreamTrack()` takes `SetMediaStreamTrackOptions` (`force`, `deferEndedListener`, `preservePreviousTrack`, `isUnmuting`) and restores the previous source, constraints, `enabled` state, listeners, processor and sender when a swap fails. `stageTrackReplacement()` and `commitStagedTrackReplacement()` give a two-phase swap. `runWithTrackChangeLock()` and `stopProcessorIfCurrent()` are public for the app. Secondary simulcast senders get the processed track, and processor install and teardown roll back every sender. Upstream's `onSenderTrackSwapped()` hook runs after a successful `replaceTrack`, `stageTrackReplacement`, `setProcessor`, `stopProcessor` and `stopProcessorIfCurrent`.
|
||||
|
||||
13. **Track bitrate entries are replaced** (`src/room/PCTransport.ts`)
|
||||
|
||||
`setTrackCodecBitrate()` replaces the entry for the same cid or transceiver instead of appending, because `trackBitrates` is never cleared. Start bitrates for every video codec are upstream since 2.22 through `applyVideoStartBitrate()`.
|
||||
|
||||
14. **Subscriber video decoder exclusions** (`src/options.ts`, `src/room/RTCEngine.ts`, `src/room/PCTransportManager.ts`, `src/room/PCTransport.ts`)
|
||||
|
||||
`RoomOptions.subscriberVideoCodecExclusions` removes codecs from receive transceivers with `setCodecPreferences()` before every offer and answer, including the initial offer sent with the join request. Firefox and Safari are skipped.
|
||||
|
||||
15. **ICE diagnostics** (`src/room/PCTransportManager.ts`)
|
||||
|
||||
ICE candidate errors are logged once per transport, code, URL and address until the next ICE restart. The transport connect timeout names each transport's connection, ICE and signalling state.
|
||||
|
||||
16. **Screen share capture options** (`src/room/track/options.ts`, `src/room/track/utils.ts`)
|
||||
|
||||
`ScreenShareCaptureOptions` has `windowAudio`, `monitorTypeSurfaces` and `restrictOwnAudio`. `restrictOwnAudio` and `suppressLocalAudioPlayback` are passed as audio constraints.
|
||||
|
||||
17. **Stats and monitors** (`src/room/stats.ts`, `src/room/track/Track.ts`, `LocalVideoTrack.ts`, `RemoteVideoTrack.ts`, `RemoteTrack.ts`, `LocalAudioTrack.ts`)
|
||||
|
||||
Sender stats have `encoderImplementation` and `powerEfficientEncoder`, receiver stats have `powerEfficientDecoder`. `runMonitor()` skips a stats poll while the previous one is still running. Adaptive stream tracks that are not visible skip receiver stats. `setPublishingLayersForSender` writes `maxFramerate` where upstream writes a misspelt `maxFrameRate`, and `videoLayersFromEncodings` handles an SVC codec without a scalability mode.
|
||||
|
||||
18. **Smaller fixes** (`src/api/SignalClient.ts`, `src/api/utils.ts`, `src/room/Room.ts`, `src/room/timers.ts`, `src/room/utils.ts`, `src/e2ee/worker/e2ee.worker.ts`)
|
||||
|
||||
The abort path passes its reason to `SignalClient.close()` as the reason instead of `updateState`. `handleOnClose` is public so `simulateScenario` needs no suppression. Queue bypass checks no longer log every request. Abort reasons that are numbers, booleans, bigints or symbols become strings. `Room` connect rejections are always `Error` instances. `CriticalTimers` binds the native timers when the module loads. `Future` rejects when its executor throws. The e2ee worker listens with `addEventListener`, and the outgoing data stream sink no longer logs aborts to `console`.
|
||||
|
||||
## Removed upstream code
|
||||
|
||||
`connectionHelper`, `createLocalScreenTracks`, `getStereoAudioStreamTrack`, `isLocalPub`, the frame metadata worker, `room/token-source/test-tokens.ts`, `utils/subscribeToEvents.ts`, and the `DataTrackPacket`, data track packet extension and `LocalTrackRecorder` exports. `createV0RtcUrl`, `truncateBytes`, `videoQualityForRid` and `STOP_REFETCH_DELAY_MS` are no longer exported.
|
||||
@@ -101,6 +23,6 @@ Every file went through the same mechanical steps, so a fresh upstream tree can
|
||||
1. Read the upstream changelog for the target version.
|
||||
2. Run the source transforms on the old tag and on the new tag. Keep the old result as the merge base and the new result as the other side.
|
||||
3. Strip blank lines from the base, this tree and the new tree, run `git merge-file --diff3 --diff-algorithm=histogram` per file, and put blank lines back from the new tree. Blank-line drift between the trees otherwise turns into conflicts.
|
||||
4. Resolve conflicts with the modification list above. Where upstream rewrote a function that carries a Fluxer change, start from the upstream function and reapply the change.
|
||||
5. Diff the result against the transformed new tag after compiling both to JavaScript without types. Every hunk should belong to a modification listed here.
|
||||
4. Resolve conflicts by starting from the upstream function and reapplying whatever this tree changed.
|
||||
5. Diff the result against the transformed new tag after compiling both to JavaScript without types. Review every hunk.
|
||||
6. Run `tsgo --noEmit` under the app tsconfig, `biome check`, `knip` and `pnpm --filter livekit-client test`, then update the version in `package.json` and this file.
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface InternalRoomOptions {
|
||||
|
||||
singlePeerConnection: boolean;
|
||||
subscriberVideoCodecExclusions?: Array<VideoCodec>;
|
||||
screenShareDelivery?: boolean;
|
||||
h264HardwareProfiles?: ReadonlySet<string>;
|
||||
dataStream?: RoomDataStreamOptions;
|
||||
}
|
||||
export interface RoomDataStreamOptions {
|
||||
|
||||
@@ -55,44 +55,110 @@ function opusConfig(media: MediaDescription): string {
|
||||
}
|
||||
|
||||
describe('applyVideoStartBitrate', () => {
|
||||
it('adds a start bitrate to a non-SVC codec section', () => {
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'},
|
||||
]);
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'H264', 1000)).toBe(96);
|
||||
expect(media.fmtp[0]?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f;x-google-start-bitrate=900',
|
||||
function multiPayloadScreenMedia(): MediaDescription {
|
||||
return videoMedia(
|
||||
'screen-track',
|
||||
[
|
||||
{payload: 116, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f'},
|
||||
{payload: 102, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f'},
|
||||
{payload: 108, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'},
|
||||
],
|
||||
[
|
||||
{payload: 116, codec: 'H264'},
|
||||
{payload: 117, codec: 'rtx'},
|
||||
{payload: 102, codec: 'H264'},
|
||||
{payload: 108, codec: 'H264'},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
it('adds a start bitrate to a non-SVC codec section', () => {
|
||||
for (const screenShareDelivery of [false, true]) {
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'},
|
||||
]);
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'H264', 1000, false, screenShareDelivery)).toBe(96);
|
||||
expect(media.fmtp[0]?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f;x-google-start-bitrate=900',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('caps camera start bitrates but not screen share start bitrates', () => {
|
||||
it('caps camera start bitrates but not screen share start bitrates while screen share delivery is off', () => {
|
||||
const camera = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000);
|
||||
expect(camera.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
|
||||
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 3000, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=2700');
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=5400');
|
||||
});
|
||||
|
||||
it('caps camera and screen share start bitrates at their own ceilings', () => {
|
||||
const camera = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000, false, true);
|
||||
expect(camera.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
|
||||
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1500');
|
||||
});
|
||||
|
||||
it('leaves a small screen share start bitrate on the floor while screen share delivery is off', () => {
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=270');
|
||||
});
|
||||
|
||||
it('keeps a screen share start bitrate above the frame dropper cliff', () => {
|
||||
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true, true);
|
||||
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=600');
|
||||
});
|
||||
|
||||
it('stamps only the lead payload type while screen share delivery is off', () => {
|
||||
const media = multiPayloadScreenMedia();
|
||||
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true)).toBe(116);
|
||||
expect(media.fmtp.find((fmtp) => fmtp.payload === 116)?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f;x-google-start-bitrate=5400',
|
||||
);
|
||||
expect(media.fmtp.find((fmtp) => fmtp.payload === 102)?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
|
||||
);
|
||||
expect(media.fmtp.find((fmtp) => fmtp.payload === 108)?.config).toBe(
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
});
|
||||
|
||||
it('stamps every payload type the codec is offered under, not only the lead one', () => {
|
||||
const media = multiPayloadScreenMedia();
|
||||
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true, true)).toBe(116);
|
||||
for (const fmtp of media.fmtp) {
|
||||
expect(fmtp.config).toContain('x-google-start-bitrate=1500');
|
||||
}
|
||||
expect(media.fmtp.some((fmtp) => fmtp.payload === 117)).toBe(false);
|
||||
});
|
||||
|
||||
it('only touches the fmtp line for the matching payload', () => {
|
||||
const media = videoMedia(
|
||||
'screen-track',
|
||||
[
|
||||
{payload: 96, config: 'profile-level-id=42e01f'},
|
||||
{payload: 98, config: 'profile-id=0'},
|
||||
],
|
||||
[
|
||||
{payload: 96, codec: 'H264'},
|
||||
{payload: 98, codec: 'VP9'},
|
||||
],
|
||||
);
|
||||
applyVideoStartBitrate(media, 'screen-track', 'VP9', 1500, true);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
expect(media.fmtp[1]?.config).toBe('profile-id=0;x-google-start-bitrate=1350');
|
||||
for (const screenShareDelivery of [false, true]) {
|
||||
const media = videoMedia(
|
||||
'screen-track',
|
||||
[
|
||||
{payload: 96, config: 'profile-level-id=42e01f'},
|
||||
{payload: 98, config: 'profile-id=0'},
|
||||
],
|
||||
[
|
||||
{payload: 96, codec: 'H264'},
|
||||
{payload: 98, codec: 'VP9'},
|
||||
],
|
||||
);
|
||||
applyVideoStartBitrate(media, 'screen-track', 'VP9', 1500, true, screenShareDelivery);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
expect(media.fmtp[1]?.config).toBe('profile-id=0;x-google-start-bitrate=1350');
|
||||
}
|
||||
});
|
||||
|
||||
it('never appends a second start bitrate', () => {
|
||||
it('never appends a second start bitrate while screen share delivery is off', () => {
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'profile-level-id=42e01f;x-google-start-bitrate=900'},
|
||||
]);
|
||||
@@ -100,11 +166,21 @@ describe('applyVideoStartBitrate', () => {
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=900');
|
||||
});
|
||||
|
||||
it('replaces a start bitrate an earlier offer wrote instead of keeping it', () => {
|
||||
const media = videoMedia('camera-track', [
|
||||
{payload: 96, config: 'profile-level-id=42e01f;x-google-start-bitrate=900'},
|
||||
]);
|
||||
applyVideoStartBitrate(media, 'camera-track', 'H264', 2000, false, true);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
|
||||
});
|
||||
|
||||
it('leaves other tracks and missing codecs alone', () => {
|
||||
const media = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
expect(applyVideoStartBitrate(media, 'other-track', 'H264', 2000)).toBeUndefined();
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'AV1', 2000)).toBe(0);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
for (const screenShareDelivery of [false, true]) {
|
||||
const media = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
|
||||
expect(applyVideoStartBitrate(media, 'other-track', 'H264', 2000, false, screenShareDelivery)).toBeUndefined();
|
||||
expect(applyVideoStartBitrate(media, 'camera-track', 'AV1', 2000, false, screenShareDelivery)).toBe(0);
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ const startBitrateMultiplier = 0.9;
|
||||
|
||||
const maxStartBitrateKbps = 1000;
|
||||
|
||||
const maxScreenShareStartBitrateKbps = 1500;
|
||||
|
||||
const minScreenShareStartBitrateKbps = 600;
|
||||
|
||||
const startBitrateParameter = 'x-google-start-bitrate';
|
||||
|
||||
const debounceInterval = 20;
|
||||
|
||||
const opusMaxAverageBitrateBps = 510000;
|
||||
@@ -43,32 +49,49 @@ export function applyVideoStartBitrate(
|
||||
codec: string,
|
||||
maxbr: number,
|
||||
isScreenShare = false,
|
||||
screenShareDelivery = false,
|
||||
): number | undefined {
|
||||
if (!media.msid?.includes(cid)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const codecPayload = media.rtp.find((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())?.payload ?? 0;
|
||||
if (codecPayload === 0) {
|
||||
const codecPayloads = media.rtp
|
||||
.filter((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())
|
||||
.map((rtp) => rtp.payload);
|
||||
if (codecPayloads.length === 0 || codecPayloads[0] === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const calculatedStartBitrate = Math.round(maxbr * startBitrateMultiplier);
|
||||
const startBitrate = isScreenShare ? calculatedStartBitrate : Math.min(calculatedStartBitrate, maxStartBitrateKbps);
|
||||
|
||||
const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
|
||||
if (fmtp) {
|
||||
if (!fmtp.config.includes('x-google-start-bitrate')) {
|
||||
fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
|
||||
}
|
||||
} else {
|
||||
media.fmtp.push({
|
||||
payload: codecPayload,
|
||||
config: `x-google-start-bitrate=${startBitrate}`,
|
||||
});
|
||||
let startBitrate = Math.min(calculatedStartBitrate, maxStartBitrateKbps);
|
||||
if (isScreenShare) {
|
||||
startBitrate = screenShareDelivery
|
||||
? Math.max(minScreenShareStartBitrateKbps, Math.min(calculatedStartBitrate, maxScreenShareStartBitrateKbps))
|
||||
: calculatedStartBitrate;
|
||||
}
|
||||
|
||||
return codecPayload;
|
||||
if (!screenShareDelivery) {
|
||||
const codecPayload = codecPayloads[0];
|
||||
const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
|
||||
if (fmtp) {
|
||||
if (!fmtp.config.includes(startBitrateParameter)) {
|
||||
fmtp.config += `;${startBitrateParameter}=${startBitrate}`;
|
||||
}
|
||||
} else {
|
||||
media.fmtp.push({
|
||||
payload: codecPayload,
|
||||
config: `${startBitrateParameter}=${startBitrate}`,
|
||||
});
|
||||
}
|
||||
return codecPayload;
|
||||
}
|
||||
|
||||
for (const payload of codecPayloads) {
|
||||
const fmtp = ensureFmtp(media, payload);
|
||||
fmtp.config = setFmtpParameter(fmtp.config, startBitrateParameter, String(startBitrate));
|
||||
}
|
||||
|
||||
return codecPayloads[0];
|
||||
}
|
||||
|
||||
export const PCEvents = {
|
||||
@@ -120,6 +143,8 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
|
||||
excludedVideoDecoderMimeTypes: Set<string> = new Set();
|
||||
|
||||
private screenShareDelivery: boolean;
|
||||
|
||||
onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void;
|
||||
|
||||
onIceCandidate?: (candidate: RTCIceCandidate) => void;
|
||||
@@ -136,9 +161,10 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
|
||||
onTrack?: (ev: RTCTrackEvent) => void;
|
||||
|
||||
constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
|
||||
constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}, screenShareDelivery: boolean = false) {
|
||||
super();
|
||||
this.loggerOptions = loggerOptions;
|
||||
this.screenShareDelivery = screenShareDelivery;
|
||||
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCTransport, () => this.logContext);
|
||||
this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
|
||||
this.config = config;
|
||||
@@ -386,6 +412,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
trackbr.codec,
|
||||
trackbr.maxbr,
|
||||
trackbr.isScreenShare,
|
||||
this.screenShareDelivery,
|
||||
);
|
||||
if (codecPayload === undefined) {
|
||||
return false;
|
||||
@@ -444,7 +471,10 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
for (const transceiver of this.getTransceivers()) {
|
||||
if (transceiver.receiver.track?.kind !== 'video') continue;
|
||||
if ((transceiver as {stopped?: boolean}).stopped) continue;
|
||||
if (transceiver.direction !== 'recvonly' && transceiver.direction !== 'sendrecv') continue;
|
||||
const receives = this.screenShareDelivery
|
||||
? transceiver.direction === 'recvonly'
|
||||
: transceiver.direction === 'recvonly' || transceiver.direction === 'sendrecv';
|
||||
if (!receives) continue;
|
||||
if (typeof transceiver.setCodecPreferences !== 'function') continue;
|
||||
try {
|
||||
transceiver.setCodecPreferences(allowed);
|
||||
|
||||
@@ -98,6 +98,7 @@ export class PCTransportManager {
|
||||
loggerOptions: LoggerOptions,
|
||||
rtcConfig?: RTCConfiguration,
|
||||
subscriberVideoCodecExclusions?: Array<VideoCodec>,
|
||||
screenShareDelivery: boolean = false,
|
||||
) {
|
||||
this.loggerOptions = loggerOptions;
|
||||
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager, () => this.logContext);
|
||||
@@ -105,15 +106,10 @@ export class PCTransportManager {
|
||||
|
||||
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
|
||||
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
|
||||
this.publisher = new PCTransport(rtcConfig, loggerOptions);
|
||||
this.publisher = new PCTransport(rtcConfig, loggerOptions, screenShareDelivery);
|
||||
this._mode = mode;
|
||||
if (mode !== 'publisher-only') {
|
||||
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
|
||||
if (subscriberVideoCodecExclusions?.length) {
|
||||
for (const codec of subscriberVideoCodecExclusions) {
|
||||
this.subscriber.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
|
||||
}
|
||||
}
|
||||
this.subscriber = new PCTransport(rtcConfig, loggerOptions, screenShareDelivery);
|
||||
this.subscriber.onConnectionStateChange = this.updateState;
|
||||
this.subscriber.onIceConnectionStateChange = this.updateState;
|
||||
this.subscriber.onSignalingStatechange = this.updateState;
|
||||
@@ -131,6 +127,13 @@ export class PCTransportManager {
|
||||
};
|
||||
}
|
||||
|
||||
const receivingTransport = screenShareDelivery ? (this.subscriber ?? this.publisher) : this.subscriber;
|
||||
if (receivingTransport) {
|
||||
for (const codec of subscriberVideoCodecExclusions ?? []) {
|
||||
receivingTransport.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.publisher.onConnectionStateChange = this.updateState;
|
||||
this.publisher.onIceConnectionStateChange = this.updateState;
|
||||
this.publisher.onSignalingStatechange = this.updateState;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {InternalRoomOptions} from '../options.ts';
|
||||
import {EngineEvent} from './events.ts';
|
||||
import RTCEngine, {selectPublisherCodecPreferences} from './RTCEngine.ts';
|
||||
|
||||
function codec(
|
||||
@@ -19,20 +20,51 @@ function codec(
|
||||
}
|
||||
|
||||
describe('selectPublisherCodecPreferences', () => {
|
||||
it('offers the profiles a hardware encoder accepts before Constrained Baseline, which is always software on Windows', () => {
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const baseline = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f');
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f');
|
||||
const constrainedBaselineLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f';
|
||||
const baselineLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f';
|
||||
const mainLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f';
|
||||
const highLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f';
|
||||
|
||||
it('keeps Main and Baseline ahead of Constrained Baseline while screen share delivery is off', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [constrainedBaseline, rtx, baseline, highProfile]);
|
||||
expect(preferences).toEqual([highProfile, baseline, constrainedBaseline, rtx]);
|
||||
});
|
||||
|
||||
it('offers High first out of the capabilities Chromium reports, so the only hardware profile this server registers wins', () => {
|
||||
const capabilities = [
|
||||
it('offers High first, then the one profile this server always registers, then the ones it registers nowhere', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences(
|
||||
'h264',
|
||||
[constrainedBaseline, rtx, baseline, highProfile],
|
||||
true,
|
||||
);
|
||||
expect(preferences).toEqual([highProfile, constrainedBaseline, baseline, rtx]);
|
||||
});
|
||||
|
||||
it('leads with Main and then Baseline while screen share delivery is off', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const mainProfile = codec('video/H264', mainLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline]);
|
||||
expect(preferences).toEqual([mainProfile, baseline, constrainedBaseline]);
|
||||
});
|
||||
|
||||
it('never leads with Main or Baseline, which this server registers nowhere and deletes from the answer', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const mainProfile = codec('video/H264', mainLine);
|
||||
const baseline = codec('video/H264', baselineLine);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline], true);
|
||||
expect(preferences).toEqual([constrainedBaseline, mainProfile, baseline]);
|
||||
});
|
||||
|
||||
function chromiumCapabilities() {
|
||||
return [
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'),
|
||||
@@ -41,7 +73,10 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034'),
|
||||
];
|
||||
const preferences = selectPublisherCodecPreferences('h264', capabilities);
|
||||
}
|
||||
|
||||
it('sorts the capabilities Chromium reports by the old table while screen share delivery is off', () => {
|
||||
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities());
|
||||
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f',
|
||||
@@ -53,16 +88,23 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks High, Constrained High, Main and Baseline above Constrained Baseline, whatever level each one carries', () => {
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
it('offers High first out of the capabilities Chromium reports, so the only hardware profile this server registers wins', () => {
|
||||
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities(), true);
|
||||
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const mainProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f');
|
||||
const highProfileLevel31 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f',
|
||||
);
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f',
|
||||
]);
|
||||
});
|
||||
|
||||
function levelSpread() {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const mainProfile = codec('video/H264', mainLine);
|
||||
const highProfileLevel31 = codec('video/H264', highLine);
|
||||
const highProfileLevel51 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033',
|
||||
@@ -71,6 +113,11 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f',
|
||||
);
|
||||
return {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh};
|
||||
}
|
||||
|
||||
it('ranks High, Constrained High, Main and Baseline above Constrained Baseline while screen share delivery is off', () => {
|
||||
const {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh} = levelSpread();
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
@@ -87,15 +134,28 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks packetization-mode=1 above packetization-mode=0, which no hardware encoder takes', () => {
|
||||
it('ranks High and Constrained High above Constrained Baseline, whatever level each one reports', () => {
|
||||
const {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh} = levelSpread();
|
||||
const preferences = selectPublisherCodecPreferences(
|
||||
'h264',
|
||||
[mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh, constrainedBaseline],
|
||||
true,
|
||||
);
|
||||
expect(preferences).toEqual([
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
constrainedBaseline,
|
||||
mainProfile,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks packetization-mode=1 above packetization-mode=0 in both arms, which no hardware encoder takes', () => {
|
||||
const constrainedBaselineMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
);
|
||||
const constrainedBaselineMode1 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const constrainedBaselineMode1 = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfileMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=640033',
|
||||
@@ -104,18 +164,10 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
highProfileMode0,
|
||||
constrainedBaselineMode0,
|
||||
constrainedBaselineMode1,
|
||||
highProfileMode1,
|
||||
]);
|
||||
expect(preferences).toEqual([
|
||||
highProfileMode1,
|
||||
constrainedBaselineMode1,
|
||||
highProfileMode0,
|
||||
constrainedBaselineMode0,
|
||||
]);
|
||||
const capabilities = [highProfileMode0, constrainedBaselineMode0, constrainedBaselineMode1, highProfileMode1];
|
||||
const expected = [highProfileMode1, constrainedBaselineMode1, highProfileMode0, constrainedBaselineMode0];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities)).toEqual(expected);
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('puts the chosen codec first and keeps every other codec in browser capability order', () => {
|
||||
@@ -123,12 +175,13 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const vp8 = codec('video/VP8');
|
||||
const rtx = codec('video/rtx');
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, vp8, rtx]);
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9], true)).toEqual([vp9, vp8, rtx]);
|
||||
});
|
||||
|
||||
it('keeps the other codecs so a later publication on the same connection can negotiate them', () => {
|
||||
const vp9 = codec('video/VP9');
|
||||
const av1 = codec('video/AV1');
|
||||
const h264 = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
|
||||
const h264 = codec('video/H264', constrainedBaselineLine);
|
||||
const preferences = selectPublisherCodecPreferences('vp9', [av1, h264, vp9]);
|
||||
expect(preferences.map((entry) => entry.mimeType)).toEqual(['video/VP9', 'video/AV1', 'video/H264']);
|
||||
});
|
||||
@@ -136,17 +189,65 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
it('ranks the H.264 profiles it leaves behind the chosen codec', () => {
|
||||
const vp8 = codec('video/VP8');
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('vp8', [vp8, highProfile, constrainedBaseline]);
|
||||
expect(preferences).toEqual([vp8, highProfile, constrainedBaseline]);
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const capabilities = [vp8, highProfile, constrainedBaseline];
|
||||
expect(selectPublisherCodecPreferences('vp8', capabilities)).toEqual([vp8, highProfile, constrainedBaseline]);
|
||||
expect(selectPublisherCodecPreferences('vp8', capabilities, true, new Set(['42e0']))).toEqual([
|
||||
vp8,
|
||||
constrainedBaseline,
|
||||
highProfile,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing when the sender cannot encode the chosen codec', () => {
|
||||
expect(selectPublisherCodecPreferences('av1', [codec('video/VP8'), codec('video/rtx')])).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores the profiles this host measured while screen share delivery is off', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const capabilities = [highProfile, constrainedBaseline];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, false, new Set(['42e0']))).toEqual([
|
||||
highProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
});
|
||||
|
||||
it('only lets a profile this host encodes in hardware outrank Constrained Baseline', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const capabilities = [highProfile, constrainedBaseline];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set(['42e0']))).toEqual([
|
||||
constrainedBaseline,
|
||||
highProfile,
|
||||
]);
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set(['6400', '42e0']))).toEqual([
|
||||
highProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the static order when nothing was measured or nothing is hardware', () => {
|
||||
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfile = codec('video/H264', highLine);
|
||||
const capabilities = [constrainedBaseline, highProfile];
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true)).toEqual([highProfile, constrainedBaseline]);
|
||||
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set())).toEqual([
|
||||
highProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps packetization-mode=1 ahead of a hardware packetization-mode=0 profile', () => {
|
||||
const constrainedBaselineMode1 = codec('video/H264', constrainedBaselineLine);
|
||||
const highProfileMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=64001f',
|
||||
);
|
||||
expect(
|
||||
selectPublisherCodecPreferences('h264', [highProfileMode0, constrainedBaselineMode1], true, new Set(['6400'])),
|
||||
).toEqual([constrainedBaselineMode1, highProfileMode0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publisher data channels before negotiation', () => {
|
||||
@@ -187,3 +288,66 @@ describe('publisher data channels before negotiation', () => {
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('negotiation aborts', () => {
|
||||
function negotiableEngine() {
|
||||
const engine = new RTCEngine({} as InternalRoomOptions);
|
||||
const controllers: Array<AbortController> = [];
|
||||
const internals = engine as unknown as {
|
||||
_isClosed: boolean;
|
||||
pcManager: unknown;
|
||||
dataChannels: {hasPublisherChannels: boolean};
|
||||
pendingNegotiationAborts: Set<() => void>;
|
||||
};
|
||||
internals._isClosed = false;
|
||||
internals.dataChannels = {hasPublisherChannels: true};
|
||||
internals.pcManager = {
|
||||
requirePublisher: () => {},
|
||||
publisher: {
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
getTransceivers: () => [{} as RTCRtpTransceiver],
|
||||
},
|
||||
negotiate: (abortController: AbortController) =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
controllers.push(abortController);
|
||||
abortController.signal.addEventListener('abort', () => reject(new Error('negotiation aborted')), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
};
|
||||
return {engine, controllers, pendingAborts: internals.pendingNegotiationAborts};
|
||||
}
|
||||
|
||||
it('keeps one listener pair however many negotiations are in flight, and aborts every one', async () => {
|
||||
const {engine, controllers, pendingAborts} = negotiableEngine();
|
||||
const closingListeners = engine.listenerCount(EngineEvent.Closing);
|
||||
const restartingListeners = engine.listenerCount(EngineEvent.Restarting);
|
||||
|
||||
const pending = Array.from({length: 20}, () => engine.negotiate());
|
||||
expect(engine.listenerCount(EngineEvent.Closing)).toBe(closingListeners);
|
||||
expect(engine.listenerCount(EngineEvent.Restarting)).toBe(restartingListeners);
|
||||
expect(pendingAborts.size).toBe(20);
|
||||
|
||||
engine.emit(EngineEvent.Closing);
|
||||
await expect(Promise.all(pending)).resolves.toHaveLength(20);
|
||||
expect(controllers.every((controller) => controller.signal.aborted)).toBe(true);
|
||||
expect(pendingAborts.size).toBe(0);
|
||||
});
|
||||
|
||||
it('re-arms after a restart so a later negotiation is not aborted on arrival', async () => {
|
||||
const {engine, controllers, pendingAborts} = negotiableEngine();
|
||||
const firstBatch = [engine.negotiate(), engine.negotiate()];
|
||||
engine.emit(EngineEvent.Restarting);
|
||||
await expect(Promise.all(firstBatch)).resolves.toHaveLength(2);
|
||||
|
||||
const afterRestart = engine.negotiate();
|
||||
expect(controllers).toHaveLength(3);
|
||||
expect(controllers[2].signal.aborted).toBe(false);
|
||||
expect(pendingAborts.size).toBe(1);
|
||||
|
||||
engine.emit(EngineEvent.Restarting);
|
||||
await expect(afterRestart).resolves.toBeUndefined();
|
||||
expect(pendingAborts.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,9 +129,18 @@ const h264ProfileRanks = new Map([
|
||||
['4200', 3],
|
||||
['42e0', 4],
|
||||
]);
|
||||
const h264DeliveryProfileRanks = new Map([
|
||||
['6400', 0],
|
||||
['640c', 1],
|
||||
['42e0', 2],
|
||||
['4d00', 3],
|
||||
['4200', 4],
|
||||
]);
|
||||
const h264UnrankedProfileScore = 5;
|
||||
const h264MissingProfileScore = 6;
|
||||
const h264NonHardwareProfilePenalty = 8;
|
||||
const h264PacketizationMode0Score = 10;
|
||||
const h264DeliveryPacketizationMode0Score = 20;
|
||||
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
|
||||
|
||||
enum PCState {
|
||||
@@ -250,6 +259,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
|
||||
private transportConnectingSince?: number;
|
||||
|
||||
private pendingNegotiationAborts = new Set<() => void>();
|
||||
|
||||
constructor(private options: InternalRoomOptions) {
|
||||
super();
|
||||
this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext);
|
||||
@@ -286,6 +297,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
this.client.onRequestResponse = (response) => this.emit(EngineEvent.SignalRequestResponse, response);
|
||||
this.client.onParticipantUpdate = (updates) => this.emit(EngineEvent.ParticipantUpdate, updates);
|
||||
this.client.onJoined = (joinResponse) => this.emit(EngineEvent.Joined, joinResponse);
|
||||
|
||||
const abortPendingNegotiations = () => {
|
||||
for (const abort of Array.from(this.pendingNegotiationAborts)) {
|
||||
abort();
|
||||
}
|
||||
};
|
||||
this.on(EngineEvent.Closing, abortPendingNegotiations);
|
||||
this.on(EngineEvent.Restarting, abortPendingNegotiations);
|
||||
}
|
||||
|
||||
get logContext() {
|
||||
@@ -504,6 +523,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
this.loggerOptions,
|
||||
rtcConfig,
|
||||
this.options.subscriberVideoCodecExclusions,
|
||||
this.options.screenShareDelivery ?? false,
|
||||
);
|
||||
} else {
|
||||
this.participantSid = joinResponse.participant?.sid;
|
||||
@@ -517,6 +537,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
this.loggerOptions,
|
||||
rtcConfig,
|
||||
this.options.subscriberVideoCodecExclusions,
|
||||
this.options.screenShareDelivery ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1041,7 +1062,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
if (typeof RTCRtpSender === 'undefined' || typeof RTCRtpSender.getCapabilities !== 'function') return;
|
||||
const capabilities = RTCRtpSender.getCapabilities('video');
|
||||
if (!capabilities) return;
|
||||
const preferences = selectPublisherCodecPreferences(codec, capabilities.codecs);
|
||||
const preferences = selectPublisherCodecPreferences(
|
||||
codec,
|
||||
capabilities.codecs,
|
||||
this.options.screenShareDelivery ?? false,
|
||||
this.options.h264HardwareProfiles,
|
||||
);
|
||||
if (preferences.length === 0) {
|
||||
this.log.warn('sender cannot encode the requested codec, leaving the browser order in place', {
|
||||
...this.logContext,
|
||||
@@ -1583,8 +1609,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
if (this.isClosed) {
|
||||
reject(new NegotiationError('cannot negotiate on closed engine'));
|
||||
}
|
||||
this.on(EngineEvent.Closing, handleClosed);
|
||||
this.on(EngineEvent.Restarting, handleClosed);
|
||||
this.pendingNegotiationAborts.add(handleClosed);
|
||||
this.pcManager.publisher.off(PCEvents.RTPVideoPayloadTypes, this.onRtpMapAvailable);
|
||||
this.pcManager.publisher.once(PCEvents.RTPVideoPayloadTypes, this.onRtpMapAvailable);
|
||||
|
||||
@@ -1606,8 +1631,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
reject(new Error(String(e)));
|
||||
}
|
||||
} finally {
|
||||
this.off(EngineEvent.Closing, handleClosed);
|
||||
this.off(EngineEvent.Restarting, handleClosed);
|
||||
this.pendingNegotiationAborts.delete(handleClosed);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1817,17 +1841,36 @@ function getFmtpParameter(sdpFmtpLine: string | undefined, key: string): string
|
||||
return null;
|
||||
}
|
||||
|
||||
function getH264PublisherCodecScore(codec: RtpCodecCapability): number {
|
||||
function getH264PublisherCodecScore(
|
||||
codec: RtpCodecCapability,
|
||||
screenShareDelivery: boolean,
|
||||
hardwareProfiles: ReadonlySet<string> | undefined,
|
||||
): number {
|
||||
const profileLevelId = getFmtpParameter(codec.sdpFmtpLine, 'profile-level-id');
|
||||
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : h264PacketizationMode0Score;
|
||||
const mode0Score = screenShareDelivery ? h264DeliveryPacketizationMode0Score : h264PacketizationMode0Score;
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : mode0Score;
|
||||
if (!profileLevelId) return packetizationScore + h264MissingProfileScore;
|
||||
return packetizationScore + (h264ProfileRanks.get(profileLevelId.slice(0, 4)) ?? h264UnrankedProfileScore);
|
||||
const profile = profileLevelId.slice(0, 4);
|
||||
if (!screenShareDelivery) {
|
||||
return packetizationScore + (h264ProfileRanks.get(profile) ?? h264UnrankedProfileScore);
|
||||
}
|
||||
const isSoftwareOnly = hardwareProfiles !== undefined && hardwareProfiles.size > 0 && !hardwareProfiles.has(profile);
|
||||
const hardwareScore = isSoftwareOnly ? h264NonHardwareProfilePenalty : 0;
|
||||
return packetizationScore + hardwareScore + (h264DeliveryProfileRanks.get(profile) ?? h264UnrankedProfileScore);
|
||||
}
|
||||
|
||||
function preferHardwareH264Codecs(codecs: ReadonlyArray<RtpCodecCapability>): Array<RtpCodecCapability> {
|
||||
function preferHardwareH264Codecs(
|
||||
codecs: ReadonlyArray<RtpCodecCapability>,
|
||||
screenShareDelivery: boolean,
|
||||
hardwareProfiles: ReadonlySet<string> | undefined,
|
||||
): Array<RtpCodecCapability> {
|
||||
return codecs
|
||||
.map((codec, index) => ({codec, index, score: getH264PublisherCodecScore(codec)}))
|
||||
.map((codec, index) => ({
|
||||
codec,
|
||||
index,
|
||||
score: getH264PublisherCodecScore(codec, screenShareDelivery, hardwareProfiles),
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score || a.index - b.index)
|
||||
.map((entry) => entry.codec);
|
||||
}
|
||||
@@ -1835,14 +1878,17 @@ function preferHardwareH264Codecs(codecs: ReadonlyArray<RtpCodecCapability>): Ar
|
||||
export function selectPublisherCodecPreferences(
|
||||
codec: VideoCodec,
|
||||
codecs: ReadonlyArray<RtpCodecCapability>,
|
||||
screenShareDelivery: boolean = false,
|
||||
h264HardwareProfiles?: ReadonlySet<string>,
|
||||
): Array<RtpCodecCapability> {
|
||||
const mimeTypes = new Set(videoCodecMimeTypes[codec]);
|
||||
const selected = codecs.filter((entry) => mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
if (selected.length === 0) return [];
|
||||
const preferred = codec === 'h264' ? preferHardwareH264Codecs(selected) : selected;
|
||||
const preferred =
|
||||
codec === 'h264' ? preferHardwareH264Codecs(selected, screenShareDelivery, h264HardwareProfiles) : selected;
|
||||
const isH264 = (entry: RtpCodecCapability): boolean => entry.mimeType.toLowerCase() === 'video/h264';
|
||||
const remaining = codecs.filter((entry) => !mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264));
|
||||
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264), screenShareDelivery, h264HardwareProfiles);
|
||||
let nextH264 = 0;
|
||||
const rest = remaining.map((entry) => (isH264(entry) ? rankedH264[nextH264++] : entry));
|
||||
return [...preferred, ...rest];
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
publishDefaults,
|
||||
roomConnectOptionDefaults,
|
||||
roomOptionDefaults,
|
||||
screenShareDeliveryPublishDefaults,
|
||||
videoDefaults,
|
||||
} from './defaults.ts';
|
||||
import {ConnectionError, ConnectionErrorReason, UnexpectedConnectionState, UnsupportedServer} from './errors.ts';
|
||||
@@ -227,7 +228,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
||||
...options?.videoCaptureDefaults,
|
||||
};
|
||||
this.options.publishDefaults = {
|
||||
...publishDefaults,
|
||||
...(this.options.screenShareDelivery ? screenShareDeliveryPublishDefaults : publishDefaults),
|
||||
...options?.publishDefaults,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {AudioPresets, BackupCodecPolicy, ScreenSharePresets, VideoPresets} from
|
||||
|
||||
export const defaultVideoCodec = 'h264';
|
||||
|
||||
export const publishDefaults: TrackPublishDefaults = {
|
||||
export const screenShareDeliveryPublishDefaults: TrackPublishDefaults = {
|
||||
audioPreset: AudioPresets.music,
|
||||
dtx: false,
|
||||
red: true,
|
||||
@@ -18,10 +18,14 @@ export const publishDefaults: TrackPublishDefaults = {
|
||||
videoCodec: defaultVideoCodec,
|
||||
backupCodec: {codec: 'h264'},
|
||||
backupCodecPolicy: BackupCodecPolicy.SIMULCAST,
|
||||
degradationPreference: 'maintain-resolution',
|
||||
preConnectBuffer: false,
|
||||
} as const;
|
||||
|
||||
export const publishDefaults: TrackPublishDefaults = {
|
||||
...screenShareDeliveryPublishDefaults,
|
||||
degradationPreference: 'maintain-resolution',
|
||||
};
|
||||
|
||||
export const audioDefaults: AudioCaptureOptions = {
|
||||
deviceId: {ideal: 'default'},
|
||||
autoGainControl: true,
|
||||
|
||||
@@ -123,6 +123,11 @@ import {
|
||||
} from './publishUtils.ts';
|
||||
import type RemoteParticipant from './RemoteParticipant.ts';
|
||||
|
||||
function hasSingleRidlessEncoding(track: LocalVideoTrack): boolean {
|
||||
const encodings = track.sender?.getParameters().encodings;
|
||||
return encodings?.length === 1 && !encodings[0].rid;
|
||||
}
|
||||
|
||||
export default class LocalParticipant extends Participant {
|
||||
override audioTrackPublications: Map<string, LocalTrackPublication>;
|
||||
|
||||
@@ -809,6 +814,7 @@ export default class LocalParticipant extends Participant {
|
||||
...this.roomOptions.publishDefaults,
|
||||
...options,
|
||||
};
|
||||
track.screenShareDelivery = this.roomOptions.screenShareDelivery ?? false;
|
||||
const isStereoInput =
|
||||
('channelCount' in track.mediaStreamTrack.getSettings() &&
|
||||
track.mediaStreamTrack.getSettings().channelCount === 2) ||
|
||||
@@ -1781,7 +1787,14 @@ export default class LocalParticipant extends Participant {
|
||||
if (!pub.videoTrack) {
|
||||
return;
|
||||
}
|
||||
const newCodecs = await pub.videoTrack.setPublishingCodecs(update.subscribedCodecs);
|
||||
let subscribedCodecs = update.subscribedCodecs;
|
||||
if (this.roomOptions.screenShareDelivery && hasSingleRidlessEncoding(pub.videoTrack)) {
|
||||
subscribedCodecs = subscribedCodecs.filter((codec) => codec.qualities.some((quality) => quality.enabled));
|
||||
if (subscribedCodecs.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newCodecs = await pub.videoTrack.setPublishingCodecs(subscribedCodecs);
|
||||
for await (const codec of newCodecs) {
|
||||
if (isBackupCodec(codec)) {
|
||||
this.log.debug(`publish ${codec} for ${pub.videoTrack.sid}`, getLogContextFromTrack(pub));
|
||||
|
||||
@@ -41,6 +41,8 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
|
||||
|
||||
codec?: VideoCodec;
|
||||
|
||||
screenShareDelivery: boolean = false;
|
||||
|
||||
get constraints() {
|
||||
return this._constraints;
|
||||
}
|
||||
@@ -561,6 +563,10 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
|
||||
);
|
||||
|
||||
private debouncedTrackMuteHandler = debounce(async () => {
|
||||
if (this.screenShareDelivery && this.source === Track.Source.ScreenShare) {
|
||||
this.log.debug('screen share capture went idle, keeping upstream published', this.logContext);
|
||||
return;
|
||||
}
|
||||
await this.pauseUpstream();
|
||||
}, 5000);
|
||||
|
||||
|
||||
@@ -52,69 +52,13 @@ async function renderSVGToBuffer(svgContent: string, size: number): Promise<Buff
|
||||
return sharp(Buffer.from(fixed)).resize(size, size).png().toBuffer();
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
h /= 360;
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hueToRgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
r = hueToRgb(p, q, h + 1 / 3);
|
||||
g = hueToRgb(p, q, h);
|
||||
b = hueToRgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return [
|
||||
Math.round(Math.min(1, Math.max(0, r)) * 255),
|
||||
Math.round(Math.min(1, Math.max(0, g)) * 255),
|
||||
Math.round(Math.min(1, Math.max(0, b)) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
async function createPlaceholder(size: number): Promise<Buffer> {
|
||||
const h = Math.random() * 360;
|
||||
const [r, g, b] = hslToRgb(h, 0.7, 0.6);
|
||||
const radius = Math.floor(size * 0.4);
|
||||
const cx = Math.floor(size / 2);
|
||||
const cy = Math.floor(size / 2);
|
||||
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgb(${r},${g},${b})"/>
|
||||
</svg>`;
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
|
||||
async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer> {
|
||||
const codepoint = convertToCodePoints(surrogate);
|
||||
const svg = loadLocalTwemojiSVG(codepoint);
|
||||
if (svg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(svg, size);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render SVG for ${codepoint}:`, error);
|
||||
}
|
||||
if (svg == null) {
|
||||
throw new Error(`Missing SVG for ${codepoint} (${surrogate})`);
|
||||
}
|
||||
if (codepoint.includes('-200d-')) {
|
||||
const basePart = codepoint.split('-200d-')[0];
|
||||
const baseSvg = loadLocalTwemojiSVG(basePart);
|
||||
if (baseSvg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(baseSvg, size);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render base SVG for ${basePart}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`);
|
||||
return createPlaceholder(size);
|
||||
return renderSVGToBuffer(svg, size);
|
||||
}
|
||||
|
||||
async function renderSpriteSheet(
|
||||
@@ -164,11 +108,11 @@ async function renderSpriteSheet(
|
||||
}
|
||||
|
||||
async function generateMainSpriteSheet(
|
||||
emojiData: Record<string, Array<EmojiObject>>,
|
||||
categories: Record<string, Array<EmojiObject>>,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
const base: Array<EmojiEntry> = [];
|
||||
for (const objs of Object.values(emojiData)) {
|
||||
for (const objs of Object.values(categories)) {
|
||||
for (const obj of objs) {
|
||||
base.push({surrogates: obj.surrogates});
|
||||
}
|
||||
@@ -177,7 +121,7 @@ async function generateMainSpriteSheet(
|
||||
}
|
||||
|
||||
async function generateSkinToneSpriteSheets(
|
||||
emojiData: Record<string, Array<EmojiObject>>,
|
||||
categories: Record<string, Array<EmojiObject>>,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
const skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
@@ -185,7 +129,7 @@ async function generateSkinToneSpriteSheets(
|
||||
const skinTone = skinTones[skinIndex];
|
||||
const skinCodepoint = convertToCodePoints(skinTone);
|
||||
const skinEntries: Array<EmojiEntry> = [];
|
||||
for (const objs of Object.values(emojiData)) {
|
||||
for (const objs of Object.values(categories)) {
|
||||
for (const obj of objs) {
|
||||
if (obj.skins && obj.skins.length > skinIndex && obj.skins[skinIndex].surrogates) {
|
||||
skinEntries.push({surrogates: obj.skins[skinIndex].surrogates});
|
||||
@@ -242,11 +186,11 @@ async function main(): Promise<void> {
|
||||
const outputDir = join(appDir, 'src', 'media', 'images', 'emoji-sprites');
|
||||
mkdirSync(outputDir, {recursive: true});
|
||||
const emojiDataPath = join(appDir, 'src', 'media', 'data', 'emojis.json');
|
||||
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
|
||||
const emojiData: {categories: Record<string, Array<EmojiObject>>} = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
|
||||
console.log('Generating main sprite sheet...');
|
||||
await generateMainSpriteSheet(emojiData, outputDir);
|
||||
await generateMainSpriteSheet(emojiData.categories, outputDir);
|
||||
console.log('Generating skin tone sprite sheets...');
|
||||
await generateSkinToneSpriteSheets(emojiData, outputDir);
|
||||
await generateSkinToneSpriteSheets(emojiData.categories, outputDir);
|
||||
console.log('Generating picker sprite sheet...');
|
||||
await generatePickerSpriteSheet(outputDir);
|
||||
console.log('Emoji sprites generated successfully.');
|
||||
|
||||
@@ -87,7 +87,7 @@ export class Channel {
|
||||
this.instanceId = options?.instanceId ?? RuntimeConfig.localInstanceDomain;
|
||||
this.id = channel.id;
|
||||
this.guildId = channel.guild_id;
|
||||
this.name = channel.name;
|
||||
this.name = channel.name ?? undefined;
|
||||
noteText(this.name);
|
||||
this.topic = channel.topic ?? null;
|
||||
this.url = channel.url ?? null;
|
||||
@@ -223,7 +223,7 @@ export class Channel {
|
||||
{
|
||||
id: this.id,
|
||||
guild_id: updates.guild_id ?? this.guildId,
|
||||
name: updates.name ?? this.name,
|
||||
name: updates.name !== undefined ? updates.name : this.name,
|
||||
topic: updates.topic !== undefined ? updates.topic : this.topic,
|
||||
url: updates.url !== undefined ? updates.url : this.url,
|
||||
icon: updates.icon !== undefined ? updates.icon : this.icon,
|
||||
|
||||
@@ -7,22 +7,34 @@ import type {DesktopTroubleshootingSettings} from '@app/types/electron.d';
|
||||
const logger = new Logger('DesktopTroubleshootingUtils');
|
||||
|
||||
let cachedSettings: DesktopTroubleshootingSettings | null = null;
|
||||
let pendingPromise: Promise<DesktopTroubleshootingSettings | null> | null = null;
|
||||
|
||||
function fetchSettings(): Promise<DesktopTroubleshootingSettings | null> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.getDesktopTroubleshootingSettings) return Promise.resolve(null);
|
||||
return electronApi
|
||||
.getDesktopTroubleshootingSettings()
|
||||
.then((settings) => {
|
||||
cachedSettings = settings;
|
||||
return settings;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to read desktop troubleshooting settings', error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export function getCachedDesktopTroubleshootingSettings(): DesktopTroubleshootingSettings | null {
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
export async function getDesktopTroubleshootingSettings(): Promise<DesktopTroubleshootingSettings | null> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.getDesktopTroubleshootingSettings) return null;
|
||||
try {
|
||||
const settings = await electronApi.getDesktopTroubleshootingSettings();
|
||||
cachedSettings = settings;
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read desktop troubleshooting settings', error);
|
||||
return null;
|
||||
}
|
||||
export function getDesktopTroubleshootingSettings(): Promise<DesktopTroubleshootingSettings | null> {
|
||||
if (cachedSettings) return Promise.resolve(cachedSettings);
|
||||
if (pendingPromise) return pendingPromise;
|
||||
pendingPromise = fetchSettings().finally(() => {
|
||||
pendingPromise = null;
|
||||
});
|
||||
return pendingPromise;
|
||||
}
|
||||
|
||||
export async function setDesktopDisableHardwareAcceleration(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {VoiceNoiseSuppressionAssignmentResponse} from '@fluxer/schema/src/d
|
||||
import {
|
||||
type ExperimentAssignmentsResponse,
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
readScreenShareDeliveryAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
@@ -122,6 +123,8 @@ describe('ExperimentAssignments cold start', () => {
|
||||
await settle();
|
||||
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
expect(ExperimentAssignments.response.assignments.voice_noise_suppression).toBeUndefined();
|
||||
expect(ExperimentAssignments.response.assignments.screen_share_delivery).toBeUndefined();
|
||||
expect(readScreenShareDeliveryAssignment(ExperimentAssignments.response).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the inert envelope while unauthenticated and retries later', async () => {
|
||||
@@ -167,6 +170,32 @@ describe('ExperimentAssignments response handling', () => {
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
|
||||
it('adopts a screen share delivery assignment beside the voice one', async () => {
|
||||
await adopt({
|
||||
...CANARY_ENVELOPE,
|
||||
assignments: {...CANARY_ENVELOPE.assignments, screen_share_delivery: {enabled: true}},
|
||||
});
|
||||
expect(readScreenShareDeliveryAssignment(ExperimentAssignments.response).enabled).toBe(true);
|
||||
expect(readVoiceNoiseSuppressionAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('reads screen share delivery as disabled when the envelope omits it', async () => {
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(readScreenShareDeliveryAssignment(ExperimentAssignments.response).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('discards an envelope with a malformed screen share delivery assignment', async () => {
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
vi.mocked(http.get).mockResolvedValue(
|
||||
reply(200, {
|
||||
...CANARY_ENVELOPE,
|
||||
assignments: {...CANARY_ENVELOPE.assignments, screen_share_delivery: {enabled: 'yes'}},
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
|
||||
it('accepts an envelope that carries no voice noise suppression assignment', async () => {
|
||||
await adopt({poll_interval_seconds: 600, poll_jitter_percent: 0, assignments: {}});
|
||||
expect(ExperimentAssignments.response.assignments.voice_noise_suppression).toBeUndefined();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const EYE_IN_SPEECH_BUBBLE = '\u{1F441}\u200D\u{1F5E8}';
|
||||
|
||||
export function convertToCodePoints(emoji: string): string {
|
||||
const containsZWJ = emoji.includes('\u200D');
|
||||
const processedEmoji = containsZWJ ? emoji : emoji.replace(/\uFE0F/g, '');
|
||||
const emojiWithoutFE0F = emoji.replace(/\uFE0F/g, '');
|
||||
const keepsFE0F = emoji.includes('\u200D') && emojiWithoutFE0F !== EYE_IN_SPEECH_BUBBLE;
|
||||
const processedEmoji = keepsFE0F ? emoji : emojiWithoutFE0F;
|
||||
return Array.from(processedEmoji)
|
||||
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
|
||||
.join('-');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {UnicodeEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils';
|
||||
import type {EmojiSurrogateMatch} from '@app/features/messaging/utils/markdown/parser/EmojiParsers';
|
||||
import * as RegexUtils from '@app/features/messaging/utils/RegexUtils';
|
||||
import emojiData from '@app/media/data/emojis.json';
|
||||
import {SKIN_TONE_SURROGATES} from '@fluxer/constants/src/EmojiConstants';
|
||||
@@ -60,6 +61,15 @@ const categories = Object.freeze(Object.keys(emojiData.categories));
|
||||
|
||||
const toCanonicalSurrogate = (surrogate: string): string => surrogate.replace(/️/g, '');
|
||||
|
||||
const EYE_IN_SPEECH_BUBBLE_FULLY_QUALIFIED = '\u{1F441}\uFE0F\u200D\u{1F5E8}\uFE0F';
|
||||
const HAIR_COMPONENT_NAMES: Record<string, string> = {
|
||||
'\u{1F9B0}': 'red_hair',
|
||||
'\u{1F9B1}': 'curly_hair',
|
||||
'\u{1F9B3}': 'white_hair',
|
||||
'\u{1F9B2}': 'bald',
|
||||
};
|
||||
const REGIONAL_INDICATOR_PAIR_PATTERN = '\\uD83C[\\uDDE6-\\uDDFF]\\uD83C[\\uDDE6-\\uDDFF]';
|
||||
|
||||
let defaultSkinTone: string = '';
|
||||
|
||||
class UnicodeEmojiClass {
|
||||
@@ -216,6 +226,11 @@ function buildEmojiIndex(): EmojiIndex {
|
||||
surrogateToName[skinToneEntry.surrogatePair] = skinToneEntry.name;
|
||||
canonicalSurrogateToName[toCanonicalSurrogate(skinToneEntry.surrogatePair)] = skinToneEntry.name;
|
||||
});
|
||||
const skins = (emojiObject as {skins?: ReadonlyArray<{names: ReadonlyArray<string>; surrogates: string}>}).skins;
|
||||
skins?.slice(SKIN_TONE_SURROGATES.length).forEach((skin) => {
|
||||
surrogateToName[skin.surrogates] = skin.names[0];
|
||||
canonicalSurrogateToName[toCanonicalSurrogate(skin.surrogates)] = skin.names[0];
|
||||
});
|
||||
categoryByEmojiName[emoji.uniqueName] = category;
|
||||
emojis.push(emojiJson);
|
||||
return emojiJson;
|
||||
@@ -228,6 +243,20 @@ function buildEmojiIndex(): EmojiIndex {
|
||||
canonicalSurrogateToName[toCanonicalSurrogate(surrogatePair)] = `skin-tone-${index + 1}`;
|
||||
});
|
||||
|
||||
Object.entries(HAIR_COMPONENT_NAMES).forEach(([surrogate, name]) => {
|
||||
surrogateToName[surrogate] = name;
|
||||
canonicalSurrogateToName[surrogate] = name;
|
||||
});
|
||||
|
||||
Object.keys(surrogateToName)
|
||||
.filter((surrogate) => surrogate.includes('\u20E3'))
|
||||
.map(toCanonicalSurrogate)
|
||||
.concat(EYE_IN_SPEECH_BUBBLE_FULLY_QUALIFIED)
|
||||
.forEach((alias) => {
|
||||
const name = canonicalSurrogateToName[toCanonicalSurrogate(alias)];
|
||||
if (name) surrogateToName[alias] = name;
|
||||
});
|
||||
|
||||
const keywordOwner: Record<string, string> = {};
|
||||
const keywordCount: Record<string, number> = {};
|
||||
|
||||
@@ -275,7 +304,7 @@ function buildEmojiIndex(): EmojiIndex {
|
||||
emojis,
|
||||
skinToneSpriteCount,
|
||||
baseSpriteCount,
|
||||
emojiSurrogateRegex: new RegExp(`(${surrogateAlternation})`, 'g'),
|
||||
emojiSurrogateRegex: new RegExp(`(${REGIONAL_INDICATOR_PAIR_PATTERN}|${surrogateAlternation})`, 'g'),
|
||||
emojiShortcutRegex: new RegExp(`^(${shortcutAlternation})`),
|
||||
};
|
||||
}
|
||||
@@ -288,6 +317,50 @@ function getEmojiIndex(): EmojiIndex {
|
||||
const lookupSurrogateName = (surrogate: string): string | null =>
|
||||
getEmojiIndex().canonicalSurrogateToName[toCanonicalSurrogate(surrogate)] ?? null;
|
||||
|
||||
const isRegionalIndicatorAt = (text: string, index: number): boolean => {
|
||||
const lowSurrogate = text.charCodeAt(index + 1);
|
||||
return text.charCodeAt(index) === 0xd83c && lowSurrogate >= 0xdde6 && lowSurrogate <= 0xddff;
|
||||
};
|
||||
|
||||
const isInsideRegionalIndicatorPair = (text: string, index: number): boolean => {
|
||||
if (!isRegionalIndicatorAt(text, index)) return false;
|
||||
let runStart = index;
|
||||
while (isRegionalIndicatorAt(text, runStart - 2)) runStart -= 2;
|
||||
return (index - runStart) % 4 === 2;
|
||||
};
|
||||
|
||||
const toEmojiSurrogateMatch = (text: string, start: number, end: number): EmojiSurrogateMatch => ({
|
||||
start,
|
||||
end,
|
||||
name: lookupSurrogateName(text.slice(start, end)),
|
||||
});
|
||||
|
||||
function* matchEmojiSurrogates(text: string, startIndex = 0): Generator<EmojiSurrogateMatch, void> {
|
||||
const regex = getEmojiIndex().emojiSurrogateRegex;
|
||||
let index = startIndex;
|
||||
if (isInsideRegionalIndicatorPair(text, index)) {
|
||||
yield toEmojiSurrogateMatch(text, index, index + 2);
|
||||
index += 2;
|
||||
}
|
||||
while (true) {
|
||||
regex.lastIndex = index;
|
||||
const match = regex.exec(text);
|
||||
if (match == null) return;
|
||||
index = match.index + match[0].length;
|
||||
const emojiMatch = toEmojiSurrogateMatch(text, match.index, index);
|
||||
const isUnknownRegionalIndicatorPair =
|
||||
emojiMatch.name == null &&
|
||||
isRegionalIndicatorAt(text, match.index) &&
|
||||
isRegionalIndicatorAt(text, match.index + 2);
|
||||
if (isUnknownRegionalIndicatorPair) {
|
||||
yield toEmojiSurrogateMatch(text, match.index, match.index + 2);
|
||||
yield toEmojiSurrogateMatch(text, match.index + 2, index);
|
||||
} else {
|
||||
yield emojiMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EMOJI_SHORTCODE_RE = /^:([^\s:]+(?:::skin-tone-[0-9])?):/;
|
||||
const categoryIcons = {
|
||||
people: SmileyIcon,
|
||||
@@ -377,6 +450,8 @@ export default {
|
||||
getSurrogateName: (surrogate: string): string | null => {
|
||||
return lookupSurrogateName(surrogate);
|
||||
},
|
||||
matchEmojiSurrogates,
|
||||
isInsideRegionalIndicatorPair,
|
||||
findEmojiByName: (emojiName: string): UnicodeEmoji | null => {
|
||||
return getEmojiIndex().nameToEmoji[emojiName] || null;
|
||||
},
|
||||
@@ -400,8 +475,5 @@ export default {
|
||||
get EMOTICON_PREFIX_RE(): RegExp {
|
||||
return getEmojiIndex().emojiShortcutRegex;
|
||||
},
|
||||
get EMOJI_SURROGATE_RE(): RegExp {
|
||||
return getEmojiIndex().emojiSurrogateRegex;
|
||||
},
|
||||
EMOJI_SPRITES,
|
||||
};
|
||||
|
||||
@@ -1070,7 +1070,7 @@ msgstr "{count, plural, zero {توسيع (# سطر)} one {توسيع (سطر و
|
||||
msgid "{count, plural, one {Expand (# row)} other {Expand (# rows)}}"
|
||||
msgstr "{count, plural, zero {توسيع (# صف)} one {توسيع (صف واحد)} two {توسيع (صفان)} few {توسيع (# صفوف)} many {توسيع (# صفًا)} other {توسيع (# صف)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:315
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:320
|
||||
msgid "{count, plural, one {Expand # other device} other {Expand # other devices}}"
|
||||
msgstr "{count, plural, zero {توسيع # جهاز آخر} one {توسيع جهاز آخر} two {توسيع جهازين آخرين} few {توسيع # أجهزة أخرى} many {توسيع # جهازًا آخر} other {توسيع # جهاز آخر}}"
|
||||
|
||||
@@ -1091,7 +1091,7 @@ msgstr "{count, plural, zero {# في المكالمة} one {في المكالم
|
||||
msgid "{count, plural, one {Join video call (# participant)} other {Join video call (# participants)}}"
|
||||
msgstr "{count, plural, zero {الانضمام إلى مكالمة الفيديو (# مشارك)} one {الانضمام إلى مكالمة الفيديو (مشارك واحد)} two {الانضمام إلى مكالمة الفيديو (مشاركان)} few {الانضمام إلى مكالمة الفيديو (# مشاركين)} many {الانضمام إلى مكالمة الفيديو (# مشاركًا)} other {الانضمام إلى مكالمة الفيديو (# مشارك)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:671
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:676
|
||||
msgid "{count, plural, one {Keep watching # stream and add this one} other {Keep watching # streams and add this one}}"
|
||||
msgstr "{count, plural, zero {متابعة مشاهدة # بث وإضافة هذا البث} one {متابعة مشاهدة بث واحد وإضافة هذا البث} two {متابعة مشاهدة بثين وإضافة هذا البث} few {متابعة مشاهدة # بثوث وإضافة هذا البث} many {متابعة مشاهدة # بثًا وإضافة هذا البث} other {متابعة مشاهدة # بث وإضافة هذا البث}}"
|
||||
|
||||
@@ -1347,7 +1347,7 @@ msgstr "{fps} إطار/ث"
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:53
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:114
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:115
|
||||
msgid "{frameRate} FPS"
|
||||
msgstr "{frameRate} إطارًا في الثانية"
|
||||
|
||||
@@ -3590,8 +3590,8 @@ msgstr "السماح لأي شخص يمكنه عرض ملفك الشخصي با
|
||||
|
||||
#. Settings search entry label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#. Switch label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:143
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:88
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:136
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:87
|
||||
msgid "Allow AV1 for screen sharing"
|
||||
msgstr "السماح بـ AV1 لمشاركة الشاشة"
|
||||
|
||||
@@ -3649,8 +3649,8 @@ msgstr "السماح لكل المجتمعات"
|
||||
|
||||
#. Settings search entry label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#. Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:147
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:92
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:140
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:91
|
||||
msgid "Allow H.265 (HEVC) for screen sharing"
|
||||
msgstr "السماح بـ H.265 (HEVC) لمشاركة الشاشة"
|
||||
|
||||
@@ -4681,12 +4681,12 @@ msgid "Audio boost"
|
||||
msgstr "تعزيز الصوت"
|
||||
|
||||
#. Section header in the stream settings menu. Selects the audio capture device for the share.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:136
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:137
|
||||
msgid "Audio device"
|
||||
msgstr "جهاز الصوت"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:16
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:15
|
||||
msgid "Audio device alerts"
|
||||
msgstr "تنبيهات جهاز الصوت"
|
||||
|
||||
@@ -4776,7 +4776,7 @@ msgid "Audio send:"
|
||||
msgstr "إرسال الصوت:"
|
||||
|
||||
#. Section header in the stream settings menu grouping audio-related toggles.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:148
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:149
|
||||
msgid "Audio settings"
|
||||
msgstr "إعدادات الصوت"
|
||||
|
||||
@@ -4987,9 +4987,9 @@ msgstr "اقتراح الإكمال التلقائي: {label}، {selectedOptionP
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#. Voice region picker option label meaning 'let Fluxer pick the best region automatically'.
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx:19
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:63
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:83
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:104
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:56
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:76
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:97
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts:264
|
||||
#: src/features/voice/components/VoiceRegionSelector.tsx:16
|
||||
#: src/features/voice/components/VoiceSettingsMenus.tsx:93
|
||||
@@ -4997,13 +4997,13 @@ msgid "Automatic"
|
||||
msgstr "تلقائي"
|
||||
|
||||
#. Option label for the screen-share codec select. codec is the automatically selected codec label.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:129
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:122
|
||||
msgid "Automatic ({codec})"
|
||||
msgstr "تلقائي ({codec})"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Voice setting label for automatically evening out microphone volume.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:48
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:47
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:215
|
||||
msgid "Automatic gain control"
|
||||
msgstr "التحكم التلقائي في الكسب"
|
||||
@@ -5094,12 +5094,12 @@ msgid "Autoscrolling"
|
||||
msgstr "التمرير التلقائي"
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:101
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:100
|
||||
msgid "AV1"
|
||||
msgstr "AV1"
|
||||
|
||||
#. Option label for the screen-share codec select while the AV1 opt-in is off and the option is disabled. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:133
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:126
|
||||
msgid "AV1 (requires opt-in)"
|
||||
msgstr "AV1 (يتطلب التفعيل)"
|
||||
|
||||
@@ -5558,7 +5558,7 @@ msgid "Biometrics"
|
||||
msgstr "المقاييس الحيوية"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:169
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:152
|
||||
msgid "Bitrate"
|
||||
msgstr "معدل البت"
|
||||
|
||||
@@ -5953,7 +5953,7 @@ msgid "Browser locale: {format}"
|
||||
msgstr "الإعدادات الإقليمية للمتصفح: {format}"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:64
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:63
|
||||
msgid "Browser mic gain for custom processing"
|
||||
msgstr "تضخيم ميكروفون المتصفح للمعالجة المخصصة"
|
||||
|
||||
@@ -6483,15 +6483,15 @@ msgstr "فشل التحقق من الكابتشا. حاول مرة أخرى."
|
||||
msgid "Capture"
|
||||
msgstr "التقاط"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:810
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
msgid "Capture app audio"
|
||||
msgstr "التقاط صوت التطبيق"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:814
|
||||
msgid "Capture desktop audio"
|
||||
msgstr "التقاط صوت سطح المكتب"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:809
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:811
|
||||
msgid "Capture device audio"
|
||||
msgstr "التقاط صوت الجهاز"
|
||||
|
||||
@@ -6501,7 +6501,7 @@ msgid "Capture devices use the Gaming preset."
|
||||
msgstr "تستخدم أجهزة الالتقاط الإعداد المسبق للألعاب."
|
||||
|
||||
#. Toggle label in the stream settings menu for a window share whose audio scope the user widened from the shared window to the whole system mix.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:160
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:161
|
||||
msgid "Capture entire system audio"
|
||||
msgstr "التقاط صوت النظام بالكامل"
|
||||
|
||||
@@ -7637,11 +7637,6 @@ msgstr "اختر لغتك"
|
||||
msgid "Choose your profile accent color"
|
||||
msgstr "اختر لون تمييز ملفك الشخصي"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:117
|
||||
msgid "Cisco"
|
||||
msgstr "Cisco"
|
||||
|
||||
#. Generic account action label that opens or submits the flow to secure an unclaimed account.
|
||||
#: src/features/i18n/utils/CommonMessageDescriptors.ts:152
|
||||
msgid "Claim account"
|
||||
@@ -8240,7 +8235,7 @@ msgid "Code:"
|
||||
msgstr "الرمز:"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:76
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:75
|
||||
msgid "Codec"
|
||||
msgstr "ترميز"
|
||||
|
||||
@@ -8766,7 +8761,7 @@ msgstr "التركيز على مربع الرسالة"
|
||||
#. Button label that opens a dedicated advanced settings modal for configuring screen-share encoder controls.
|
||||
#. Button label that opens a dedicated advanced settings modal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:164
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:120
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:113
|
||||
msgid "Configure"
|
||||
msgstr "تكوين"
|
||||
|
||||
@@ -9043,7 +9038,7 @@ msgid "Connection volume"
|
||||
msgstr "مستوى صوت الاتصال"
|
||||
|
||||
#. Settings search entry label. Names an advanced voice setting in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:28
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:27
|
||||
msgid "Connection volume controls"
|
||||
msgstr "عناصر تحكم مستوى صوت الاتصال"
|
||||
|
||||
@@ -9114,7 +9109,7 @@ msgid "Content filtering"
|
||||
msgstr "فلترة المحتوى"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to the WebRTC MediaStreamTrack contentHint value.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:95
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:88
|
||||
msgid "Content hint"
|
||||
msgstr "نوع المحتوى"
|
||||
|
||||
@@ -10594,7 +10589,7 @@ msgstr "حاليًا: {mutedText}"
|
||||
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:94
|
||||
#: src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx:45
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:238
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:102
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:103
|
||||
msgid "Custom"
|
||||
msgstr "مخصص"
|
||||
|
||||
@@ -10789,7 +10784,7 @@ msgid "Custom video backgrounds"
|
||||
msgstr "خلفيات فيديو مخصصة"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:60
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:59
|
||||
msgid "Custom voice processing"
|
||||
msgstr "معالجة صوتية مخصصة"
|
||||
|
||||
@@ -11810,7 +11805,7 @@ msgid "Detached banner"
|
||||
msgstr "لافتة منفصلة"
|
||||
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:112
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:105
|
||||
msgid "Detail"
|
||||
msgstr "التفاصيل"
|
||||
|
||||
@@ -11880,7 +11875,7 @@ msgid "Device"
|
||||
msgstr "الجهاز"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:12
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:11
|
||||
msgid "Device alerts"
|
||||
msgstr "تنبيهات الجهاز"
|
||||
|
||||
@@ -11890,7 +11885,7 @@ msgid "Device audio ({deviceLabel})"
|
||||
msgstr "صوت الجهاز ({deviceLabel})"
|
||||
|
||||
#. Screen-share audio summary on a video device share that publishes only the audio of the capture device itself.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:866
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:868
|
||||
#: src/features/voice/utils/ScreenShareAudioSummary.ts:50
|
||||
msgid "Device audio only"
|
||||
msgstr "صوت الجهاز فقط"
|
||||
@@ -11904,12 +11899,12 @@ msgstr "تفاصيل الجهاز"
|
||||
msgid "Device revoked"
|
||||
msgstr "تم إلغاء ربط الجهاز"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1004
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1006
|
||||
msgid "Device selection"
|
||||
msgstr "اختيار الجهاز"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:36
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:35
|
||||
msgid "Device volume"
|
||||
msgstr "مستوى صوت الجهاز"
|
||||
|
||||
@@ -11933,7 +11928,7 @@ msgid "Devices"
|
||||
msgstr "الأجهزة"
|
||||
|
||||
#. Description for the Custom streaming preset. Lightly playful prose; keep tone casual.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:106
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:107
|
||||
msgid "Dial in your own numbers"
|
||||
msgstr "اضبط الأرقام على ذوقك"
|
||||
|
||||
@@ -12109,7 +12104,7 @@ msgid "Disable mobile push notifications"
|
||||
msgstr "تعطيل إشعارات الدفع على الجوال"
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:51
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:44
|
||||
msgid "Disable picture-in-picture popout for screen shares"
|
||||
msgstr "تعطيل النوافذ المنبثقة (صورة داخل صورة) لمشاركات الشاشة"
|
||||
|
||||
@@ -12629,7 +12624,7 @@ msgstr "عدم اقتراح {deviceName} مرة أخرى"
|
||||
#: src/features/moderation/components/report/ReportBreadcrumbs.tsx:31
|
||||
#: src/features/search/components/search/ChannelFilterSheet.tsx:165
|
||||
#: src/features/search/components/search/HasFilterSheet.tsx:206
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:175
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:186
|
||||
msgid "Done"
|
||||
msgstr "تم"
|
||||
|
||||
@@ -13756,8 +13751,8 @@ msgstr "مفعّل"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to hardware or software video encoding.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:59
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:153
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:136
|
||||
msgid "Encoder path"
|
||||
msgstr "مسار المُرمِّز"
|
||||
|
||||
@@ -13767,7 +13762,7 @@ msgid "Encoder preference for new screen shares."
|
||||
msgstr "تفضيل المُرمِّز لعمليات مشاركة الشاشة الجديدة."
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:173
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:156
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "المُرمّز، SVC، وإعدادات البت المسبقة"
|
||||
|
||||
@@ -15616,7 +15611,7 @@ msgstr "إطار"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:341
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:132
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:133
|
||||
msgid "Frame rate"
|
||||
msgstr "معدل الإطارات"
|
||||
|
||||
@@ -15840,7 +15835,7 @@ msgid "Fully quit and restart {productName} so macOS applies this permission."
|
||||
msgstr "أغلق {productName} تمامًا ثم أعد تشغيله حتى يطبّق macOS هذا الإذن."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:51
|
||||
msgid "Gain control"
|
||||
msgstr "التحكم في الكسب"
|
||||
|
||||
@@ -15946,7 +15941,7 @@ msgstr "زر Y / المثلث في يد التحكم"
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:76
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:24
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:75
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:76
|
||||
msgid "Gaming"
|
||||
msgstr "ألعاب"
|
||||
|
||||
@@ -16451,7 +16446,7 @@ msgstr "تم منح {permissions}"
|
||||
msgid "Grants all permissions and bypasses channel restrictions. Highly sensitive."
|
||||
msgstr "يمنح جميع الأذونات ويتجاوز قيود القنوات. إذن حساس للغاية."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:991
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:993
|
||||
msgid "Granular selection"
|
||||
msgstr "تحديد دقيق"
|
||||
|
||||
@@ -16629,13 +16624,8 @@ msgstr "تم تحديث المجموعة"
|
||||
msgid "Grouping"
|
||||
msgstr "التجميع"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:113
|
||||
msgid "H.264"
|
||||
msgstr "H.264"
|
||||
|
||||
#. Option label for the screen-share codec select while the HEVC opt-in is off and the option is disabled. H.265 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:138
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:131
|
||||
msgid "H.265 (requires opt-in)"
|
||||
msgstr "H.265 (يتطلب التفعيل)"
|
||||
|
||||
@@ -16691,7 +16681,7 @@ msgid "Hardware acceleration can cause rendering glitches with transparent windo
|
||||
msgstr "قد يتسبب تسريع الجهاز في حدوث أخطاء في العرض مع النوافذ الشفافة. جرّب --disable-gpu إذا رأيت وميضًا أو تشويشًا."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:161
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:144
|
||||
msgid "Hardware encoder"
|
||||
msgstr "مرمز الأجهزة"
|
||||
|
||||
@@ -16832,7 +16822,7 @@ msgid "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMA
|
||||
msgstr "ساعد في ترجمة {PRODUCT_NAME} إلى لغتك على <0>{I18N_WEBLATE_DOMAIN}</0>."
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:105
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:104
|
||||
msgid "HEVC"
|
||||
msgstr "HEVC"
|
||||
|
||||
@@ -16995,7 +16985,7 @@ msgid "Hide muted channels by default?"
|
||||
msgstr "إخفاء القنوات المكتومة افتراضيًا؟"
|
||||
|
||||
#. Short label for an advanced stream privacy preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:55
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:48
|
||||
msgid "Hide my stream preview thumbnail"
|
||||
msgstr "إخفاء الصورة المصغرة لمعاينة البث الخاص بي"
|
||||
|
||||
@@ -17014,7 +17004,7 @@ msgstr "إخفاء كلمة المرور"
|
||||
msgid "Hide picture-in-picture popout?"
|
||||
msgstr "إخفاء نافذة \"صورة داخل صورة\" المنبثقة؟"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:768
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:770
|
||||
msgid "Hide preview thumbnail"
|
||||
msgstr "إخفاء صورة المعاينة المصغرة"
|
||||
|
||||
@@ -17417,11 +17407,11 @@ msgstr "تجاهل"
|
||||
msgid "Ignore friend request"
|
||||
msgstr "تجاهل طلب الصداقة"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:981
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:983
|
||||
msgid "Ignore hardware devices"
|
||||
msgstr "تجاهل الأجهزة المادية"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:957
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:959
|
||||
msgid "Ignore input media"
|
||||
msgstr "تجاهل وسائط الإدخال"
|
||||
|
||||
@@ -17452,7 +17442,7 @@ msgstr "هل تريد تجاهل طلب الصداقة من {displayName}؟"
|
||||
msgid "Ignore this channel's slowmode cooldown."
|
||||
msgstr "تجاهل فترة انتظار الوضع البطيء في هذه القناة."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:968
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:970
|
||||
msgid "Ignore virtual"
|
||||
msgstr "تجاهل الأجهزة الافتراضية"
|
||||
|
||||
@@ -19067,7 +19057,7 @@ msgid "Layering"
|
||||
msgstr "ترتيب الطبقات"
|
||||
|
||||
#. Description for an SVC mode select. Keep SVC and AV1 literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:79
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:72
|
||||
msgid "Layering for AV1 and VP9 when available."
|
||||
msgstr "استخدام الطبقات مع AV1 وVP9 عند توفرهما."
|
||||
|
||||
@@ -20228,7 +20218,7 @@ msgid "Maximum protection for family-friendly spaces"
|
||||
msgstr "أقصى حماية للمساحات الملائمة للعائلة"
|
||||
|
||||
#. Settings search entry description for the AV1 and H.265 screen-share opt-in toggles.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:97
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:96
|
||||
msgid "May cause compatibility issues for viewers. We’re working on improving this."
|
||||
msgstr "قد يسبب مشكلات توافق لدى المشاهدين. نعمل على تحسين ذلك."
|
||||
|
||||
@@ -20957,7 +20947,7 @@ msgid "Microphone access was denied. Enable it in your system settings and try a
|
||||
msgstr "تم رفض الوصول إلى الميكروفون. قم بتمكينه في إعدادات النظام وحاول مرة أخرى."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:56
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:55
|
||||
msgid "Microphone gain"
|
||||
msgstr "تضخيم الميكروفون"
|
||||
|
||||
@@ -21318,7 +21308,7 @@ msgstr "الأكثر صلة"
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:102
|
||||
#: src/features/user/components/modals/tabs/accessibility_tab/AccessibilityTabInline.tsx:31
|
||||
#: src/features/user/components/modals/tabs/AccessibilityTab.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:108
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:101
|
||||
#: src/features/user/components/settings_utils/search_index/AccessibilityIndex.ts:297
|
||||
#: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:271
|
||||
msgid "Motion"
|
||||
@@ -22142,7 +22132,7 @@ msgid "New Mexico"
|
||||
msgstr "نيومكسيكو"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:20
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:19
|
||||
msgid "New microphone"
|
||||
msgstr "ميكروفون جديد"
|
||||
|
||||
@@ -22830,11 +22820,11 @@ msgstr "لم يتم العثور على مستخدم باسم المستخدم
|
||||
msgid "No users are currently banned from this community."
|
||||
msgstr "لا يوجد مستخدمون محظورون حاليًا في هذا المجتمع."
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users available"
|
||||
msgstr "لا يوجد مستخدمون متاحون"
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users found"
|
||||
msgstr "لم يتم العثور على مستخدمين"
|
||||
|
||||
@@ -23313,7 +23303,7 @@ msgstr "ينطبق على الشاشات العريضة فقط."
|
||||
msgid "Only credit cards are accepted. Debit and prepaid cards cannot be used for the mature content check."
|
||||
msgstr "يتم قبول بطاقات الائتمان فقط. لا يمكن استخدام بطاقات الخصم والبطاقات مسبقة الدفع في التحقق من المحتوى للبالغين."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:946
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:948
|
||||
msgid "Only default speakers"
|
||||
msgstr "مكبرات الصوت الافتراضية فقط"
|
||||
|
||||
@@ -23361,7 +23351,7 @@ msgstr "لا تشغّل هنا إلا التعليمات البرمجية الت
|
||||
msgid "Only show message previews for DMs with unread messages"
|
||||
msgstr "إظهار معاينات الرسائل فقط للرسائل المباشرة التي تحتوي على رسائل غير مقروءة"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:936
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:938
|
||||
msgid "Only speakers"
|
||||
msgstr "مكبرات الصوت فقط"
|
||||
|
||||
@@ -23901,21 +23891,6 @@ msgstr "افتح تطبيق الرسائل على هاتفك وابدأ رسال
|
||||
msgid "Open your settings"
|
||||
msgstr "فتح إعداداتك"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:121
|
||||
msgid "OpenH264 software codec on Linux"
|
||||
msgstr "ترميز OpenH264 البرمجي على نظام Linux"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:109
|
||||
msgid "OpenH264 video codec"
|
||||
msgstr "برنامج ترميز الفيديو OpenH264"
|
||||
|
||||
#. Switch label for the OpenH264 codec toggle. "OpenH264" is a product name and "Cisco Systems, Inc." is a company name; do not translate either.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:42
|
||||
msgid "OpenH264 Video Codec provided by Cisco Systems, Inc."
|
||||
msgstr "برنامج ترميز الفيديو OpenH264 مقدم من Cisco Systems, Inc."
|
||||
|
||||
#. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening.
|
||||
#: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66
|
||||
msgid "Opens the Members page. Press Space or Enter to open."
|
||||
@@ -24411,7 +24386,7 @@ msgid "Pause invites? New users won't be able to join through invite links until
|
||||
msgstr "إيقاف الدعوات مؤقتًا؟ لن يتمكن المستخدمون الجدد من الانضمام عبر روابط الدعوة حتى تعيد تمكينها. لن يتأثر الأعضاء الحاليون."
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:47
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:40
|
||||
msgid "Pause my screen share preview in the background"
|
||||
msgstr "إيقاف معاينة مشاركة الشاشة مؤقتًا في الخلفية"
|
||||
|
||||
@@ -24528,7 +24503,7 @@ msgid "Per-community profiles"
|
||||
msgstr "ملفات شخصية لكل مجتمع"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:32
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:31
|
||||
msgid "Per-device volume"
|
||||
msgstr "مستوى الصوت لكل جهاز"
|
||||
|
||||
@@ -24776,7 +24751,7 @@ msgid "Picture"
|
||||
msgstr "صورة"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:133
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:116
|
||||
msgid "Picture-in-picture"
|
||||
msgstr "صورة داخل صورة"
|
||||
|
||||
@@ -24906,7 +24881,7 @@ msgid "Pins popout"
|
||||
msgstr "نافذة الرسائل المثبتة"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:137
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:120
|
||||
msgid "PiP"
|
||||
msgstr "PiP"
|
||||
|
||||
@@ -25108,7 +25083,7 @@ msgid "Prefer @mention"
|
||||
msgstr "تفضيل الإشارة بـ @"
|
||||
|
||||
#. Option label for an encoder path select. Refers to hardware video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:67
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:60
|
||||
msgid "Prefer hardware"
|
||||
msgstr "تفضيل مرمز الأجهزة"
|
||||
|
||||
@@ -25120,7 +25095,7 @@ msgid "Prefer no @mention"
|
||||
msgstr "تفضيل عدم الإشارة بـ @"
|
||||
|
||||
#. Option label for an encoder path select. Refers to software video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:71
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:64
|
||||
msgid "Prefer software"
|
||||
msgstr "تفضيل المرمز البرمجي"
|
||||
|
||||
@@ -25140,7 +25115,7 @@ msgid "Preferences..."
|
||||
msgstr "التفضيلات..."
|
||||
|
||||
#. Accessible label for the advanced screen-share codec preference select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:125
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:118
|
||||
msgid "Preferred screen share codec"
|
||||
msgstr "برنامج الترميز المفضل لمشاركة الشاشة"
|
||||
|
||||
@@ -25314,7 +25289,7 @@ msgid "Preview sound"
|
||||
msgstr "معاينة الصوت"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:145
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:128
|
||||
msgid "Preview, popout, and stream thumbnail behavior"
|
||||
msgstr "سلوك المعاينة والنافذة المنفصلة والصورة المصغرة للبث"
|
||||
|
||||
@@ -25644,7 +25619,7 @@ msgid "Prompt"
|
||||
msgstr "مربع حوار"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:24
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:23
|
||||
msgid "Prompt for new audio devices"
|
||||
msgstr "السؤال عند توصيل أجهزة صوت جديدة"
|
||||
|
||||
@@ -27659,7 +27634,7 @@ msgstr "يعتمد تغيير الحجم والتكبير ومحاذاة الن
|
||||
#. Section header in the stream settings menu for resolution options.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:206
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:128
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:129
|
||||
msgid "Resolution"
|
||||
msgstr "الدقة"
|
||||
|
||||
@@ -28352,7 +28327,7 @@ msgstr "مطلوب إذن تسجيل الشاشة"
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:28
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:222
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:234
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:88
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:89
|
||||
msgid "Screen share"
|
||||
msgstr "مشاركة الشاشة"
|
||||
|
||||
@@ -28372,17 +28347,17 @@ msgid "Screen share audio couldn't start"
|
||||
msgstr "تعذّر بدء صوت مشاركة الشاشة"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:68
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:67
|
||||
msgid "Screen share codec"
|
||||
msgstr "برنامج ترميز مشاركة الشاشة"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:149
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:132
|
||||
msgid "Screen share encoder controls"
|
||||
msgstr "عناصر التحكم في مُرمِّز مشاركة الشاشة"
|
||||
|
||||
#. Title of a modal shown when an active screen share stops unexpectedly.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:119
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:120
|
||||
msgctxt "screen-share"
|
||||
msgid "Screen share ended"
|
||||
msgstr "انتهت مشاركة الشاشة"
|
||||
@@ -28398,12 +28373,12 @@ msgid "Screen share is using software encoding ({codec}). Quality may be reduced
|
||||
msgstr "تستخدم مشاركة الشاشة الترميز البرمجي ({codec}). قد تنخفض الجودة ويزداد استخدام وحدة المعالجة المركزية. قد لا تدعم وحدة معالجة الرسومات لديك الترميز عبر العتاد لبرنامج الترميز هذا."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:129
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:112
|
||||
msgid "Screen share preview"
|
||||
msgstr "معاينة مشاركة الشاشة"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:125
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:108
|
||||
msgid "Screen share preview behavior"
|
||||
msgstr "سلوك معاينة مشاركة الشاشة"
|
||||
|
||||
@@ -28464,7 +28439,7 @@ msgid "Screen sharing"
|
||||
msgstr "مشاركة الشاشة"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:72
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:71
|
||||
msgid "Screen sharing codec"
|
||||
msgstr "ترميز مشاركة الشاشة"
|
||||
|
||||
@@ -29829,7 +29804,7 @@ msgid "Share screens and windows."
|
||||
msgstr "مشاركة الشاشات والنوافذ."
|
||||
|
||||
#. Toggle label for sharing audio with an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:156
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:157
|
||||
msgid "Share stream audio"
|
||||
msgstr "مشاركة صوت البث"
|
||||
|
||||
@@ -29909,12 +29884,12 @@ msgid "Sharing personal information"
|
||||
msgstr "مشاركة معلومات شخصية"
|
||||
|
||||
#. Description for the free-tier Screen share streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:97
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:98
|
||||
msgid "Sharp text at 720p, up to 30 FPS"
|
||||
msgstr "نص حاد بدقة 720 بكسل، حتى 30 إطارًا في الثانية"
|
||||
|
||||
#. Description for the high-tier Screen share streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:92
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:93
|
||||
msgid "Sharp text at up to 4K, up to 15 FPS"
|
||||
msgstr "نص حاد بدقة تصل إلى 4K، وما يصل إلى 15 إطارًا في الثانية"
|
||||
|
||||
@@ -30327,7 +30302,7 @@ msgstr "إظهار Neko"
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Short label for an advanced voice device preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVoiceControls.tsx:11
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:8
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:7
|
||||
msgid "Show new device alerts"
|
||||
msgstr "إظهار تنبيهات الأجهزة الجديدة"
|
||||
|
||||
@@ -30364,7 +30339,7 @@ msgid "Show password"
|
||||
msgstr "عرض كلمة المرور"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:44
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:43
|
||||
msgid "Show per-device participant volume sliders in voice menus"
|
||||
msgstr "إظهار عناصر تحكم مستوى صوت المشاركين لكل جهاز في قوائم الصوت"
|
||||
|
||||
@@ -30662,7 +30637,7 @@ msgstr "مكالمات صامتة"
|
||||
msgid "Silent calls from everyone"
|
||||
msgstr "مكالمات صامتة من الجميع"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:872
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:874
|
||||
msgid "Silent when the capture device has no audio input of its own"
|
||||
msgstr "صامت عندما لا يحتوي جهاز الالتقاط على مدخل صوتي خاص به"
|
||||
|
||||
@@ -30689,7 +30664,7 @@ msgid "Single community mode"
|
||||
msgstr "وضع المجتمع الواحد"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:87
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:80
|
||||
msgid "Single layer"
|
||||
msgstr "طبقة واحدة"
|
||||
|
||||
@@ -30975,12 +30950,12 @@ msgid "Smileys"
|
||||
msgstr "وجوه مبتسمة"
|
||||
|
||||
#. Description for the free-tier Gaming streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:84
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:85
|
||||
msgid "Smooth 30 FPS at up to 720p"
|
||||
msgstr "سلس بدقة 30 إطارًا في الثانية وبدقة تصل إلى 720 بكسل"
|
||||
|
||||
#. Description for the high-tier Gaming streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:79
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:80
|
||||
msgid "Smooth 60 FPS at up to 1440p"
|
||||
msgstr "معدل 60 إطارًا في الثانية بسلاسة بدقة تصل إلى 1440 بكسل"
|
||||
|
||||
@@ -31028,7 +31003,7 @@ msgid "Social"
|
||||
msgstr "اجتماعي"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:157
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:140
|
||||
msgid "Software encoder"
|
||||
msgstr "مرمز برمجي"
|
||||
|
||||
@@ -31258,7 +31233,7 @@ msgstr "الأصوات"
|
||||
#. Short label in the video tab for the native source resolution option. Keep it concise.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:83
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:250
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:110
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:111
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:336
|
||||
msgid "Source"
|
||||
msgstr "المصدر"
|
||||
@@ -32047,13 +32022,13 @@ msgstr "تم إخفاء البث"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Short label in the user settings voice activity card. Keep it concise.
|
||||
#: src/features/user/components/profile/VoiceActivityCard.tsx:90
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:141
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:124
|
||||
#: src/features/voice/components/StreamWatchHoverCard.tsx:21
|
||||
msgid "Stream preview"
|
||||
msgstr "معاينة البث"
|
||||
|
||||
#. Compact submenu label for changing the resolution and frame rate of an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:152
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:153
|
||||
msgid "Stream quality"
|
||||
msgstr "جودة البث"
|
||||
|
||||
@@ -32079,7 +32054,7 @@ msgid "Streaming"
|
||||
msgstr "البث"
|
||||
|
||||
#. Section header in the stream settings menu. Picks between Gaming / Screen share / Custom presets.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:124
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:125
|
||||
msgid "Streaming mode"
|
||||
msgstr "وضع البث"
|
||||
|
||||
@@ -32280,12 +32255,12 @@ msgid "Suspend"
|
||||
msgstr "تعليق"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:165
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:148
|
||||
msgid "SVC"
|
||||
msgstr "SVC"
|
||||
|
||||
#. Label for an advanced screen-share select. SVC is a WebRTC acronym and should stay uppercase.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:75
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:68
|
||||
msgid "SVC mode"
|
||||
msgstr "وضع SVC"
|
||||
|
||||
@@ -32559,7 +32534,7 @@ msgstr "إعلانات النظام من فريق {productName}. لا يمكنك
|
||||
#. Audio device option label meaning the OS default audio input device.
|
||||
#. Footer summary fallback for the default capture-device audio input.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:263
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:140
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:141
|
||||
msgid "System default"
|
||||
msgstr "الجهاز الافتراضي للنظام"
|
||||
|
||||
@@ -32760,7 +32735,7 @@ msgid "Template URL"
|
||||
msgstr "رابط القالب"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:91
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:84
|
||||
msgid "Temporal layers"
|
||||
msgstr "طبقات زمنية"
|
||||
|
||||
@@ -32837,7 +32812,7 @@ msgstr "تكساس"
|
||||
#: src/features/channel/utils/ChannelUtils.tsx:51
|
||||
#: src/features/lexical/composer/nodes/SlashSlotNode.ts:23
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:50
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:116
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:109
|
||||
msgid "Text"
|
||||
msgstr "نص"
|
||||
|
||||
@@ -33247,7 +33222,7 @@ msgid "The role granted {permissions}"
|
||||
msgstr "منح الدور {permissions}"
|
||||
|
||||
#. Body of a modal shown when a browser screen share track ends outside the app, for example from the browser sharing controls.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:124
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:125
|
||||
msgctxt "screen-share"
|
||||
msgid "The screen share source stopped sending video, so your screen share was stopped."
|
||||
msgstr "توقف مصدر مشاركة الشاشة عن إرسال الفيديو، لذلك أُوقفت مشاركة شاشتك."
|
||||
@@ -35774,7 +35749,7 @@ msgstr "مجموعة بلا اسم"
|
||||
#. Fallback label for an audio input device whose name is not reported by the OS.
|
||||
#. Footer summary fallback for an audio input without a reported name.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:267
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:144
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:145
|
||||
msgid "Unnamed input"
|
||||
msgstr "جهاز إدخال بلا اسم"
|
||||
|
||||
@@ -36662,7 +36637,7 @@ msgid "Used in communities that do not have their own sound."
|
||||
msgstr "يُستخدم في المجتمعات التي ليس لديها صوت خاص بها."
|
||||
|
||||
#. Description for a WebRTC content hint select. Custom, Gaming and Screen share are the stream quality preset labels.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:92
|
||||
msgid "Used in Custom mode. The Gaming and Screen share presets set their own hint."
|
||||
msgstr "مُستخدم في الوضع المخصص. الإعدادات المسبقة للألعاب ومشاركة الشاشة تحدد تلميحها الخاص."
|
||||
|
||||
@@ -37236,12 +37211,12 @@ msgid "Video call unavailable"
|
||||
msgstr "مكالمة الفيديو غير متاحة"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:80
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:79
|
||||
msgid "Video codec"
|
||||
msgstr "ترميز الفيديو"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:84
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:83
|
||||
msgid "Video codec for screen sharing"
|
||||
msgstr "برنامج ترميز الفيديو لمشاركة الشاشة"
|
||||
|
||||
@@ -37912,7 +37887,7 @@ msgid "Volume mute"
|
||||
msgstr "كتم الصوت"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:40
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:39
|
||||
msgid "Volume sliders"
|
||||
msgstr "أشرطة مستوى الصوت"
|
||||
|
||||
@@ -38466,6 +38441,11 @@ msgstr "ما الذي تريد تضمينه"
|
||||
msgid "What to search for."
|
||||
msgstr "ما تريد البحث عنه."
|
||||
|
||||
#. Informational overlay on your own screen-share tile while the shared screen or window sends no new frames, for example when it is static or minimised. Not an error.
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:180
|
||||
msgid "What you're sharing isn't updating"
|
||||
msgstr "ما تشاركه لا يتم تحديثه"
|
||||
|
||||
#. Placeholder question in the custom status text field.
|
||||
#. Placeholder question in the custom status text field.
|
||||
#: src/features/user/components/modals/CustomStatusBottomSheet.tsx:40
|
||||
@@ -39972,7 +39952,7 @@ msgid "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProduc
|
||||
msgstr "تحتاج جودة الشاشة المحفوظة لديك بدقة {savedResolution} بمعدل إطارات {savedFrameRate} إطارًا في الثانية إلى {premiumProductName}، لذا تستخدم المشاركات دقة {resolution} بمعدل إطارات {frameRate} إطارًا في الثانية."
|
||||
|
||||
#. Body of a modal shown when the video codec the browser negotiated for an active screen share is outside the codecs the app allows and no allowed codec could be published, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:130
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:131
|
||||
msgctxt "screen-share"
|
||||
msgid "Your screen share could not be published with a compatible video codec, so it was stopped. Try sharing again."
|
||||
msgstr "تعذّر نشر مشاركة شاشتك باستخدام برنامج ترميز فيديو متوافق، لذا تم إيقافها. حاول المشاركة مرة أخرى."
|
||||
@@ -40071,7 +40051,7 @@ msgid "Your vanity URL has been set to {inviteEndpoint}/{vanitySlug}"
|
||||
msgstr "تم تعيين عنوان URL المخصص إلى {inviteEndpoint}/{vanitySlug}"
|
||||
|
||||
#. Body of a modal shown when the local video encoder produces no frames for an active screen share, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:137
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:138
|
||||
msgctxt "screen-share"
|
||||
msgid "Your video encoder stopped encoding frames, so your screen share was stopped. Try sharing again."
|
||||
msgstr "توقف مُرمّز الفيديو الخاص بك عن ترميز الإطارات، لذا تم إيقاف مشاركة شاشتك. حاول المشاركة مرة أخرى."
|
||||
|
||||
@@ -3144,6 +3144,9 @@
|
||||
{
|
||||
"msgid": "What to search for."
|
||||
},
|
||||
{
|
||||
"msgid": "What you're sharing isn't updating"
|
||||
},
|
||||
{
|
||||
"msgid": "Who can join"
|
||||
},
|
||||
|
||||
@@ -1070,7 +1070,7 @@ msgstr "{count, plural, one {Разгъване (# ред)} other {Разгъв
|
||||
msgid "{count, plural, one {Expand (# row)} other {Expand (# rows)}}"
|
||||
msgstr "{count, plural, one {Разгъване (# ред)} other {Разгъване (# реда)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:315
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:320
|
||||
msgid "{count, plural, one {Expand # other device} other {Expand # other devices}}"
|
||||
msgstr "{count, plural, one {Разгъване на # друго устройство} other {Разгъване на # други устройства}}"
|
||||
|
||||
@@ -1091,7 +1091,7 @@ msgstr "{count, plural, one {В разговор} other {# в разговор}}
|
||||
msgid "{count, plural, one {Join video call (# participant)} other {Join video call (# participants)}}"
|
||||
msgstr "{count, plural, one {Присъединяване към видеоразговор (# участник)} other {Присъединяване към видеоразговор (# участници)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:671
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:676
|
||||
msgid "{count, plural, one {Keep watching # stream and add this one} other {Keep watching # streams and add this one}}"
|
||||
msgstr "{count, plural, one {Продължи да гледаш # поток и добави този} other {Продължи да гледаш # потока и добави този}}"
|
||||
|
||||
@@ -1347,7 +1347,7 @@ msgstr "{fps} FPS"
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:53
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:114
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:115
|
||||
msgid "{frameRate} FPS"
|
||||
msgstr "{frameRate} FPS"
|
||||
|
||||
@@ -3590,8 +3590,8 @@ msgstr "Позволява на всеки, който може да вижда
|
||||
|
||||
#. Settings search entry label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#. Switch label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:143
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:88
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:136
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:87
|
||||
msgid "Allow AV1 for screen sharing"
|
||||
msgstr "Разрешаване на AV1 за споделяне на екрана"
|
||||
|
||||
@@ -3649,8 +3649,8 @@ msgstr "Разрешаване за всички общности"
|
||||
|
||||
#. Settings search entry label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#. Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:147
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:92
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:140
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:91
|
||||
msgid "Allow H.265 (HEVC) for screen sharing"
|
||||
msgstr "Разрешаване на H.265 (HEVC) за споделяне на екран"
|
||||
|
||||
@@ -4681,12 +4681,12 @@ msgid "Audio boost"
|
||||
msgstr "Усилване на звука"
|
||||
|
||||
#. Section header in the stream settings menu. Selects the audio capture device for the share.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:136
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:137
|
||||
msgid "Audio device"
|
||||
msgstr "Аудиоустройство"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:16
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:15
|
||||
msgid "Audio device alerts"
|
||||
msgstr "Предупреждения за аудиоустройства"
|
||||
|
||||
@@ -4776,7 +4776,7 @@ msgid "Audio send:"
|
||||
msgstr "Изпращане на аудио:"
|
||||
|
||||
#. Section header in the stream settings menu grouping audio-related toggles.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:148
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:149
|
||||
msgid "Audio settings"
|
||||
msgstr "Настройки на звука"
|
||||
|
||||
@@ -4987,9 +4987,9 @@ msgstr "Предложение за автоматично довършване:
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#. Voice region picker option label meaning 'let Fluxer pick the best region automatically'.
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx:19
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:63
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:83
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:104
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:56
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:76
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:97
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts:264
|
||||
#: src/features/voice/components/VoiceRegionSelector.tsx:16
|
||||
#: src/features/voice/components/VoiceSettingsMenus.tsx:93
|
||||
@@ -4997,13 +4997,13 @@ msgid "Automatic"
|
||||
msgstr "Автоматично"
|
||||
|
||||
#. Option label for the screen-share codec select. codec is the automatically selected codec label.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:129
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:122
|
||||
msgid "Automatic ({codec})"
|
||||
msgstr "Автоматично ({codec})"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Voice setting label for automatically evening out microphone volume.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:48
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:47
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:215
|
||||
msgid "Automatic gain control"
|
||||
msgstr "Автоматичен контрол на усилването"
|
||||
@@ -5094,12 +5094,12 @@ msgid "Autoscrolling"
|
||||
msgstr "Автоматично превъртане"
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:101
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:100
|
||||
msgid "AV1"
|
||||
msgstr "AV1"
|
||||
|
||||
#. Option label for the screen-share codec select while the AV1 opt-in is off and the option is disabled. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:133
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:126
|
||||
msgid "AV1 (requires opt-in)"
|
||||
msgstr "AV1 (изисква активиране)"
|
||||
|
||||
@@ -5558,7 +5558,7 @@ msgid "Biometrics"
|
||||
msgstr "Биометрични данни"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:169
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:152
|
||||
msgid "Bitrate"
|
||||
msgstr "Битрейт"
|
||||
|
||||
@@ -5953,7 +5953,7 @@ msgid "Browser locale: {format}"
|
||||
msgstr "Час според настройките на браузъра: {format}"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:64
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:63
|
||||
msgid "Browser mic gain for custom processing"
|
||||
msgstr "Усилване на микрофона в браузъра за персонализирана обработка"
|
||||
|
||||
@@ -6483,15 +6483,15 @@ msgstr "Проверката с CAPTCHA не бе успешна. Опитай
|
||||
msgid "Capture"
|
||||
msgstr "Запис"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:810
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
msgid "Capture app audio"
|
||||
msgstr "Запис на звука от приложението"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:814
|
||||
msgid "Capture desktop audio"
|
||||
msgstr "Запис на звука от работния плот"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:809
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:811
|
||||
msgid "Capture device audio"
|
||||
msgstr "Запис на звука от устройството"
|
||||
|
||||
@@ -6501,7 +6501,7 @@ msgid "Capture devices use the Gaming preset."
|
||||
msgstr "Устройствата за заснемане използват предварителната настройка „Игри“."
|
||||
|
||||
#. Toggle label in the stream settings menu for a window share whose audio scope the user widened from the shared window to the whole system mix.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:160
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:161
|
||||
msgid "Capture entire system audio"
|
||||
msgstr "Запис на целия системен звук"
|
||||
|
||||
@@ -7637,11 +7637,6 @@ msgstr "Избери език"
|
||||
msgid "Choose your profile accent color"
|
||||
msgstr "Избери акцентния цвят на профила си"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:117
|
||||
msgid "Cisco"
|
||||
msgstr "Cisco"
|
||||
|
||||
#. Generic account action label that opens or submits the flow to secure an unclaimed account.
|
||||
#: src/features/i18n/utils/CommonMessageDescriptors.ts:152
|
||||
msgid "Claim account"
|
||||
@@ -8240,7 +8235,7 @@ msgid "Code:"
|
||||
msgstr "Код:"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:76
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:75
|
||||
msgid "Codec"
|
||||
msgstr "Кодек"
|
||||
|
||||
@@ -8766,7 +8761,7 @@ msgstr "Фокус на текстовото поле"
|
||||
#. Button label that opens a dedicated advanced settings modal for configuring screen-share encoder controls.
|
||||
#. Button label that opens a dedicated advanced settings modal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:164
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:120
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:113
|
||||
msgid "Configure"
|
||||
msgstr "Конфигуриране"
|
||||
|
||||
@@ -9043,7 +9038,7 @@ msgid "Connection volume"
|
||||
msgstr "Сила на звука на връзката"
|
||||
|
||||
#. Settings search entry label. Names an advanced voice setting in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:28
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:27
|
||||
msgid "Connection volume controls"
|
||||
msgstr "Управление на силата на звука по връзки"
|
||||
|
||||
@@ -9114,7 +9109,7 @@ msgid "Content filtering"
|
||||
msgstr "Филтриране на съдържание"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to the WebRTC MediaStreamTrack contentHint value.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:95
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:88
|
||||
msgid "Content hint"
|
||||
msgstr "Подсказка за съдържанието"
|
||||
|
||||
@@ -10594,7 +10589,7 @@ msgstr "В момента: {mutedText}"
|
||||
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:94
|
||||
#: src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx:45
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:238
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:102
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:103
|
||||
msgid "Custom"
|
||||
msgstr "По избор"
|
||||
|
||||
@@ -10789,7 +10784,7 @@ msgid "Custom video backgrounds"
|
||||
msgstr "Персонализирани фонове за видео"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:60
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:59
|
||||
msgid "Custom voice processing"
|
||||
msgstr "Персонализирана обработка на глас"
|
||||
|
||||
@@ -11810,7 +11805,7 @@ msgid "Detached banner"
|
||||
msgstr "Разкачен банер"
|
||||
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:112
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:105
|
||||
msgid "Detail"
|
||||
msgstr "Детайл"
|
||||
|
||||
@@ -11880,7 +11875,7 @@ msgid "Device"
|
||||
msgstr "Устройство"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:12
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:11
|
||||
msgid "Device alerts"
|
||||
msgstr "Предупреждения за устройства"
|
||||
|
||||
@@ -11890,7 +11885,7 @@ msgid "Device audio ({deviceLabel})"
|
||||
msgstr "Аудио на устройството ({deviceLabel})"
|
||||
|
||||
#. Screen-share audio summary on a video device share that publishes only the audio of the capture device itself.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:866
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:868
|
||||
#: src/features/voice/utils/ScreenShareAudioSummary.ts:50
|
||||
msgid "Device audio only"
|
||||
msgstr "Само аудио от устройството"
|
||||
@@ -11904,12 +11899,12 @@ msgstr "Подробности за устройството"
|
||||
msgid "Device revoked"
|
||||
msgstr "Достъпът на устройството е отнет"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1004
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1006
|
||||
msgid "Device selection"
|
||||
msgstr "Избор на устройство"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:36
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:35
|
||||
msgid "Device volume"
|
||||
msgstr "Сила на звука на устройството"
|
||||
|
||||
@@ -11933,7 +11928,7 @@ msgid "Devices"
|
||||
msgstr "Устройства"
|
||||
|
||||
#. Description for the Custom streaming preset. Lightly playful prose; keep tone casual.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:106
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:107
|
||||
msgid "Dial in your own numbers"
|
||||
msgstr "Задай свои стойности"
|
||||
|
||||
@@ -12109,7 +12104,7 @@ msgid "Disable mobile push notifications"
|
||||
msgstr "Изключване на мобилните push известия"
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:51
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:44
|
||||
msgid "Disable picture-in-picture popout for screen shares"
|
||||
msgstr "Изключване на изскачащия прозорец „картина в картината“ за споделяне на екран"
|
||||
|
||||
@@ -12629,7 +12624,7 @@ msgstr "Не предлагай {deviceName} отново"
|
||||
#: src/features/moderation/components/report/ReportBreadcrumbs.tsx:31
|
||||
#: src/features/search/components/search/ChannelFilterSheet.tsx:165
|
||||
#: src/features/search/components/search/HasFilterSheet.tsx:206
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:175
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:186
|
||||
msgid "Done"
|
||||
msgstr "Готово"
|
||||
|
||||
@@ -13756,8 +13751,8 @@ msgstr "Включено"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to hardware or software video encoding.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:59
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:153
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:136
|
||||
msgid "Encoder path"
|
||||
msgstr "Път на енкодера"
|
||||
|
||||
@@ -13767,7 +13762,7 @@ msgid "Encoder preference for new screen shares."
|
||||
msgstr "Предпочитан енкодер за нови споделяния на екрана."
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:173
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:156
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "Кодер, SVC и битрейт предварителни настройки"
|
||||
|
||||
@@ -15616,7 +15611,7 @@ msgstr "Рамка"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:341
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:132
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:133
|
||||
msgid "Frame rate"
|
||||
msgstr "Кадрова честота"
|
||||
|
||||
@@ -15840,7 +15835,7 @@ msgid "Fully quit and restart {productName} so macOS applies this permission."
|
||||
msgstr "Затвори напълно и рестартирай {productName}, за да приложи macOS това разрешение."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:51
|
||||
msgid "Gain control"
|
||||
msgstr "Контрол на усилването"
|
||||
|
||||
@@ -15946,7 +15941,7 @@ msgstr "Геймпад Y / Триъгълник"
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:76
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:24
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:75
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:76
|
||||
msgid "Gaming"
|
||||
msgstr "Игри"
|
||||
|
||||
@@ -16451,7 +16446,7 @@ msgstr "Предоставени {permissions}"
|
||||
msgid "Grants all permissions and bypasses channel restrictions. Highly sensitive."
|
||||
msgstr "Предоставя всички разрешения и заобикаля ограниченията на канала. Изключително чувствително разрешение."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:991
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:993
|
||||
msgid "Granular selection"
|
||||
msgstr "Подробен избор"
|
||||
|
||||
@@ -16629,13 +16624,8 @@ msgstr "Групата е актуализирана"
|
||||
msgid "Grouping"
|
||||
msgstr "Групиране"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:113
|
||||
msgid "H.264"
|
||||
msgstr "H.264"
|
||||
|
||||
#. Option label for the screen-share codec select while the HEVC opt-in is off and the option is disabled. H.265 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:138
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:131
|
||||
msgid "H.265 (requires opt-in)"
|
||||
msgstr "H.265 (изисква активиране)"
|
||||
|
||||
@@ -16691,7 +16681,7 @@ msgid "Hardware acceleration can cause rendering glitches with transparent windo
|
||||
msgstr "Хардуерното ускорение може да причини проблеми с изобразяването при прозрачни прозорци. Опитай --disable-gpu, ако виждаш трептене или размазване."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:161
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:144
|
||||
msgid "Hardware encoder"
|
||||
msgstr "Хардуерен енкодер"
|
||||
|
||||
@@ -16832,7 +16822,7 @@ msgid "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMA
|
||||
msgstr "Помогни да преведем {PRODUCT_NAME} на твоя език на <0>{I18N_WEBLATE_DOMAIN}</0>."
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:105
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:104
|
||||
msgid "HEVC"
|
||||
msgstr "HEVC"
|
||||
|
||||
@@ -16995,7 +16985,7 @@ msgid "Hide muted channels by default?"
|
||||
msgstr "Да се скриват ли заглушените канали по подразбиране?"
|
||||
|
||||
#. Short label for an advanced stream privacy preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:55
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:48
|
||||
msgid "Hide my stream preview thumbnail"
|
||||
msgstr "Скрий миниатюрата за предварителен преглед на моя поток"
|
||||
|
||||
@@ -17014,7 +17004,7 @@ msgstr "Скрий паролата"
|
||||
msgid "Hide picture-in-picture popout?"
|
||||
msgstr "Да се скрие ли изскачащият прозорец „картина в картината“?"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:768
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:770
|
||||
msgid "Hide preview thumbnail"
|
||||
msgstr "Скрий миниатюрата за предварителен преглед"
|
||||
|
||||
@@ -17417,11 +17407,11 @@ msgstr "Игнориране"
|
||||
msgid "Ignore friend request"
|
||||
msgstr "Игнориране на покана за приятелство"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:981
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:983
|
||||
msgid "Ignore hardware devices"
|
||||
msgstr "Игнориране на хардуерни устройства"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:957
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:959
|
||||
msgid "Ignore input media"
|
||||
msgstr "Игнориране на входни медии"
|
||||
|
||||
@@ -17452,7 +17442,7 @@ msgstr "Да игнорираш ли поканата за приятелств
|
||||
msgid "Ignore this channel's slowmode cooldown."
|
||||
msgstr "Игнориране на бавния режим в този канал."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:968
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:970
|
||||
msgid "Ignore virtual"
|
||||
msgstr "Игнориране на виртуални"
|
||||
|
||||
@@ -19067,7 +19057,7 @@ msgid "Layering"
|
||||
msgstr "Наслояване"
|
||||
|
||||
#. Description for an SVC mode select. Keep SVC and AV1 literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:79
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:72
|
||||
msgid "Layering for AV1 and VP9 when available."
|
||||
msgstr "Наслояване за AV1 и VP9, когато са налични."
|
||||
|
||||
@@ -20228,7 +20218,7 @@ msgid "Maximum protection for family-friendly spaces"
|
||||
msgstr "Максимална защита за пространства, подходящи за семейството"
|
||||
|
||||
#. Settings search entry description for the AV1 and H.265 screen-share opt-in toggles.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:97
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:96
|
||||
msgid "May cause compatibility issues for viewers. We’re working on improving this."
|
||||
msgstr "Може да причини проблеми със съвместимостта при зрителите. Работим по подобряването на това."
|
||||
|
||||
@@ -20957,7 +20947,7 @@ msgid "Microphone access was denied. Enable it in your system settings and try a
|
||||
msgstr "Достъпът до микрофона е отказан. Разреши го в системните настройки и опитай отново."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:56
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:55
|
||||
msgid "Microphone gain"
|
||||
msgstr "Усилване на микрофона"
|
||||
|
||||
@@ -21318,7 +21308,7 @@ msgstr "Най-подходящи"
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:102
|
||||
#: src/features/user/components/modals/tabs/accessibility_tab/AccessibilityTabInline.tsx:31
|
||||
#: src/features/user/components/modals/tabs/AccessibilityTab.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:108
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:101
|
||||
#: src/features/user/components/settings_utils/search_index/AccessibilityIndex.ts:297
|
||||
#: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:271
|
||||
msgid "Motion"
|
||||
@@ -22142,7 +22132,7 @@ msgid "New Mexico"
|
||||
msgstr "Ню Мексико"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:20
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:19
|
||||
msgid "New microphone"
|
||||
msgstr "Нов микрофон"
|
||||
|
||||
@@ -22830,11 +22820,11 @@ msgstr "Няма потребител с това потребителско и
|
||||
msgid "No users are currently banned from this community."
|
||||
msgstr "В момента няма забранени потребители в тази общност."
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users available"
|
||||
msgstr "Няма налични потребители"
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users found"
|
||||
msgstr "Няма намерени потребители"
|
||||
|
||||
@@ -23313,7 +23303,7 @@ msgstr "Прилага се само на широки екрани."
|
||||
msgid "Only credit cards are accepted. Debit and prepaid cards cannot be used for the mature content check."
|
||||
msgstr "Приемат се само кредитни карти. Дебитните и предплатените карти не могат да се използват за проверката за съдържание за възрастни."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:946
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:948
|
||||
msgid "Only default speakers"
|
||||
msgstr "Само високоговорители по подразбиране"
|
||||
|
||||
@@ -23361,7 +23351,7 @@ msgstr "Изпълнявай код тук само ако разбираш то
|
||||
msgid "Only show message previews for DMs with unread messages"
|
||||
msgstr "Показване на визуализации само за DM с непрочетени съобщения"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:936
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:938
|
||||
msgid "Only speakers"
|
||||
msgstr "Само високоговорители"
|
||||
|
||||
@@ -23901,21 +23891,6 @@ msgstr "Отвори приложението за съобщения на те
|
||||
msgid "Open your settings"
|
||||
msgstr "Отваряне на настройките"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:121
|
||||
msgid "OpenH264 software codec on Linux"
|
||||
msgstr "Софтуерен кодек OpenH264 за Linux"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:109
|
||||
msgid "OpenH264 video codec"
|
||||
msgstr "Видеокодек OpenH264"
|
||||
|
||||
#. Switch label for the OpenH264 codec toggle. "OpenH264" is a product name and "Cisco Systems, Inc." is a company name; do not translate either.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:42
|
||||
msgid "OpenH264 Video Codec provided by Cisco Systems, Inc."
|
||||
msgstr "Видеокодек OpenH264, предоставен от Cisco Systems, Inc."
|
||||
|
||||
#. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening.
|
||||
#: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66
|
||||
msgid "Opens the Members page. Press Space or Enter to open."
|
||||
@@ -24411,7 +24386,7 @@ msgid "Pause invites? New users won't be able to join through invite links until
|
||||
msgstr "Да спра ли поканите? Новите потребители няма да могат да се присъединяват чрез линкове за покани, докато не ги активираш отново. Това няма да засегне съществуващите членове."
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:47
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:40
|
||||
msgid "Pause my screen share preview in the background"
|
||||
msgstr "Пауза на прегледа на споделения от мен екран във фонов режим"
|
||||
|
||||
@@ -24528,7 +24503,7 @@ msgid "Per-community profiles"
|
||||
msgstr "Профили за всяка общност"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:32
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:31
|
||||
msgid "Per-device volume"
|
||||
msgstr "Сила на звука за всяко устройство"
|
||||
|
||||
@@ -24776,7 +24751,7 @@ msgid "Picture"
|
||||
msgstr "Снимка"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:133
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:116
|
||||
msgid "Picture-in-picture"
|
||||
msgstr "Картина в картината"
|
||||
|
||||
@@ -24906,7 +24881,7 @@ msgid "Pins popout"
|
||||
msgstr "Изскачащ прозорец със закачени съобщения"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:137
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:120
|
||||
msgid "PiP"
|
||||
msgstr "Картина в картината"
|
||||
|
||||
@@ -25108,7 +25083,7 @@ msgid "Prefer @mention"
|
||||
msgstr "Предпочитам @споменаване"
|
||||
|
||||
#. Option label for an encoder path select. Refers to hardware video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:67
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:60
|
||||
msgid "Prefer hardware"
|
||||
msgstr "Предпочитане на хардуер"
|
||||
|
||||
@@ -25120,7 +25095,7 @@ msgid "Prefer no @mention"
|
||||
msgstr "Предпочитам без @споменаване"
|
||||
|
||||
#. Option label for an encoder path select. Refers to software video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:71
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:64
|
||||
msgid "Prefer software"
|
||||
msgstr "Предпочитане на софтуер"
|
||||
|
||||
@@ -25140,7 +25115,7 @@ msgid "Preferences..."
|
||||
msgstr "Предпочитания..."
|
||||
|
||||
#. Accessible label for the advanced screen-share codec preference select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:125
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:118
|
||||
msgid "Preferred screen share codec"
|
||||
msgstr "Предпочитан кодек за споделяне на екран"
|
||||
|
||||
@@ -25314,7 +25289,7 @@ msgid "Preview sound"
|
||||
msgstr "Преглед на звука"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:145
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:128
|
||||
msgid "Preview, popout, and stream thumbnail behavior"
|
||||
msgstr "Преглед, изскачащ прозорец и поведение на миниатюрите на стрийма"
|
||||
|
||||
@@ -25644,7 +25619,7 @@ msgid "Prompt"
|
||||
msgstr "Подкана"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:24
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:23
|
||||
msgid "Prompt for new audio devices"
|
||||
msgstr "Показване на подкана при нови аудиоустройства"
|
||||
|
||||
@@ -27659,7 +27634,7 @@ msgstr "Преоразмеряването, максимизирането и п
|
||||
#. Section header in the stream settings menu for resolution options.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:206
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:128
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:129
|
||||
msgid "Resolution"
|
||||
msgstr "Разделителна способност"
|
||||
|
||||
@@ -28352,7 +28327,7 @@ msgstr "Изисква се разрешение за запис на екран
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:28
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:222
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:234
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:88
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:89
|
||||
msgid "Screen share"
|
||||
msgstr "Споделяне на екран"
|
||||
|
||||
@@ -28372,17 +28347,17 @@ msgid "Screen share audio couldn't start"
|
||||
msgstr "Аудиото за споделяне на екрана не можа да стартира"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:68
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:67
|
||||
msgid "Screen share codec"
|
||||
msgstr "Кодек за споделяне на екран"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:149
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:132
|
||||
msgid "Screen share encoder controls"
|
||||
msgstr "Контроли за кодиране на споделяне на екрана"
|
||||
|
||||
#. Title of a modal shown when an active screen share stops unexpectedly.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:119
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:120
|
||||
msgctxt "screen-share"
|
||||
msgid "Screen share ended"
|
||||
msgstr "Споделянето на екрана приключи"
|
||||
@@ -28398,12 +28373,12 @@ msgid "Screen share is using software encoding ({codec}). Quality may be reduced
|
||||
msgstr "Споделянето на екрана използва софтуерно кодиране ({codec}). Качеството може да е намалено, а използването на процесора да се увеличи. Графичният ти процесор може да не поддържа хардуерно кодиране за този кодек."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:129
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:112
|
||||
msgid "Screen share preview"
|
||||
msgstr "Визуализация на споделен екран"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:125
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:108
|
||||
msgid "Screen share preview behavior"
|
||||
msgstr "Поведение при предварителен преглед на споделянето на екрана"
|
||||
|
||||
@@ -28464,7 +28439,7 @@ msgid "Screen sharing"
|
||||
msgstr "Споделяне на екран"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:72
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:71
|
||||
msgid "Screen sharing codec"
|
||||
msgstr "Кодек за споделяне на екран"
|
||||
|
||||
@@ -29829,7 +29804,7 @@ msgid "Share screens and windows."
|
||||
msgstr "Споделяй екрани и прозорци."
|
||||
|
||||
#. Toggle label for sharing audio with an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:156
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:157
|
||||
msgid "Share stream audio"
|
||||
msgstr "Споделяне на звука от стрийма"
|
||||
|
||||
@@ -29909,12 +29884,12 @@ msgid "Sharing personal information"
|
||||
msgstr "Споделяне на лична информация"
|
||||
|
||||
#. Description for the free-tier Screen share streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:97
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:98
|
||||
msgid "Sharp text at 720p, up to 30 FPS"
|
||||
msgstr "Ясен текст на 720p, до 30 FPS"
|
||||
|
||||
#. Description for the high-tier Screen share streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:92
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:93
|
||||
msgid "Sharp text at up to 4K, up to 15 FPS"
|
||||
msgstr "Ясен текст до 4K, до 15 FPS"
|
||||
|
||||
@@ -30327,7 +30302,7 @@ msgstr "Показване на Неко"
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Short label for an advanced voice device preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVoiceControls.tsx:11
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:8
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:7
|
||||
msgid "Show new device alerts"
|
||||
msgstr "Показване на известия за нови устройства"
|
||||
|
||||
@@ -30364,7 +30339,7 @@ msgid "Show password"
|
||||
msgstr "Покажи паролата"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:44
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:43
|
||||
msgid "Show per-device participant volume sliders in voice menus"
|
||||
msgstr "Показване на плъзгачи за силата на звука на участниците за всяко устройство в гласовите менюта"
|
||||
|
||||
@@ -30662,7 +30637,7 @@ msgstr "Безшумни повиквания"
|
||||
msgid "Silent calls from everyone"
|
||||
msgstr "Безшумни повиквания от всички"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:872
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:874
|
||||
msgid "Silent when the capture device has no audio input of its own"
|
||||
msgstr "Тихо, когато устройството за заснемане няма собствен аудио вход"
|
||||
|
||||
@@ -30689,7 +30664,7 @@ msgid "Single community mode"
|
||||
msgstr "Режим на една общност"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:87
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:80
|
||||
msgid "Single layer"
|
||||
msgstr "Един слой"
|
||||
|
||||
@@ -30975,12 +30950,12 @@ msgid "Smileys"
|
||||
msgstr "Емотикони"
|
||||
|
||||
#. Description for the free-tier Gaming streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:84
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:85
|
||||
msgid "Smooth 30 FPS at up to 720p"
|
||||
msgstr "Гладко 30 FPS при до 720p"
|
||||
|
||||
#. Description for the high-tier Gaming streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:79
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:80
|
||||
msgid "Smooth 60 FPS at up to 1440p"
|
||||
msgstr "Плавно 60 FPS при резолюция до 1440p"
|
||||
|
||||
@@ -31028,7 +31003,7 @@ msgid "Social"
|
||||
msgstr "Социални мрежи"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:157
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:140
|
||||
msgid "Software encoder"
|
||||
msgstr "Софтуерен енкодер"
|
||||
|
||||
@@ -31258,7 +31233,7 @@ msgstr "Звуци"
|
||||
#. Short label in the video tab for the native source resolution option. Keep it concise.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:83
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:250
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:110
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:111
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:336
|
||||
msgid "Source"
|
||||
msgstr "Източник"
|
||||
@@ -32047,13 +32022,13 @@ msgstr "Потокът е скрит"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Short label in the user settings voice activity card. Keep it concise.
|
||||
#: src/features/user/components/profile/VoiceActivityCard.tsx:90
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:141
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:124
|
||||
#: src/features/voice/components/StreamWatchHoverCard.tsx:21
|
||||
msgid "Stream preview"
|
||||
msgstr "Преглед на потока"
|
||||
|
||||
#. Compact submenu label for changing the resolution and frame rate of an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:152
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:153
|
||||
msgid "Stream quality"
|
||||
msgstr "Качество на потока"
|
||||
|
||||
@@ -32079,7 +32054,7 @@ msgid "Streaming"
|
||||
msgstr "Предава на живо"
|
||||
|
||||
#. Section header in the stream settings menu. Picks between Gaming / Screen share / Custom presets.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:124
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:125
|
||||
msgid "Streaming mode"
|
||||
msgstr "Режим на стрийминг"
|
||||
|
||||
@@ -32280,12 +32255,12 @@ msgid "Suspend"
|
||||
msgstr "Спиране"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:165
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:148
|
||||
msgid "SVC"
|
||||
msgstr "SVC"
|
||||
|
||||
#. Label for an advanced screen-share select. SVC is a WebRTC acronym and should stay uppercase.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:75
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:68
|
||||
msgid "SVC mode"
|
||||
msgstr "Режим SVC"
|
||||
|
||||
@@ -32559,7 +32534,7 @@ msgstr "Системни съобщения от екипа на {productName}.
|
||||
#. Audio device option label meaning the OS default audio input device.
|
||||
#. Footer summary fallback for the default capture-device audio input.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:263
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:140
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:141
|
||||
msgid "System default"
|
||||
msgstr "Системно по подразбиране"
|
||||
|
||||
@@ -32760,7 +32735,7 @@ msgid "Template URL"
|
||||
msgstr "URL на шаблон"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:91
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:84
|
||||
msgid "Temporal layers"
|
||||
msgstr "Времеви слоеве"
|
||||
|
||||
@@ -32837,7 +32812,7 @@ msgstr "Тексас"
|
||||
#: src/features/channel/utils/ChannelUtils.tsx:51
|
||||
#: src/features/lexical/composer/nodes/SlashSlotNode.ts:23
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:50
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:116
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:109
|
||||
msgid "Text"
|
||||
msgstr "Текст"
|
||||
|
||||
@@ -33247,7 +33222,7 @@ msgid "The role granted {permissions}"
|
||||
msgstr "Ролята предостави {permissions}"
|
||||
|
||||
#. Body of a modal shown when a browser screen share track ends outside the app, for example from the browser sharing controls.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:124
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:125
|
||||
msgctxt "screen-share"
|
||||
msgid "The screen share source stopped sending video, so your screen share was stopped."
|
||||
msgstr "Източникът на споделяне на екрана спря да изпраща видео, поради което споделянето на екрана беше прекратено."
|
||||
@@ -35774,7 +35749,7 @@ msgstr "Група без име"
|
||||
#. Fallback label for an audio input device whose name is not reported by the OS.
|
||||
#. Footer summary fallback for an audio input without a reported name.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:267
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:144
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:145
|
||||
msgid "Unnamed input"
|
||||
msgstr "Неименуван вход"
|
||||
|
||||
@@ -36662,7 +36637,7 @@ msgid "Used in communities that do not have their own sound."
|
||||
msgstr "Използва се в общности, които нямат собствен звук."
|
||||
|
||||
#. Description for a WebRTC content hint select. Custom, Gaming and Screen share are the stream quality preset labels.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:92
|
||||
msgid "Used in Custom mode. The Gaming and Screen share presets set their own hint."
|
||||
msgstr "Използва се в режим „Персонализирано“. Предварителните настройки „Игри“ и „Споделяне на екран“ задават свой собствен намек."
|
||||
|
||||
@@ -37236,12 +37211,12 @@ msgid "Video call unavailable"
|
||||
msgstr "Видеообаждането не е налично"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:80
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:79
|
||||
msgid "Video codec"
|
||||
msgstr "Видеокодек"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:84
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:83
|
||||
msgid "Video codec for screen sharing"
|
||||
msgstr "Видеокодек за споделяне на екрана"
|
||||
|
||||
@@ -37912,7 +37887,7 @@ msgid "Volume mute"
|
||||
msgstr "Изключване на звука"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:40
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:39
|
||||
msgid "Volume sliders"
|
||||
msgstr "Плъзгачи за сила на звука"
|
||||
|
||||
@@ -38466,6 +38441,11 @@ msgstr "Какво да включиш"
|
||||
msgid "What to search for."
|
||||
msgstr "Какво да търсиш."
|
||||
|
||||
#. Informational overlay on your own screen-share tile while the shared screen or window sends no new frames, for example when it is static or minimised. Not an error.
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:180
|
||||
msgid "What you're sharing isn't updating"
|
||||
msgstr "Това, което споделяте, не се актуализира"
|
||||
|
||||
#. Placeholder question in the custom status text field.
|
||||
#. Placeholder question in the custom status text field.
|
||||
#: src/features/user/components/modals/CustomStatusBottomSheet.tsx:40
|
||||
@@ -39972,7 +39952,7 @@ msgid "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProduc
|
||||
msgstr "Вашата запазена настройка {savedResolution} при {savedFrameRate} FPS изисква {premiumProductName}, така че споделянията използват {resolution} при {frameRate} FPS."
|
||||
|
||||
#. Body of a modal shown when the video codec the browser negotiated for an active screen share is outside the codecs the app allows and no allowed codec could be published, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:130
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:131
|
||||
msgctxt "screen-share"
|
||||
msgid "Your screen share could not be published with a compatible video codec, so it was stopped. Try sharing again."
|
||||
msgstr "Споделянето на екрана ти не можа да бъде публикувано със съвместим видео кодек, затова беше спряно. Опитай да споделиш отново."
|
||||
@@ -40071,7 +40051,7 @@ msgid "Your vanity URL has been set to {inviteEndpoint}/{vanitySlug}"
|
||||
msgstr "Персонализираният ти URL адрес е зададен на {inviteEndpoint}/{vanitySlug}"
|
||||
|
||||
#. Body of a modal shown when the local video encoder produces no frames for an active screen share, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:137
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:138
|
||||
msgctxt "screen-share"
|
||||
msgid "Your video encoder stopped encoding frames, so your screen share was stopped. Try sharing again."
|
||||
msgstr "Видеоенкодерът ти спря да кодира кадри, затова споделянето на екрана беше спряно. Опитай да споделиш отново."
|
||||
|
||||
@@ -1070,7 +1070,7 @@ msgstr "{count, plural, one {Rozbalit (# řádek)} few {Rozbalit (# řádky)} ma
|
||||
msgid "{count, plural, one {Expand (# row)} other {Expand (# rows)}}"
|
||||
msgstr "{count, plural, one {Rozbalit (# řádek)} few {Rozbalit (# řádky)} many {Rozbalit (# řádku)} other {Rozbalit (# řádků)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:315
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:320
|
||||
msgid "{count, plural, one {Expand # other device} other {Expand # other devices}}"
|
||||
msgstr "{count, plural, one {Rozbalit # další zařízení} few {Rozbalit # další zařízení} many {Rozbalit # dalšího zařízení} other {Rozbalit # dalších zařízení}}"
|
||||
|
||||
@@ -1091,7 +1091,7 @@ msgstr "{count, plural, one {V hovoru} other {# v hovoru}}"
|
||||
msgid "{count, plural, one {Join video call (# participant)} other {Join video call (# participants)}}"
|
||||
msgstr "{count, plural, one {Připojit se k videohovoru (# účastník)} few {Připojit se k videohovoru (# účastníci)} many {Připojit se k videohovoru (# účastníka)} other {Připojit se k videohovoru (# účastníků)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:671
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:676
|
||||
msgid "{count, plural, one {Keep watching # stream and add this one} other {Keep watching # streams and add this one}}"
|
||||
msgstr "{count, plural, one {Dál sledovat # stream a přidat tento} few {Dál sledovat # streamy a přidat tento} many {Dál sledovat # streamu a přidat tento} other {Dál sledovat # streamů a přidat tento}}"
|
||||
|
||||
@@ -1347,7 +1347,7 @@ msgstr "{fps} FPS"
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:53
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:114
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:115
|
||||
msgid "{frameRate} FPS"
|
||||
msgstr "{frameRate} snímků/s"
|
||||
|
||||
@@ -3590,8 +3590,8 @@ msgstr "Povolit všem, kdo si mohou zobrazit váš celý profil, zobrazit také
|
||||
|
||||
#. Settings search entry label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#. Switch label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:143
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:88
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:136
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:87
|
||||
msgid "Allow AV1 for screen sharing"
|
||||
msgstr "Povolit AV1 pro sdílení obrazovky"
|
||||
|
||||
@@ -3649,8 +3649,8 @@ msgstr "Povolit pro všechny komunity"
|
||||
|
||||
#. Settings search entry label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#. Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:147
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:92
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:140
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:91
|
||||
msgid "Allow H.265 (HEVC) for screen sharing"
|
||||
msgstr "Povolit H.265 (HEVC) pro sdílení obrazovky"
|
||||
|
||||
@@ -4681,12 +4681,12 @@ msgid "Audio boost"
|
||||
msgstr "Zvýšení hlasitosti zvuku"
|
||||
|
||||
#. Section header in the stream settings menu. Selects the audio capture device for the share.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:136
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:137
|
||||
msgid "Audio device"
|
||||
msgstr "Zvukové zařízení"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:16
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:15
|
||||
msgid "Audio device alerts"
|
||||
msgstr "Upozornění na zvuková zařízení"
|
||||
|
||||
@@ -4776,7 +4776,7 @@ msgid "Audio send:"
|
||||
msgstr "Odesílání zvuku:"
|
||||
|
||||
#. Section header in the stream settings menu grouping audio-related toggles.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:148
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:149
|
||||
msgid "Audio settings"
|
||||
msgstr "Nastavení zvuku"
|
||||
|
||||
@@ -4987,9 +4987,9 @@ msgstr "Návrh automatického doplňování: {label}, {selectedOptionPosition} z
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#. Voice region picker option label meaning 'let Fluxer pick the best region automatically'.
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx:19
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:63
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:83
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:104
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:56
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:76
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:97
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts:264
|
||||
#: src/features/voice/components/VoiceRegionSelector.tsx:16
|
||||
#: src/features/voice/components/VoiceSettingsMenus.tsx:93
|
||||
@@ -4997,13 +4997,13 @@ msgid "Automatic"
|
||||
msgstr "Automaticky"
|
||||
|
||||
#. Option label for the screen-share codec select. codec is the automatically selected codec label.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:129
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:122
|
||||
msgid "Automatic ({codec})"
|
||||
msgstr "Automaticky ({codec})"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Voice setting label for automatically evening out microphone volume.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:48
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:47
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:215
|
||||
msgid "Automatic gain control"
|
||||
msgstr "Automatické řízení zesílení"
|
||||
@@ -5094,12 +5094,12 @@ msgid "Autoscrolling"
|
||||
msgstr "Automatické posouvání"
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:101
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:100
|
||||
msgid "AV1"
|
||||
msgstr "AV1"
|
||||
|
||||
#. Option label for the screen-share codec select while the AV1 opt-in is off and the option is disabled. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:133
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:126
|
||||
msgid "AV1 (requires opt-in)"
|
||||
msgstr "AV1 (vyžaduje výslovné zapnutí)"
|
||||
|
||||
@@ -5558,7 +5558,7 @@ msgid "Biometrics"
|
||||
msgstr "Biometrické ověřování"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:169
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:152
|
||||
msgid "Bitrate"
|
||||
msgstr "Přenosová rychlost"
|
||||
|
||||
@@ -5953,7 +5953,7 @@ msgid "Browser locale: {format}"
|
||||
msgstr "Čas podle nastavení prohlížeče: {format}"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:64
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:63
|
||||
msgid "Browser mic gain for custom processing"
|
||||
msgstr "Zesílení mikrofonu v prohlížeči pro vlastní zpracování"
|
||||
|
||||
@@ -6483,15 +6483,15 @@ msgstr "Ověření CAPTCHA se nezdařilo. Zkuste to znovu."
|
||||
msgid "Capture"
|
||||
msgstr "Zachytávání"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:810
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
msgid "Capture app audio"
|
||||
msgstr "Zachytávat zvuk aplikace"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:814
|
||||
msgid "Capture desktop audio"
|
||||
msgstr "Zachytávat zvuk z počítače"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:809
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:811
|
||||
msgid "Capture device audio"
|
||||
msgstr "Zachytávat zvuk zařízení"
|
||||
|
||||
@@ -6501,7 +6501,7 @@ msgid "Capture devices use the Gaming preset."
|
||||
msgstr "Zařízení pro snímání používají předvolbu Hraní her."
|
||||
|
||||
#. Toggle label in the stream settings menu for a window share whose audio scope the user widened from the shared window to the whole system mix.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:160
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:161
|
||||
msgid "Capture entire system audio"
|
||||
msgstr "Zachytávat zvuk celého systému"
|
||||
|
||||
@@ -7637,11 +7637,6 @@ msgstr "Vyberte jazyk"
|
||||
msgid "Choose your profile accent color"
|
||||
msgstr "Vyberte barvu zvýraznění profilu"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:117
|
||||
msgid "Cisco"
|
||||
msgstr "Cisco"
|
||||
|
||||
#. Generic account action label that opens or submits the flow to secure an unclaimed account.
|
||||
#: src/features/i18n/utils/CommonMessageDescriptors.ts:152
|
||||
msgid "Claim account"
|
||||
@@ -8240,7 +8235,7 @@ msgid "Code:"
|
||||
msgstr "Kód:"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:76
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:75
|
||||
msgid "Codec"
|
||||
msgstr "Kodek"
|
||||
|
||||
@@ -8766,7 +8761,7 @@ msgstr "Fokus v poli pro psaní zpráv"
|
||||
#. Button label that opens a dedicated advanced settings modal for configuring screen-share encoder controls.
|
||||
#. Button label that opens a dedicated advanced settings modal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:164
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:120
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:113
|
||||
msgid "Configure"
|
||||
msgstr "Nastavit"
|
||||
|
||||
@@ -9043,7 +9038,7 @@ msgid "Connection volume"
|
||||
msgstr "Hlasitost připojení"
|
||||
|
||||
#. Settings search entry label. Names an advanced voice setting in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:28
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:27
|
||||
msgid "Connection volume controls"
|
||||
msgstr "Ovládací prvky hlasitosti připojení"
|
||||
|
||||
@@ -9114,7 +9109,7 @@ msgid "Content filtering"
|
||||
msgstr "Filtrování obsahu"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to the WebRTC MediaStreamTrack contentHint value.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:95
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:88
|
||||
msgid "Content hint"
|
||||
msgstr "Typ obsahu pro kódování"
|
||||
|
||||
@@ -10594,7 +10589,7 @@ msgstr "Aktuálně: {mutedText}"
|
||||
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:94
|
||||
#: src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx:45
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:238
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:102
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:103
|
||||
msgid "Custom"
|
||||
msgstr "Vlastní"
|
||||
|
||||
@@ -10789,7 +10784,7 @@ msgid "Custom video backgrounds"
|
||||
msgstr "Vlastní pozadí videa"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:60
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:59
|
||||
msgid "Custom voice processing"
|
||||
msgstr "Vlastní zpracování zvuku"
|
||||
|
||||
@@ -11810,7 +11805,7 @@ msgid "Detached banner"
|
||||
msgstr "Odpojený banner"
|
||||
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:112
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:105
|
||||
msgid "Detail"
|
||||
msgstr "Detail"
|
||||
|
||||
@@ -11880,7 +11875,7 @@ msgid "Device"
|
||||
msgstr "Zařízení"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:12
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:11
|
||||
msgid "Device alerts"
|
||||
msgstr "Upozornění na zařízení"
|
||||
|
||||
@@ -11890,7 +11885,7 @@ msgid "Device audio ({deviceLabel})"
|
||||
msgstr "Zvuk zařízení ({deviceLabel})"
|
||||
|
||||
#. Screen-share audio summary on a video device share that publishes only the audio of the capture device itself.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:866
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:868
|
||||
#: src/features/voice/utils/ScreenShareAudioSummary.ts:50
|
||||
msgid "Device audio only"
|
||||
msgstr "Zvuk zařízení"
|
||||
@@ -11904,12 +11899,12 @@ msgstr "Podrobnosti o zařízení"
|
||||
msgid "Device revoked"
|
||||
msgstr "Zařízení odhlášeno"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1004
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1006
|
||||
msgid "Device selection"
|
||||
msgstr "Výběr zařízení"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:36
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:35
|
||||
msgid "Device volume"
|
||||
msgstr "Hlasitost zařízení"
|
||||
|
||||
@@ -11933,7 +11928,7 @@ msgid "Devices"
|
||||
msgstr "Zařízení"
|
||||
|
||||
#. Description for the Custom streaming preset. Lightly playful prose; keep tone casual.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:106
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:107
|
||||
msgid "Dial in your own numbers"
|
||||
msgstr "Nastavte si vlastní hodnoty"
|
||||
|
||||
@@ -12109,7 +12104,7 @@ msgid "Disable mobile push notifications"
|
||||
msgstr "Vypnout push oznámení na mobilu"
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:51
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:44
|
||||
msgid "Disable picture-in-picture popout for screen shares"
|
||||
msgstr "Vypnout samostatná okna obrazu v obraze pro sdílení obrazovky"
|
||||
|
||||
@@ -12629,7 +12624,7 @@ msgstr "Už nenavrhovat zařízení {deviceName}"
|
||||
#: src/features/moderation/components/report/ReportBreadcrumbs.tsx:31
|
||||
#: src/features/search/components/search/ChannelFilterSheet.tsx:165
|
||||
#: src/features/search/components/search/HasFilterSheet.tsx:206
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:175
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:186
|
||||
msgid "Done"
|
||||
msgstr "Hotovo"
|
||||
|
||||
@@ -13756,8 +13751,8 @@ msgstr "Zapnuto"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to hardware or software video encoding.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:59
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:153
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:136
|
||||
msgid "Encoder path"
|
||||
msgstr "Způsob kódování"
|
||||
|
||||
@@ -13767,7 +13762,7 @@ msgid "Encoder preference for new screen shares."
|
||||
msgstr "Preferovaný enkodér pro nová sdílení obrazovky."
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:173
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:156
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "Kodér, SVC a předvolby přenosové rychlosti"
|
||||
|
||||
@@ -15616,7 +15611,7 @@ msgstr "Rámeček"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:341
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:132
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:133
|
||||
msgid "Frame rate"
|
||||
msgstr "Snímková frekvence"
|
||||
|
||||
@@ -15840,7 +15835,7 @@ msgid "Fully quit and restart {productName} so macOS applies this permission."
|
||||
msgstr "Úplně ukončete a restartujte {productName}, aby systém macOS použil toto oprávnění."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:51
|
||||
msgid "Gain control"
|
||||
msgstr "Řízení zesílení"
|
||||
|
||||
@@ -15946,7 +15941,7 @@ msgstr "Gamepad Y / Trojúhelník"
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:76
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:24
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:75
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:76
|
||||
msgid "Gaming"
|
||||
msgstr "Hraní"
|
||||
|
||||
@@ -16451,7 +16446,7 @@ msgstr "Udělena oprávnění {permissions}"
|
||||
msgid "Grants all permissions and bypasses channel restrictions. Highly sensitive."
|
||||
msgstr "Uděluje všechna oprávnění a obchází omezení kanálů. Jde o vysoce citlivé oprávnění."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:991
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:993
|
||||
msgid "Granular selection"
|
||||
msgstr "Podrobný výběr"
|
||||
|
||||
@@ -16629,13 +16624,8 @@ msgstr "Skupina aktualizována"
|
||||
msgid "Grouping"
|
||||
msgstr "Seskupování"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:113
|
||||
msgid "H.264"
|
||||
msgstr "H.264"
|
||||
|
||||
#. Option label for the screen-share codec select while the HEVC opt-in is off and the option is disabled. H.265 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:138
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:131
|
||||
msgid "H.265 (requires opt-in)"
|
||||
msgstr "H.265 (vyžaduje výslovné zapnutí)"
|
||||
|
||||
@@ -16691,7 +16681,7 @@ msgid "Hardware acceleration can cause rendering glitches with transparent windo
|
||||
msgstr "Hardwarová akcelerace může způsobovat chyby vykreslování s průhlednými okny. Pokud vidíte blikání nebo rozmazání, zkuste --disable-gpu."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:161
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:144
|
||||
msgid "Hardware encoder"
|
||||
msgstr "Hardwarový enkodér"
|
||||
|
||||
@@ -16832,7 +16822,7 @@ msgid "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMA
|
||||
msgstr "Pomozte přeložit {PRODUCT_NAME} do vašeho jazyka na <0>{I18N_WEBLATE_DOMAIN}</0>."
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:105
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:104
|
||||
msgid "HEVC"
|
||||
msgstr "HEVC"
|
||||
|
||||
@@ -16995,7 +16985,7 @@ msgid "Hide muted channels by default?"
|
||||
msgstr "Skrýt ztlumené kanály ve výchozím nastavení?"
|
||||
|
||||
#. Short label for an advanced stream privacy preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:55
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:48
|
||||
msgid "Hide my stream preview thumbnail"
|
||||
msgstr "Skrýt náhled mého streamu"
|
||||
|
||||
@@ -17014,7 +17004,7 @@ msgstr "Skrýt heslo"
|
||||
msgid "Hide picture-in-picture popout?"
|
||||
msgstr "Skrýt obraz v obraze?"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:768
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:770
|
||||
msgid "Hide preview thumbnail"
|
||||
msgstr "Skrýt miniaturu náhledu"
|
||||
|
||||
@@ -17417,11 +17407,11 @@ msgstr "Ignorovat"
|
||||
msgid "Ignore friend request"
|
||||
msgstr "Ignorovat žádost o přátelství"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:981
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:983
|
||||
msgid "Ignore hardware devices"
|
||||
msgstr "Ignorovat hardwarová zařízení"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:957
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:959
|
||||
msgid "Ignore input media"
|
||||
msgstr "Ignorovat vstupní média"
|
||||
|
||||
@@ -17452,7 +17442,7 @@ msgstr "Ignorovat žádost o přátelství od uživatele {displayName}?"
|
||||
msgid "Ignore this channel's slowmode cooldown."
|
||||
msgstr "Ignorovat čekací dobu pomalého režimu v tomto kanálu."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:968
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:970
|
||||
msgid "Ignore virtual"
|
||||
msgstr "Ignorovat virtuální"
|
||||
|
||||
@@ -19067,7 +19057,7 @@ msgid "Layering"
|
||||
msgstr "Vrstvení"
|
||||
|
||||
#. Description for an SVC mode select. Keep SVC and AV1 literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:79
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:72
|
||||
msgid "Layering for AV1 and VP9 when available."
|
||||
msgstr "Vrstvení pro AV1 a VP9, pokud jsou k dispozici."
|
||||
|
||||
@@ -20228,7 +20218,7 @@ msgid "Maximum protection for family-friendly spaces"
|
||||
msgstr "Maximální ochrana pro prostory vhodné pro rodiny"
|
||||
|
||||
#. Settings search entry description for the AV1 and H.265 screen-share opt-in toggles.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:97
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:96
|
||||
msgid "May cause compatibility issues for viewers. We’re working on improving this."
|
||||
msgstr "Může způsobit problémy s kompatibilitou u diváků. Pracujeme na jejím vylepšení."
|
||||
|
||||
@@ -20957,7 +20947,7 @@ msgid "Microphone access was denied. Enable it in your system settings and try a
|
||||
msgstr "Přístup k mikrofonu byl odepřen. Povolte jej v nastavení systému a zkuste to znovu."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:56
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:55
|
||||
msgid "Microphone gain"
|
||||
msgstr "Zesílení mikrofonu"
|
||||
|
||||
@@ -21318,7 +21308,7 @@ msgstr "Nejrelevantnější"
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:102
|
||||
#: src/features/user/components/modals/tabs/accessibility_tab/AccessibilityTabInline.tsx:31
|
||||
#: src/features/user/components/modals/tabs/AccessibilityTab.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:108
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:101
|
||||
#: src/features/user/components/settings_utils/search_index/AccessibilityIndex.ts:297
|
||||
#: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:271
|
||||
msgid "Motion"
|
||||
@@ -22142,7 +22132,7 @@ msgid "New Mexico"
|
||||
msgstr "Nové Mexiko"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:20
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:19
|
||||
msgid "New microphone"
|
||||
msgstr "Nový mikrofon"
|
||||
|
||||
@@ -22830,11 +22820,11 @@ msgstr "Uživatel s tímto uživatelským jménem nebyl nalezen."
|
||||
msgid "No users are currently banned from this community."
|
||||
msgstr "V této komunitě momentálně nejsou žádní zabanovaní uživatelé."
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users available"
|
||||
msgstr "Žádní uživatelé k dispozici"
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users found"
|
||||
msgstr "Žádní uživatelé nenalezeni"
|
||||
|
||||
@@ -23313,7 +23303,7 @@ msgstr "Platí pouze na dostatečně širokých obrazovkách."
|
||||
msgid "Only credit cards are accepted. Debit and prepaid cards cannot be used for the mature content check."
|
||||
msgstr "Přijímány jsou pouze kreditní karty. Debetní a předplacené karty nelze použít k ověření věku pro obsah pro dospělé."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:946
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:948
|
||||
msgid "Only default speakers"
|
||||
msgstr "Pouze výchozí reproduktory"
|
||||
|
||||
@@ -23361,7 +23351,7 @@ msgstr "Spouštějte zde kód, pouze pokud přesně rozumíte tomu, co dělá."
|
||||
msgid "Only show message previews for DMs with unread messages"
|
||||
msgstr "Zobrazovat náhledy zpráv pouze u soukromých konverzací s nepřečtenými zprávami"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:936
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:938
|
||||
msgid "Only speakers"
|
||||
msgstr "Pouze reproduktory"
|
||||
|
||||
@@ -23901,21 +23891,6 @@ msgstr "Otevřete v telefonu aplikaci pro zprávy a začněte psát novou SMS."
|
||||
msgid "Open your settings"
|
||||
msgstr "Otevřít nastavení"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:121
|
||||
msgid "OpenH264 software codec on Linux"
|
||||
msgstr "Softwarový kodek OpenH264 pro Linux"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:109
|
||||
msgid "OpenH264 video codec"
|
||||
msgstr "Videokodek OpenH264"
|
||||
|
||||
#. Switch label for the OpenH264 codec toggle. "OpenH264" is a product name and "Cisco Systems, Inc." is a company name; do not translate either.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:42
|
||||
msgid "OpenH264 Video Codec provided by Cisco Systems, Inc."
|
||||
msgstr "Videokodek OpenH264 poskytovaný společností Cisco Systems, Inc."
|
||||
|
||||
#. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening.
|
||||
#: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66
|
||||
msgid "Opens the Members page. Press Space or Enter to open."
|
||||
@@ -24411,7 +24386,7 @@ msgid "Pause invites? New users won't be able to join through invite links until
|
||||
msgstr "Pozastavit pozvánky? Noví uživatelé se nebudou moct připojit přes odkazy na pozvánky, dokud je znovu nepovolíte. Stávající členové nebudou ovlivněni."
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:47
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:40
|
||||
msgid "Pause my screen share preview in the background"
|
||||
msgstr "Pozastavit náhled sdílené obrazovky na pozadí"
|
||||
|
||||
@@ -24528,7 +24503,7 @@ msgid "Per-community profiles"
|
||||
msgstr "Profily pro jednotlivé komunity"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:32
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:31
|
||||
msgid "Per-device volume"
|
||||
msgstr "Hlasitost pro jednotlivá zařízení"
|
||||
|
||||
@@ -24776,7 +24751,7 @@ msgid "Picture"
|
||||
msgstr "Obrázek"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:133
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:116
|
||||
msgid "Picture-in-picture"
|
||||
msgstr "Obraz v obraze"
|
||||
|
||||
@@ -24906,7 +24881,7 @@ msgid "Pins popout"
|
||||
msgstr "Samostatné okno připnutých zpráv"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:137
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:120
|
||||
msgid "PiP"
|
||||
msgstr "Obraz v obraze"
|
||||
|
||||
@@ -25108,7 +25083,7 @@ msgid "Prefer @mention"
|
||||
msgstr "Preferovat odpovědi s @zmínkou"
|
||||
|
||||
#. Option label for an encoder path select. Refers to hardware video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:67
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:60
|
||||
msgid "Prefer hardware"
|
||||
msgstr "Preferovat hardware"
|
||||
|
||||
@@ -25120,7 +25095,7 @@ msgid "Prefer no @mention"
|
||||
msgstr "Preferovat odpovědi bez @zmínky"
|
||||
|
||||
#. Option label for an encoder path select. Refers to software video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:71
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:64
|
||||
msgid "Prefer software"
|
||||
msgstr "Preferovat software"
|
||||
|
||||
@@ -25140,7 +25115,7 @@ msgid "Preferences..."
|
||||
msgstr "Předvolby..."
|
||||
|
||||
#. Accessible label for the advanced screen-share codec preference select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:125
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:118
|
||||
msgid "Preferred screen share codec"
|
||||
msgstr "Preferovaný kodek pro sdílení obrazovky"
|
||||
|
||||
@@ -25314,7 +25289,7 @@ msgid "Preview sound"
|
||||
msgstr "Přehrát zvuk"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:145
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:128
|
||||
msgid "Preview, popout, and stream thumbnail behavior"
|
||||
msgstr "Chování náhledů, samostatných oken a miniatur streamů"
|
||||
|
||||
@@ -25644,7 +25619,7 @@ msgid "Prompt"
|
||||
msgstr "Výzva"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:24
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:23
|
||||
msgid "Prompt for new audio devices"
|
||||
msgstr "Nabídnout přepnutí při připojení nového zvukového zařízení"
|
||||
|
||||
@@ -27659,7 +27634,7 @@ msgstr "Změna velikosti, maximalizace a přichytávání oken závisí na kompo
|
||||
#. Section header in the stream settings menu for resolution options.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:206
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:128
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:129
|
||||
msgid "Resolution"
|
||||
msgstr "Rozlišení"
|
||||
|
||||
@@ -28352,7 +28327,7 @@ msgstr "Je potřeba povolit nahrávání obrazovky"
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:28
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:222
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:234
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:88
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:89
|
||||
msgid "Screen share"
|
||||
msgstr "Sdílení obrazovky"
|
||||
|
||||
@@ -28372,17 +28347,17 @@ msgid "Screen share audio couldn't start"
|
||||
msgstr "Zvuk sdílení obrazovky se nespustil"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:68
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:67
|
||||
msgid "Screen share codec"
|
||||
msgstr "Kodek sdílení obrazovky"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:149
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:132
|
||||
msgid "Screen share encoder controls"
|
||||
msgstr "Ovládací prvky enkodéru pro sdílení obrazovky"
|
||||
|
||||
#. Title of a modal shown when an active screen share stops unexpectedly.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:119
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:120
|
||||
msgctxt "screen-share"
|
||||
msgid "Screen share ended"
|
||||
msgstr "Sdílení obrazovky ukončeno"
|
||||
@@ -28398,12 +28373,12 @@ msgid "Screen share is using software encoding ({codec}). Quality may be reduced
|
||||
msgstr "Sdílení obrazovky používá softwarové kódování ({codec}). Kvalita může být snížena a využití CPU zvýšeno. Vaše GPU nemusí podporovat hardwarové kódování pro tento kodek."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:129
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:112
|
||||
msgid "Screen share preview"
|
||||
msgstr "Náhled sdílení obrazovky"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:125
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:108
|
||||
msgid "Screen share preview behavior"
|
||||
msgstr "Chování náhledu sdílení obrazovky"
|
||||
|
||||
@@ -28464,7 +28439,7 @@ msgid "Screen sharing"
|
||||
msgstr "Sdílení obrazovky"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:72
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:71
|
||||
msgid "Screen sharing codec"
|
||||
msgstr "Kodek sdílení obrazovky"
|
||||
|
||||
@@ -29829,7 +29804,7 @@ msgid "Share screens and windows."
|
||||
msgstr "Sdílet obrazovky a okna."
|
||||
|
||||
#. Toggle label for sharing audio with an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:156
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:157
|
||||
msgid "Share stream audio"
|
||||
msgstr "Sdílet zvuk streamu"
|
||||
|
||||
@@ -29909,12 +29884,12 @@ msgid "Sharing personal information"
|
||||
msgstr "Sdílení osobních údajů"
|
||||
|
||||
#. Description for the free-tier Screen share streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:97
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:98
|
||||
msgid "Sharp text at 720p, up to 30 FPS"
|
||||
msgstr "Ostrý text v rozlišení 720p, až 30 snímků za sekundu"
|
||||
|
||||
#. Description for the high-tier Screen share streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:92
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:93
|
||||
msgid "Sharp text at up to 4K, up to 15 FPS"
|
||||
msgstr "Ostrý text až do 4K, až 15 snímků za sekundu"
|
||||
|
||||
@@ -30327,7 +30302,7 @@ msgstr "Zobrazit Neko"
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Short label for an advanced voice device preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVoiceControls.tsx:11
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:8
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:7
|
||||
msgid "Show new device alerts"
|
||||
msgstr "Zobrazovat upozornění na nová zařízení"
|
||||
|
||||
@@ -30364,7 +30339,7 @@ msgid "Show password"
|
||||
msgstr "Zobrazit heslo"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:44
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:43
|
||||
msgid "Show per-device participant volume sliders in voice menus"
|
||||
msgstr "Zobrazit posuvníky hlasitosti účastníků pro každé zařízení v nabídkách hlasu"
|
||||
|
||||
@@ -30662,7 +30637,7 @@ msgstr "Tiché hovory"
|
||||
msgid "Silent calls from everyone"
|
||||
msgstr "Tiché hovory od všech"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:872
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:874
|
||||
msgid "Silent when the capture device has no audio input of its own"
|
||||
msgstr "Tiché, když zachytávací zařízení nemá vlastní zvukový vstup"
|
||||
|
||||
@@ -30689,7 +30664,7 @@ msgid "Single community mode"
|
||||
msgstr "Režim jedné komunity"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:87
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:80
|
||||
msgid "Single layer"
|
||||
msgstr "Jedna vrstva"
|
||||
|
||||
@@ -30975,12 +30950,12 @@ msgid "Smileys"
|
||||
msgstr "Emotikony"
|
||||
|
||||
#. Description for the free-tier Gaming streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:84
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:85
|
||||
msgid "Smooth 30 FPS at up to 720p"
|
||||
msgstr "Plynulé 30 FPS při rozlišení až 720p"
|
||||
|
||||
#. Description for the high-tier Gaming streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:79
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:80
|
||||
msgid "Smooth 60 FPS at up to 1440p"
|
||||
msgstr "Plynulé 60 FPS až do rozlišení 1440p"
|
||||
|
||||
@@ -31028,7 +31003,7 @@ msgid "Social"
|
||||
msgstr "Sociální sítě"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:157
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:140
|
||||
msgid "Software encoder"
|
||||
msgstr "Softwarový enkodér"
|
||||
|
||||
@@ -31258,7 +31233,7 @@ msgstr "Zvuky"
|
||||
#. Short label in the video tab for the native source resolution option. Keep it concise.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:83
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:250
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:110
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:111
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:336
|
||||
msgid "Source"
|
||||
msgstr "Zdroj"
|
||||
@@ -32047,13 +32022,13 @@ msgstr "Stream skryt"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Short label in the user settings voice activity card. Keep it concise.
|
||||
#: src/features/user/components/profile/VoiceActivityCard.tsx:90
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:141
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:124
|
||||
#: src/features/voice/components/StreamWatchHoverCard.tsx:21
|
||||
msgid "Stream preview"
|
||||
msgstr "Náhled streamu"
|
||||
|
||||
#. Compact submenu label for changing the resolution and frame rate of an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:152
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:153
|
||||
msgid "Stream quality"
|
||||
msgstr "Kvalita streamu"
|
||||
|
||||
@@ -32079,7 +32054,7 @@ msgid "Streaming"
|
||||
msgstr "Streamování"
|
||||
|
||||
#. Section header in the stream settings menu. Picks between Gaming / Screen share / Custom presets.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:124
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:125
|
||||
msgid "Streaming mode"
|
||||
msgstr "Režim streamování"
|
||||
|
||||
@@ -32280,12 +32255,12 @@ msgid "Suspend"
|
||||
msgstr "Pozastavit"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:165
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:148
|
||||
msgid "SVC"
|
||||
msgstr "SVC"
|
||||
|
||||
#. Label for an advanced screen-share select. SVC is a WebRTC acronym and should stay uppercase.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:75
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:68
|
||||
msgid "SVC mode"
|
||||
msgstr "Režim SVC"
|
||||
|
||||
@@ -32559,7 +32534,7 @@ msgstr "Systémová oznámení od zaměstnanců {productName}. Zde nelze odpoví
|
||||
#. Audio device option label meaning the OS default audio input device.
|
||||
#. Footer summary fallback for the default capture-device audio input.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:263
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:140
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:141
|
||||
msgid "System default"
|
||||
msgstr "Výchozí systémové"
|
||||
|
||||
@@ -32760,7 +32735,7 @@ msgid "Template URL"
|
||||
msgstr "Adresa URL šablony"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:91
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:84
|
||||
msgid "Temporal layers"
|
||||
msgstr "Časové vrstvy"
|
||||
|
||||
@@ -32837,7 +32812,7 @@ msgstr "Texas"
|
||||
#: src/features/channel/utils/ChannelUtils.tsx:51
|
||||
#: src/features/lexical/composer/nodes/SlashSlotNode.ts:23
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:50
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:116
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:109
|
||||
msgid "Text"
|
||||
msgstr "Text"
|
||||
|
||||
@@ -33247,7 +33222,7 @@ msgid "The role granted {permissions}"
|
||||
msgstr "Role udělila {permissions}"
|
||||
|
||||
#. Body of a modal shown when a browser screen share track ends outside the app, for example from the browser sharing controls.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:124
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:125
|
||||
msgctxt "screen-share"
|
||||
msgid "The screen share source stopped sending video, so your screen share was stopped."
|
||||
msgstr "Zdroj sdílení obrazovky přestal odesílat video, takže bylo ukončeno sdílení obrazovky."
|
||||
@@ -35774,7 +35749,7 @@ msgstr "Nepojmenovaná skupina"
|
||||
#. Fallback label for an audio input device whose name is not reported by the OS.
|
||||
#. Footer summary fallback for an audio input without a reported name.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:267
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:144
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:145
|
||||
msgid "Unnamed input"
|
||||
msgstr "Nepojmenovaný vstup"
|
||||
|
||||
@@ -36662,7 +36637,7 @@ msgid "Used in communities that do not have their own sound."
|
||||
msgstr "Používá se v komunitách, které nemají vlastní zvuk."
|
||||
|
||||
#. Description for a WebRTC content hint select. Custom, Gaming and Screen share are the stream quality preset labels.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:92
|
||||
msgid "Used in Custom mode. The Gaming and Screen share presets set their own hint."
|
||||
msgstr "Používá se v režimu Vlastní. Předvolby Hraní her a Sdílení obrazovky nastavují vlastní nápovědu."
|
||||
|
||||
@@ -37236,12 +37211,12 @@ msgid "Video call unavailable"
|
||||
msgstr "Videohovor není k dispozici"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:80
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:79
|
||||
msgid "Video codec"
|
||||
msgstr "Kodek videa"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:84
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:83
|
||||
msgid "Video codec for screen sharing"
|
||||
msgstr "Video kodek pro sdílení obrazovky"
|
||||
|
||||
@@ -37912,7 +37887,7 @@ msgid "Volume mute"
|
||||
msgstr "Ztlumit zvuk"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:40
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:39
|
||||
msgid "Volume sliders"
|
||||
msgstr "Posuvníky hlasitosti"
|
||||
|
||||
@@ -38466,6 +38441,11 @@ msgstr "Co zahrnout"
|
||||
msgid "What to search for."
|
||||
msgstr "Co hledat."
|
||||
|
||||
#. Informational overlay on your own screen-share tile while the shared screen or window sends no new frames, for example when it is static or minimised. Not an error.
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:180
|
||||
msgid "What you're sharing isn't updating"
|
||||
msgstr "Sdílený obsah se neaktualizuje"
|
||||
|
||||
#. Placeholder question in the custom status text field.
|
||||
#. Placeholder question in the custom status text field.
|
||||
#: src/features/user/components/modals/CustomStatusBottomSheet.tsx:40
|
||||
@@ -39972,7 +39952,7 @@ msgid "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProduc
|
||||
msgstr "Vaše uložené rozlišení {savedResolution} s frekvencí {savedFrameRate} snímků za sekundu vyžaduje {premiumProductName}, takže sdílení používá rozlišení {resolution} s frekvencí {frameRate} snímků za sekundu."
|
||||
|
||||
#. Body of a modal shown when the video codec the browser negotiated for an active screen share is outside the codecs the app allows and no allowed codec could be published, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:130
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:131
|
||||
msgctxt "screen-share"
|
||||
msgid "Your screen share could not be published with a compatible video codec, so it was stopped. Try sharing again."
|
||||
msgstr "Sdílení obrazovky se nepodařilo publikovat s kompatibilním video kodekem, proto bylo zastaveno. Zkuste sdílet znovu."
|
||||
@@ -40071,7 +40051,7 @@ msgid "Your vanity URL has been set to {inviteEndpoint}/{vanitySlug}"
|
||||
msgstr "Váš vlastní odkaz na pozvánku byl nastaven na {inviteEndpoint}/{vanitySlug}"
|
||||
|
||||
#. Body of a modal shown when the local video encoder produces no frames for an active screen share, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:137
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:138
|
||||
msgctxt "screen-share"
|
||||
msgid "Your video encoder stopped encoding frames, so your screen share was stopped. Try sharing again."
|
||||
msgstr "Enkodér videa přestal kódovat snímky, takže sdílení obrazovky bylo ukončeno. Zkuste sdílet znovu."
|
||||
|
||||
@@ -1070,7 +1070,7 @@ msgstr "{count, plural, one {Vis (# linje)} other {Vis (# linjer)}}"
|
||||
msgid "{count, plural, one {Expand (# row)} other {Expand (# rows)}}"
|
||||
msgstr "{count, plural, one {Vis (# række)} other {Vis (# rækker)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:315
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:320
|
||||
msgid "{count, plural, one {Expand # other device} other {Expand # other devices}}"
|
||||
msgstr "{count, plural, one {Vis # anden enhed} other {Vis # andre enheder}}"
|
||||
|
||||
@@ -1091,7 +1091,7 @@ msgstr "{count, plural, one {I opkald} other {# i opkald}}"
|
||||
msgid "{count, plural, one {Join video call (# participant)} other {Join video call (# participants)}}"
|
||||
msgstr "{count, plural, one {Deltag i videoopkald (# deltager)} other {Deltag i videoopkald (# deltagere)}}"
|
||||
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:671
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:676
|
||||
msgid "{count, plural, one {Keep watching # stream and add this one} other {Keep watching # streams and add this one}}"
|
||||
msgstr "{count, plural, one {Fortsæt med at se # stream, og tilføj denne} other {Fortsæt med at se # streams, og tilføj denne}}"
|
||||
|
||||
@@ -1347,7 +1347,7 @@ msgstr "{fps} FPS"
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#. Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:53
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:114
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:115
|
||||
msgid "{frameRate} FPS"
|
||||
msgstr "{frameRate} billeder/sek."
|
||||
|
||||
@@ -3590,8 +3590,8 @@ msgstr "Tillad alle, der kan se din fulde profil, at se din lokale tid på profi
|
||||
|
||||
#. Settings search entry label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#. Switch label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:143
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:88
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:136
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:87
|
||||
msgid "Allow AV1 for screen sharing"
|
||||
msgstr "Tillad AV1 til skærmdeling"
|
||||
|
||||
@@ -3649,8 +3649,8 @@ msgstr "Tillad for alle fællesskaber"
|
||||
|
||||
#. Settings search entry label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#. Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:147
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:92
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:140
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:91
|
||||
msgid "Allow H.265 (HEVC) for screen sharing"
|
||||
msgstr "Tillad H.265 (HEVC) til skærmdeling"
|
||||
|
||||
@@ -4681,12 +4681,12 @@ msgid "Audio boost"
|
||||
msgstr "Lydforstærkning"
|
||||
|
||||
#. Section header in the stream settings menu. Selects the audio capture device for the share.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:136
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:137
|
||||
msgid "Audio device"
|
||||
msgstr "Lydenhed"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:16
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:15
|
||||
msgid "Audio device alerts"
|
||||
msgstr "Notifikationer om lydenheder"
|
||||
|
||||
@@ -4776,7 +4776,7 @@ msgid "Audio send:"
|
||||
msgstr "Lydafsendelse:"
|
||||
|
||||
#. Section header in the stream settings menu grouping audio-related toggles.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:148
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:149
|
||||
msgid "Audio settings"
|
||||
msgstr "Lydindstillinger"
|
||||
|
||||
@@ -4987,9 +4987,9 @@ msgstr "Forslag til autoudfyldelse: {label}, {selectedOptionPosition} af {result
|
||||
#. Voice region option label in the voice settings menu meaning let Fluxer pick the best region automatically.
|
||||
#. Voice region picker option label meaning 'let Fluxer pick the best region automatically'.
|
||||
#: src/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect.tsx:19
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:63
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:83
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:104
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:56
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:76
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:97
|
||||
#: src/features/user/components/settings_utils/section_registry/NotificationsSections.ts:264
|
||||
#: src/features/voice/components/VoiceRegionSelector.tsx:16
|
||||
#: src/features/voice/components/VoiceSettingsMenus.tsx:93
|
||||
@@ -4997,13 +4997,13 @@ msgid "Automatic"
|
||||
msgstr "Automatisk"
|
||||
|
||||
#. Option label for the screen-share codec select. codec is the automatically selected codec label.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:129
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:122
|
||||
msgid "Automatic ({codec})"
|
||||
msgstr "Automatisk ({codec})"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Voice setting label for automatically evening out microphone volume.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:48
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:47
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:215
|
||||
msgid "Automatic gain control"
|
||||
msgstr "Automatisk forstærkningskontrol"
|
||||
@@ -5094,12 +5094,12 @@ msgid "Autoscrolling"
|
||||
msgstr "Automatisk rulning"
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:101
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:100
|
||||
msgid "AV1"
|
||||
msgstr "AV1"
|
||||
|
||||
#. Option label for the screen-share codec select while the AV1 opt-in is off and the option is disabled. AV1 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:133
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:126
|
||||
msgid "AV1 (requires opt-in)"
|
||||
msgstr "AV1 (kræver tilvalg)"
|
||||
|
||||
@@ -5558,7 +5558,7 @@ msgid "Biometrics"
|
||||
msgstr "Biometri"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:169
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:152
|
||||
msgid "Bitrate"
|
||||
msgstr "Bitrate"
|
||||
|
||||
@@ -5953,7 +5953,7 @@ msgid "Browser locale: {format}"
|
||||
msgstr "Tidsformat fra browseren: {format}"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:64
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:63
|
||||
msgid "Browser mic gain for custom processing"
|
||||
msgstr "Browserens mikrofonforstærkning ved tilpasset lydbehandling"
|
||||
|
||||
@@ -6483,15 +6483,15 @@ msgstr "Captcha-verificering mislykkedes. Prøv igen."
|
||||
msgid "Capture"
|
||||
msgstr "Optag"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:810
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
msgid "Capture app audio"
|
||||
msgstr "Optag app-lyd"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:812
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:814
|
||||
msgid "Capture desktop audio"
|
||||
msgstr "Optag computerens lyd"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:809
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:811
|
||||
msgid "Capture device audio"
|
||||
msgstr "Optag enhedslyd"
|
||||
|
||||
@@ -6501,7 +6501,7 @@ msgid "Capture devices use the Gaming preset."
|
||||
msgstr "Optagelsesenheder bruger Gaming-forudindstillingen."
|
||||
|
||||
#. Toggle label in the stream settings menu for a window share whose audio scope the user widened from the shared window to the whole system mix.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:160
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:161
|
||||
msgid "Capture entire system audio"
|
||||
msgstr "Optag al systemlyd"
|
||||
|
||||
@@ -7637,11 +7637,6 @@ msgstr "Vælg dit sprog"
|
||||
msgid "Choose your profile accent color"
|
||||
msgstr "Vælg din profils accentfarve"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:117
|
||||
msgid "Cisco"
|
||||
msgstr "Cisco"
|
||||
|
||||
#. Generic account action label that opens or submits the flow to secure an unclaimed account.
|
||||
#: src/features/i18n/utils/CommonMessageDescriptors.ts:152
|
||||
msgid "Claim account"
|
||||
@@ -8240,7 +8235,7 @@ msgid "Code:"
|
||||
msgstr "Kode:"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:76
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:75
|
||||
msgid "Codec"
|
||||
msgstr "Codec"
|
||||
|
||||
@@ -8766,7 +8761,7 @@ msgstr "Fokus på skrivefelt"
|
||||
#. Button label that opens a dedicated advanced settings modal for configuring screen-share encoder controls.
|
||||
#. Button label that opens a dedicated advanced settings modal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls.tsx:164
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:120
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:113
|
||||
msgid "Configure"
|
||||
msgstr "Konfigurer"
|
||||
|
||||
@@ -9043,7 +9038,7 @@ msgid "Connection volume"
|
||||
msgstr "Forbindelseslydstyrke"
|
||||
|
||||
#. Settings search entry label. Names an advanced voice setting in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:28
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:27
|
||||
msgid "Connection volume controls"
|
||||
msgstr "Lydstyrkekontroller for forbindelsen"
|
||||
|
||||
@@ -9114,7 +9109,7 @@ msgid "Content filtering"
|
||||
msgstr "Indholdsfiltrering"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to the WebRTC MediaStreamTrack contentHint value.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:95
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:88
|
||||
msgid "Content hint"
|
||||
msgstr "Indholdstype"
|
||||
|
||||
@@ -10594,7 +10589,7 @@ msgstr "Aktuelt: {mutedText}"
|
||||
#: src/features/user/components/settings_utils/section_registry/AppearanceSections.ts:94
|
||||
#: src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx:45
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:238
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:102
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:103
|
||||
msgid "Custom"
|
||||
msgstr "Brugerdefineret"
|
||||
|
||||
@@ -10789,7 +10784,7 @@ msgid "Custom video backgrounds"
|
||||
msgstr "Brugerdefinerede videobaggrunde"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:60
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:59
|
||||
msgid "Custom voice processing"
|
||||
msgstr "Brugerdefineret lydbehandling"
|
||||
|
||||
@@ -11810,7 +11805,7 @@ msgid "Detached banner"
|
||||
msgstr "Frakoblet banner"
|
||||
|
||||
#. Option label for a WebRTC content hint select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:112
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:105
|
||||
msgid "Detail"
|
||||
msgstr "Detalje"
|
||||
|
||||
@@ -11880,7 +11875,7 @@ msgid "Device"
|
||||
msgstr "Enhed"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:12
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:11
|
||||
msgid "Device alerts"
|
||||
msgstr "Notifikationer om enheder"
|
||||
|
||||
@@ -11890,7 +11885,7 @@ msgid "Device audio ({deviceLabel})"
|
||||
msgstr "Enhedslyd ({deviceLabel})"
|
||||
|
||||
#. Screen-share audio summary on a video device share that publishes only the audio of the capture device itself.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:866
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:868
|
||||
#: src/features/voice/utils/ScreenShareAudioSummary.ts:50
|
||||
msgid "Device audio only"
|
||||
msgstr "Kun lyden fra enheden"
|
||||
@@ -11904,12 +11899,12 @@ msgstr "Enhedsdetaljer"
|
||||
msgid "Device revoked"
|
||||
msgstr "Enhed logget ud"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1004
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:1006
|
||||
msgid "Device selection"
|
||||
msgstr "Valg af enhed"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:36
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:35
|
||||
msgid "Device volume"
|
||||
msgstr "Enhedens lydstyrke"
|
||||
|
||||
@@ -11933,7 +11928,7 @@ msgid "Devices"
|
||||
msgstr "Enheder"
|
||||
|
||||
#. Description for the Custom streaming preset. Lightly playful prose; keep tone casual.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:106
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:107
|
||||
msgid "Dial in your own numbers"
|
||||
msgstr "Indstil dine egne værdier"
|
||||
|
||||
@@ -12109,7 +12104,7 @@ msgid "Disable mobile push notifications"
|
||||
msgstr "Deaktiver push-notifikationer på mobilen"
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:51
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:44
|
||||
msgid "Disable picture-in-picture popout for screen shares"
|
||||
msgstr "Deaktiver separate billede-i-billede-vinduer til skærmdeling"
|
||||
|
||||
@@ -12629,7 +12624,7 @@ msgstr "Foreslå ikke {deviceName} igen"
|
||||
#: src/features/moderation/components/report/ReportBreadcrumbs.tsx:31
|
||||
#: src/features/search/components/search/ChannelFilterSheet.tsx:165
|
||||
#: src/features/search/components/search/HasFilterSheet.tsx:206
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:175
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:186
|
||||
msgid "Done"
|
||||
msgstr "Færdig"
|
||||
|
||||
@@ -13756,8 +13751,8 @@ msgstr "Aktiveret"
|
||||
|
||||
#. Label for an advanced screen-share select. Refers to hardware or software video encoding.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:59
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:153
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:136
|
||||
msgid "Encoder path"
|
||||
msgstr "Encodersti"
|
||||
|
||||
@@ -13767,7 +13762,7 @@ msgid "Encoder preference for new screen shares."
|
||||
msgstr "Encoderpræference for nye skærmdelinger."
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:173
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:156
|
||||
msgid "Encoder, SVC, and bitrate presets"
|
||||
msgstr "Encoder, SVC og bitrate-forudindstillinger"
|
||||
|
||||
@@ -15616,7 +15611,7 @@ msgstr "Ramme"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:341
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:132
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:133
|
||||
msgid "Frame rate"
|
||||
msgstr "Billedhastighed"
|
||||
|
||||
@@ -15840,7 +15835,7 @@ msgid "Fully quit and restart {productName} so macOS applies this permission."
|
||||
msgstr "Luk {productName} helt ned, og genstart den, så macOS kan anvende denne tilladelse."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:52
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:51
|
||||
msgid "Gain control"
|
||||
msgstr "Forstærkningskontrol"
|
||||
|
||||
@@ -15946,7 +15941,7 @@ msgstr "Controller Y / Trekant"
|
||||
#: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:76
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:24
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:230
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:75
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:76
|
||||
msgid "Gaming"
|
||||
msgstr "Gaming"
|
||||
|
||||
@@ -16451,7 +16446,7 @@ msgstr "Tildelte {permissions}"
|
||||
msgid "Grants all permissions and bypasses channel restrictions. Highly sensitive."
|
||||
msgstr "Giver alle tilladelser og omgår kanalbegrænsninger. Giver omfattende adgang."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:991
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:993
|
||||
msgid "Granular selection"
|
||||
msgstr "Detaljeret valg"
|
||||
|
||||
@@ -16629,13 +16624,8 @@ msgstr "Gruppe opdateret"
|
||||
msgid "Grouping"
|
||||
msgstr "Gruppering"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:113
|
||||
msgid "H.264"
|
||||
msgstr "H.264"
|
||||
|
||||
#. Option label for the screen-share codec select while the HEVC opt-in is off and the option is disabled. H.265 is a codec name and should stay literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:138
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:131
|
||||
msgid "H.265 (requires opt-in)"
|
||||
msgstr "H.265 (kræver tilvalg)"
|
||||
|
||||
@@ -16691,7 +16681,7 @@ msgid "Hardware acceleration can cause rendering glitches with transparent windo
|
||||
msgstr "Hardwareacceleration kan forårsage gengivelsesfejl med gennemsigtige vinduer. Prøv --disable-gpu, hvis du oplever flimmer eller udtværing."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:161
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:144
|
||||
msgid "Hardware encoder"
|
||||
msgstr "Hardware-encoder"
|
||||
|
||||
@@ -16832,7 +16822,7 @@ msgid "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMA
|
||||
msgstr "Hjælp med at oversætte {PRODUCT_NAME} til dit sprog på <0>{I18N_WEBLATE_DOMAIN}</0>."
|
||||
|
||||
#. Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:105
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:104
|
||||
msgid "HEVC"
|
||||
msgstr "HEVC"
|
||||
|
||||
@@ -16995,7 +16985,7 @@ msgid "Hide muted channels by default?"
|
||||
msgstr "Skjul som standard kanaler, der er sat på lydløs?"
|
||||
|
||||
#. Short label for an advanced stream privacy preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:55
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:48
|
||||
msgid "Hide my stream preview thumbnail"
|
||||
msgstr "Skjul miniaturebilledet af min stream"
|
||||
|
||||
@@ -17014,7 +17004,7 @@ msgstr "Skjul adgangskode"
|
||||
msgid "Hide picture-in-picture popout?"
|
||||
msgstr "Skjul billede-i-billede-vinduet?"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:768
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:770
|
||||
msgid "Hide preview thumbnail"
|
||||
msgstr "Skjul miniaturebilledet til forhåndsvisning"
|
||||
|
||||
@@ -17417,11 +17407,11 @@ msgstr "Ignorer"
|
||||
msgid "Ignore friend request"
|
||||
msgstr "Ignorer venneanmodning"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:981
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:983
|
||||
msgid "Ignore hardware devices"
|
||||
msgstr "Ignorer hardwareenheder"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:957
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:959
|
||||
msgid "Ignore input media"
|
||||
msgstr "Ignorer inputmedie"
|
||||
|
||||
@@ -17452,7 +17442,7 @@ msgstr "Ignorer venneanmodningen fra {displayName}?"
|
||||
msgid "Ignore this channel's slowmode cooldown."
|
||||
msgstr "Ignorer ventetiden i langsom tilstand i denne kanal."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:968
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:970
|
||||
msgid "Ignore virtual"
|
||||
msgstr "Ignorer virtuelle enheder"
|
||||
|
||||
@@ -19067,7 +19057,7 @@ msgid "Layering"
|
||||
msgstr "Lagdeling"
|
||||
|
||||
#. Description for an SVC mode select. Keep SVC and AV1 literal.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:79
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:72
|
||||
msgid "Layering for AV1 and VP9 when available."
|
||||
msgstr "Lagdeling for AV1 og VP9, når det er tilgængeligt."
|
||||
|
||||
@@ -20228,7 +20218,7 @@ msgid "Maximum protection for family-friendly spaces"
|
||||
msgstr "Maksimal beskyttelse af familievenlige fællesskaber"
|
||||
|
||||
#. Settings search entry description for the AV1 and H.265 screen-share opt-in toggles.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:97
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:96
|
||||
msgid "May cause compatibility issues for viewers. We’re working on improving this."
|
||||
msgstr "Kan forårsage kompatibilitetsproblemer for seere. Vi arbejder på at forbedre dette."
|
||||
|
||||
@@ -20957,7 +20947,7 @@ msgid "Microphone access was denied. Enable it in your system settings and try a
|
||||
msgstr "Mikrofonadgang blev nægtet. Aktivér den i dine systemindstillinger, og prøv igen."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:56
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:55
|
||||
msgid "Microphone gain"
|
||||
msgstr "Mikrofonforstærkning"
|
||||
|
||||
@@ -21318,7 +21308,7 @@ msgstr "Mest relevant"
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:102
|
||||
#: src/features/user/components/modals/tabs/accessibility_tab/AccessibilityTabInline.tsx:31
|
||||
#: src/features/user/components/modals/tabs/AccessibilityTab.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:108
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:101
|
||||
#: src/features/user/components/settings_utils/search_index/AccessibilityIndex.ts:297
|
||||
#: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:271
|
||||
msgid "Motion"
|
||||
@@ -22142,7 +22132,7 @@ msgid "New Mexico"
|
||||
msgstr "New Mexico"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:20
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:19
|
||||
msgid "New microphone"
|
||||
msgstr "Ny mikrofon"
|
||||
|
||||
@@ -22830,11 +22820,11 @@ msgstr "Ingen bruger fundet med det brugernavn."
|
||||
msgid "No users are currently banned from this community."
|
||||
msgstr "Der er ingen brugere, der er udelukket fra dette fællesskab i øjeblikket."
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users available"
|
||||
msgstr "Ingen brugere tilgængelige"
|
||||
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:127
|
||||
#: src/features/search/components/search/UserFilterSheet.tsx:138
|
||||
msgid "No users found"
|
||||
msgstr "Ingen brugere fundet"
|
||||
|
||||
@@ -23313,7 +23303,7 @@ msgstr "Gælder kun på brede skærme."
|
||||
msgid "Only credit cards are accepted. Debit and prepaid cards cannot be used for the mature content check."
|
||||
msgstr "Kun kreditkort accepteres. Debetkort og forudbetalte kort kan ikke bruges til adgangsbekræftelse for indhold for voksne."
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:946
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:948
|
||||
msgid "Only default speakers"
|
||||
msgstr "Kun standardhøjttalere"
|
||||
|
||||
@@ -23361,7 +23351,7 @@ msgstr "Kør kun kode her, hvis du forstår præcis, hvad den gør."
|
||||
msgid "Only show message previews for DMs with unread messages"
|
||||
msgstr "Vis kun beskedforhåndsvisninger for DM'er med ulæste beskeder"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:936
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:938
|
||||
msgid "Only speakers"
|
||||
msgstr "Kun højttalere"
|
||||
|
||||
@@ -23901,21 +23891,6 @@ msgstr "Åbn din telefons beskedapp, og start en ny sms."
|
||||
msgid "Open your settings"
|
||||
msgstr "Åbn dine indstillinger"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:121
|
||||
msgid "OpenH264 software codec on Linux"
|
||||
msgstr "OpenH264-softwarekodek på Linux"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:109
|
||||
msgid "OpenH264 video codec"
|
||||
msgstr "OpenH264-videokodek"
|
||||
|
||||
#. Switch label for the OpenH264 codec toggle. "OpenH264" is a product name and "Cisco Systems, Inc." is a company name; do not translate either.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:42
|
||||
msgid "OpenH264 Video Codec provided by Cisco Systems, Inc."
|
||||
msgstr "OpenH264-videokodek leveret af Cisco Systems, Inc."
|
||||
|
||||
#. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening.
|
||||
#: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66
|
||||
msgid "Opens the Members page. Press Space or Enter to open."
|
||||
@@ -24411,7 +24386,7 @@ msgid "Pause invites? New users won't be able to join through invite links until
|
||||
msgstr "Sæt invitationer på pause? Nye brugere kan ikke blive medlem via invitationslinks, før du aktiverer dem igen. Eksisterende medlemmer påvirkes ikke."
|
||||
|
||||
#. Short label for an advanced screen-share preview preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:47
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:40
|
||||
msgid "Pause my screen share preview in the background"
|
||||
msgstr "Sæt forhåndsvisning af skærmdeling på pause i baggrunden"
|
||||
|
||||
@@ -24528,7 +24503,7 @@ msgid "Per-community profiles"
|
||||
msgstr "Profiler pr. fællesskab"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:32
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:31
|
||||
msgid "Per-device volume"
|
||||
msgstr "Lydstyrke pr. enhed"
|
||||
|
||||
@@ -24776,7 +24751,7 @@ msgid "Picture"
|
||||
msgstr "Billede"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:133
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:116
|
||||
msgid "Picture-in-picture"
|
||||
msgstr "Billede i billede"
|
||||
|
||||
@@ -24906,7 +24881,7 @@ msgid "Pins popout"
|
||||
msgstr "Panel med fastgjorte beskeder"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:137
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:120
|
||||
msgid "PiP"
|
||||
msgstr "Billede i billede"
|
||||
|
||||
@@ -25108,7 +25083,7 @@ msgid "Prefer @mention"
|
||||
msgstr "Foretræk @omtale"
|
||||
|
||||
#. Option label for an encoder path select. Refers to hardware video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:67
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:60
|
||||
msgid "Prefer hardware"
|
||||
msgstr "Foretræk hardware"
|
||||
|
||||
@@ -25120,7 +25095,7 @@ msgid "Prefer no @mention"
|
||||
msgstr "Foretræk ingen @omtaler"
|
||||
|
||||
#. Option label for an encoder path select. Refers to software video encoders.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:71
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:64
|
||||
msgid "Prefer software"
|
||||
msgstr "Foretræk software"
|
||||
|
||||
@@ -25140,7 +25115,7 @@ msgid "Preferences..."
|
||||
msgstr "Indstillinger..."
|
||||
|
||||
#. Accessible label for the advanced screen-share codec preference select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:125
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:118
|
||||
msgid "Preferred screen share codec"
|
||||
msgstr "Foretrukken kodek til skærmdeling"
|
||||
|
||||
@@ -25314,7 +25289,7 @@ msgid "Preview sound"
|
||||
msgstr "Afspil lyd"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:145
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:128
|
||||
msgid "Preview, popout, and stream thumbnail behavior"
|
||||
msgstr "Indstillinger for forhåndsvisning, separate vinduer og streamminiaturer"
|
||||
|
||||
@@ -25644,7 +25619,7 @@ msgid "Prompt"
|
||||
msgstr "Prompt"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:24
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:23
|
||||
msgid "Prompt for new audio devices"
|
||||
msgstr "Vis prompt for nye lydenheder"
|
||||
|
||||
@@ -27659,7 +27634,7 @@ msgstr "Ændring af størrelse, maksimering og fastgørelse af vinduer afhænger
|
||||
#. Section header in the stream settings menu for resolution options.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:206
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:128
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:129
|
||||
msgid "Resolution"
|
||||
msgstr "Opløsning"
|
||||
|
||||
@@ -28352,7 +28327,7 @@ msgstr "Skærmoptagelsestilladelse påkrævet"
|
||||
#: src/features/user/components/modals/tabs/UserVideoTabState.ts:28
|
||||
#: src/features/user/components/settings_utils/section_registry/VoiceVideoSections.ts:222
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:234
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:88
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:89
|
||||
msgid "Screen share"
|
||||
msgstr "Skærmdeling"
|
||||
|
||||
@@ -28372,17 +28347,17 @@ msgid "Screen share audio couldn't start"
|
||||
msgstr "Skærmdelingens lyd kunne ikke startes"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:68
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:67
|
||||
msgid "Screen share codec"
|
||||
msgstr "Kodek til skærmdeling"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:149
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:132
|
||||
msgid "Screen share encoder controls"
|
||||
msgstr "Indstillinger for videokodning ved skærmdeling"
|
||||
|
||||
#. Title of a modal shown when an active screen share stops unexpectedly.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:119
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:120
|
||||
msgctxt "screen-share"
|
||||
msgid "Screen share ended"
|
||||
msgstr "Skærmdeling afsluttet"
|
||||
@@ -28398,12 +28373,12 @@ msgid "Screen share is using software encoding ({codec}). Quality may be reduced
|
||||
msgstr "Skærmdeling bruger softwarekodning ({codec}). Kvaliteten kan blive reduceret, og CPU-forbruget kan stige. Din GPU understøtter muligvis ikke hardwarekodning med denne kodek."
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:129
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:112
|
||||
msgid "Screen share preview"
|
||||
msgstr "Forhåndsvisning af skærmdeling"
|
||||
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:125
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:108
|
||||
msgid "Screen share preview behavior"
|
||||
msgstr "Indstillinger for forhåndsvisning af skærmdeling"
|
||||
|
||||
@@ -28464,7 +28439,7 @@ msgid "Screen sharing"
|
||||
msgstr "Skærmdeling"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:72
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:71
|
||||
msgid "Screen sharing codec"
|
||||
msgstr "Kodek til skærmdeling"
|
||||
|
||||
@@ -29829,7 +29804,7 @@ msgid "Share screens and windows."
|
||||
msgstr "Del skærme og vinduer."
|
||||
|
||||
#. Toggle label for sharing audio with an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:156
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:157
|
||||
msgid "Share stream audio"
|
||||
msgstr "Del lyd fra stream"
|
||||
|
||||
@@ -29909,12 +29884,12 @@ msgid "Sharing personal information"
|
||||
msgstr "Deler personlige oplysninger"
|
||||
|
||||
#. Description for the free-tier Screen share streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:97
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:98
|
||||
msgid "Sharp text at 720p, up to 30 FPS"
|
||||
msgstr "Skarp tekst i 720p, op til 30 FPS"
|
||||
|
||||
#. Description for the high-tier Screen share streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:92
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:93
|
||||
msgid "Sharp text at up to 4K, up to 15 FPS"
|
||||
msgstr "Skarp tekst op til 4K, op til 15 FPS"
|
||||
|
||||
@@ -30327,7 +30302,7 @@ msgstr "Vis Neko"
|
||||
#. Settings search entry label. Names the settings search entry in the settings UI.
|
||||
#. Short label for an advanced voice device preference.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVoiceControls.tsx:11
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:8
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:7
|
||||
msgid "Show new device alerts"
|
||||
msgstr "Vis notifikationer om nye enheder"
|
||||
|
||||
@@ -30364,7 +30339,7 @@ msgid "Show password"
|
||||
msgstr "Vis adgangskode"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:44
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:43
|
||||
msgid "Show per-device participant volume sliders in voice menus"
|
||||
msgstr "Vis lydstyrkeskydere for deltagernes enkelte enheder i talemenuer"
|
||||
|
||||
@@ -30662,7 +30637,7 @@ msgstr "Lydløse opkald"
|
||||
msgid "Silent calls from everyone"
|
||||
msgstr "Lydløse opkald fra alle"
|
||||
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:872
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:874
|
||||
msgid "Silent when the capture device has no audio input of its own"
|
||||
msgstr "Stille, når enheden ikke har egen lydinput"
|
||||
|
||||
@@ -30689,7 +30664,7 @@ msgid "Single community mode"
|
||||
msgstr "Tilstand for ét fællesskab"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:87
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:80
|
||||
msgid "Single layer"
|
||||
msgstr "Enkelt lag"
|
||||
|
||||
@@ -30975,12 +30950,12 @@ msgid "Smileys"
|
||||
msgstr "Tekstsmileyer"
|
||||
|
||||
#. Description for the free-tier Gaming streaming preset. Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:84
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:85
|
||||
msgid "Smooth 30 FPS at up to 720p"
|
||||
msgstr "Glat 30 FPS ved op til 720p"
|
||||
|
||||
#. Description for the high-tier Gaming streaming preset (Plutonium). Resolution and frame rate are technical tokens.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:79
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:80
|
||||
msgid "Smooth 60 FPS at up to 1440p"
|
||||
msgstr "Glat 60 FPS ved op til 1440p"
|
||||
|
||||
@@ -31028,7 +31003,7 @@ msgid "Social"
|
||||
msgstr "Socialt"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:157
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:140
|
||||
msgid "Software encoder"
|
||||
msgstr "Software-encoder"
|
||||
|
||||
@@ -31258,7 +31233,7 @@ msgstr "Lyde"
|
||||
#. Short label in the video tab for the native source resolution option. Keep it concise.
|
||||
#: src/features/user/components/modals/tabs/UserVideoTab.tsx:83
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:250
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:110
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:111
|
||||
#: src/features/voice/utils/VoiceMessageDescriptors.ts:336
|
||||
msgid "Source"
|
||||
msgstr "Kilde"
|
||||
@@ -32047,13 +32022,13 @@ msgstr "Stream skjult"
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#. Short label in the user settings voice activity card. Keep it concise.
|
||||
#: src/features/user/components/profile/VoiceActivityCard.tsx:90
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:141
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:124
|
||||
#: src/features/voice/components/StreamWatchHoverCard.tsx:21
|
||||
msgid "Stream preview"
|
||||
msgstr "Streamforhåndsvisning"
|
||||
|
||||
#. Compact submenu label for changing the resolution and frame rate of an active stream.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:152
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:153
|
||||
msgid "Stream quality"
|
||||
msgstr "Streamkvalitet"
|
||||
|
||||
@@ -32079,7 +32054,7 @@ msgid "Streaming"
|
||||
msgstr "Streamer"
|
||||
|
||||
#. Section header in the stream settings menu. Picks between Gaming / Screen share / Custom presets.
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:124
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:125
|
||||
msgid "Streaming mode"
|
||||
msgstr "Streamingtilstand"
|
||||
|
||||
@@ -32280,12 +32255,12 @@ msgid "Suspend"
|
||||
msgstr "Deaktiver midlertidigt"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:165
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:148
|
||||
msgid "SVC"
|
||||
msgstr "SVC"
|
||||
|
||||
#. Label for an advanced screen-share select. SVC is a WebRTC acronym and should stay uppercase.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:75
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:68
|
||||
msgid "SVC mode"
|
||||
msgstr "SVC-tilstand"
|
||||
|
||||
@@ -32559,7 +32534,7 @@ msgstr "Systemmeddelelser fra {productName}-medarbejdere. Du kan ikke svare her.
|
||||
#. Audio device option label meaning the OS default audio input device.
|
||||
#. Footer summary fallback for the default capture-device audio input.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:263
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:140
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:141
|
||||
msgid "System default"
|
||||
msgstr "Systemstandard"
|
||||
|
||||
@@ -32760,7 +32735,7 @@ msgid "Template URL"
|
||||
msgstr "Skabelon-URL"
|
||||
|
||||
#. Option label for an SVC mode select.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:91
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:84
|
||||
msgid "Temporal layers"
|
||||
msgstr "Tidsmæssige lag"
|
||||
|
||||
@@ -32837,7 +32812,7 @@ msgstr "Texas"
|
||||
#: src/features/channel/utils/ChannelUtils.tsx:51
|
||||
#: src/features/lexical/composer/nodes/SlashSlotNode.ts:23
|
||||
#: src/features/theme_studio/sections/TokenGroups.ts:50
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:116
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:109
|
||||
msgid "Text"
|
||||
msgstr "Tekst"
|
||||
|
||||
@@ -33247,7 +33222,7 @@ msgid "The role granted {permissions}"
|
||||
msgstr "Rollen gav {permissions}"
|
||||
|
||||
#. Body of a modal shown when a browser screen share track ends outside the app, for example from the browser sharing controls.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:124
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:125
|
||||
msgctxt "screen-share"
|
||||
msgid "The screen share source stopped sending video, so your screen share was stopped."
|
||||
msgstr "Skærmdelingskilden er stoppet med at sende video, så din skærmdeling blev stoppet."
|
||||
@@ -35774,7 +35749,7 @@ msgstr "Navnløs gruppe"
|
||||
#. Fallback label for an audio input device whose name is not reported by the OS.
|
||||
#. Footer summary fallback for an audio input without a reported name.
|
||||
#: src/features/voice/components/modals/ScreenSharePickerModal.tsx:267
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:144
|
||||
#: src/features/voice/components/StreamSettingsMenuContent.tsx:145
|
||||
msgid "Unnamed input"
|
||||
msgstr "Unavngivet input"
|
||||
|
||||
@@ -36662,7 +36637,7 @@ msgid "Used in communities that do not have their own sound."
|
||||
msgstr "Bruges i fællesskaber, der ikke har deres egen lyd."
|
||||
|
||||
#. Description for a WebRTC content hint select. Custom, Gaming and Screen share are the stream quality preset labels.
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:99
|
||||
#: src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx:92
|
||||
msgid "Used in Custom mode. The Gaming and Screen share presets set their own hint."
|
||||
msgstr "Brugt i Brugerdefineret tilstand. Forudindstillingerne for Spil og Skærmdeling angiver deres eget hint."
|
||||
|
||||
@@ -37236,12 +37211,12 @@ msgid "Video call unavailable"
|
||||
msgstr "Videoopkald er ikke tilgængeligt"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:80
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:79
|
||||
msgid "Video codec"
|
||||
msgstr "Videokodek"
|
||||
|
||||
#. Settings search entry description. One-line summary of what the settings search entry controls.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:84
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:83
|
||||
msgid "Video codec for screen sharing"
|
||||
msgstr "Videokodek til skærmdeling"
|
||||
|
||||
@@ -37912,7 +37887,7 @@ msgid "Volume mute"
|
||||
msgstr "Slå lyden fra"
|
||||
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:40
|
||||
#: src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts:39
|
||||
msgid "Volume sliders"
|
||||
msgstr "Lydstyrkeskydere"
|
||||
|
||||
@@ -38466,6 +38441,11 @@ msgstr "Hvad skal medtages"
|
||||
msgid "What to search for."
|
||||
msgstr "Hvad du skal søge efter."
|
||||
|
||||
#. Informational overlay on your own screen-share tile while the shared screen or window sends no new frames, for example when it is static or minimised. Not an error.
|
||||
#: src/features/voice/components/VoiceParticipantTile.tsx:180
|
||||
msgid "What you're sharing isn't updating"
|
||||
msgstr "Det, du deler, opdateres ikke"
|
||||
|
||||
#. Placeholder question in the custom status text field.
|
||||
#. Placeholder question in the custom status text field.
|
||||
#: src/features/user/components/modals/CustomStatusBottomSheet.tsx:40
|
||||
@@ -39972,7 +39952,7 @@ msgid "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProduc
|
||||
msgstr "Din gemte {savedResolution} med {savedFrameRate} FPS kræver {premiumProductName}, så delinger bruger {resolution} med {frameRate} FPS."
|
||||
|
||||
#. Body of a modal shown when the video codec the browser negotiated for an active screen share is outside the codecs the app allows and no allowed codec could be published, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:130
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:131
|
||||
msgctxt "screen-share"
|
||||
msgid "Your screen share could not be published with a compatible video codec, so it was stopped. Try sharing again."
|
||||
msgstr "Din skærmdeling kunne ikke sendes med et kompatibelt videokodek, så den blev stoppet. Prøv at dele igen."
|
||||
@@ -40071,7 +40051,7 @@ msgid "Your vanity URL has been set to {inviteEndpoint}/{vanitySlug}"
|
||||
msgstr "Dit brugerdefinerede invitationslink er nu {inviteEndpoint}/{vanitySlug}"
|
||||
|
||||
#. Body of a modal shown when the local video encoder produces no frames for an active screen share, which forces the share to stop.
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:137
|
||||
#: src/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter.tsx:138
|
||||
msgctxt "screen-share"
|
||||
msgid "Your video encoder stopped encoding frames, so your screen share was stopped. Try sharing again."
|
||||
msgstr "Din video-encoder stoppede med at kode billeder, så din skærmdeling blev stoppet. Prøv at dele igen."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user