feat: room settings

pull/370/head
zijiren233 3 months ago
parent 2ab2a52b08
commit 02e49cbf0f
No known key found for this signature in database
GPG Key ID: 534E082AAA9B39DC

@ -10,13 +10,13 @@ runtime settings 是存储在 PostgreSQL 的热更新产品策略。它们适合
```bash
synctv settings list
synctv settings get user
synctv settings update --request-json '{"settings":{"user":{"enablePasswordSignup":true}},"updateMask":"user.enablePasswordSignup"}'
synctv settings update --set user.enablePasswordSignup=true
```
权限默认值也属于 runtime settings。guest 使用独立上限,不能写入播放列表、媒体、聊天写入或管理权限:
```bash
synctv settings update --request-json '{"settings":{"permissions":{"guestDefaultPermissions":"40"}},"updateMask":"permissions.guestDefaultPermissions"}'
synctv settings update --set permissions.guestDefaultPermissions=40
```
## 判断是否属于 runtime settings

@ -14,7 +14,15 @@ import { Tabs, TabItem } from '@astrojs/starlight/components';
邮件配置存储在 PostgreSQL,并在集群节点间热更新:
```bash
synctv settings update --request-json '{"settings":{"email":{"smtpHost":"smtp.example.com","smtpPort":465,"smtpCredentials":{"username":"synctv@example.com","password":"secret"},"smtpProxy":{"url":"socks5://proxy.example.com:1080"},"useTls":true,"fromEmail":"synctv@example.com","fromName":"SyncTV","enabled":true}},"updateMask":"email.smtpHost,email.smtpPort,email.smtpCredentials,email.smtpProxy,email.useTls,email.fromEmail,email.fromName,email.enabled"}'
synctv settings update \
--set email.smtpHost=smtp.example.com \
--set email.smtpPort=465 \
--set 'email.smtpCredentials={"username":"synctv@example.com","password":"secret"}' \
--set 'email.smtpProxy={"url":"socks5://proxy.example.com:1080"}' \
--set email.useTls=true \
--set email.fromEmail=synctv@example.com \
--set email.fromName=SyncTV \
--set email.enabled=true
```
| 字段 | 默认值 | 作用 |

@ -10,13 +10,13 @@ Runtime settings are hot-updated product policy stored in PostgreSQL. Use them f
```bash
synctv settings list
synctv settings get user
synctv settings update --request-json '{"settings":{"user":{"enablePasswordSignup":true}},"updateMask":"user.enablePasswordSignup"}'
synctv settings update --set user.enablePasswordSignup=true
```
Permission defaults are runtime settings too. Guests use a dedicated ceiling, so media-resource, chat-write, and administration permissions are rejected:
```bash
synctv settings update --request-json '{"settings":{"permissions":{"guestDefaultPermissions":"40"}},"updateMask":"permissions.guestDefaultPermissions"}'
synctv settings update --set permissions.guestDefaultPermissions=40
```
## Where a Change Belongs

@ -14,7 +14,15 @@ Email login and OAuth2 providers use runtime settings. Email uses the `email` gr
Email settings are stored in PostgreSQL and hot-reloaded across cluster nodes:
```bash
synctv settings update --request-json '{"settings":{"email":{"smtpHost":"smtp.example.com","smtpPort":465,"smtpCredentials":{"username":"synctv@example.com","password":"secret"},"smtpProxy":{"url":"socks5://proxy.example.com:1080"},"useTls":true,"fromEmail":"synctv@example.com","fromName":"SyncTV","enabled":true}},"updateMask":"email.smtpHost,email.smtpPort,email.smtpCredentials,email.smtpProxy,email.useTls,email.fromEmail,email.fromName,email.enabled"}'
synctv settings update \
--set email.smtpHost=smtp.example.com \
--set email.smtpPort=465 \
--set 'email.smtpCredentials={"username":"synctv@example.com","password":"secret"}' \
--set 'email.smtpProxy={"url":"socks5://proxy.example.com:1080"}' \
--set email.useTls=true \
--set email.fromEmail=synctv@example.com \
--set email.fromName=SyncTV \
--set email.enabled=true
```
| Field | Default | Meaning |

@ -285,7 +285,8 @@ Room settings:
```bash
synctv room settings get <ROOM_ID>
synctv room settings update <ROOM_ID> --settings-json '{"requireApproval":true}'
synctv room settings update <ROOM_ID> --set requireApproval=true --set autoPlay.mode=shuffle
synctv room settings update <ROOM_ID> --unset autoPlay.mode
synctv room settings reset <ROOM_ID>
```
@ -451,11 +452,12 @@ Runtime settings are changed through the management endpoint. They are not the s
```bash
synctv settings list
synctv settings get email
synctv settings update --request-json '{"settings":{"email":{"whitelistEnabled":true,"whitelistDomains":["example.com"]}},"updateMask":"email.whitelistEnabled,email.whitelistDomains"}'
synctv settings update --set email.whitelistEnabled=true --set 'email.whitelistDomains=["example.com"]'
synctv settings update --unset email.smtpProxy
synctv settings test-email admin@example.com
```
`--request-json` accepts the standard `UpdateSettingsRequest` ProtoJSON shape. `updateMask` is a comma-separated lowerCamel FieldMask string.
`--set PATH=VALUE` and `--unset PATH` are repeatable, and the CLI builds the FieldMask. `--request-json` accepts the standard `UpdateSettingsRequest` ProtoJSON shape and is mutually exclusive with set/unset mode.
See [Runtime Settings Reference](../runtime-settings/) for all keys, defaults, and validation rules.

@ -23,16 +23,19 @@ Do not put startup secrets, database URLs, Redis URLs, listener ports, or slice-
```bash
synctv settings list
synctv settings get user
synctv settings update --request-json '{"settings":{"user":{"enablePasswordSignup":true}},"updateMask":"user.enablePasswordSignup"}'
synctv settings update --set user.enablePasswordSignup=true
```
Updates use protobuf `FieldMask`. Keys under `settings` and the JSON form of `updateMask` use lowerCamel names. Separate multiple mask paths with commas:
Both `--set PATH=VALUE` and `--unset PATH` are repeatable. Plain strings can be unquoted; arrays and objects use JSON:
```bash
synctv settings update --request-json '{"settings":{"email":{"whitelistEnabled":true,"whitelistDomains":["example.com"]}},"updateMask":"email.whitelistEnabled,email.whitelistDomains"}'
synctv settings update \
--set email.whitelistEnabled=true \
--set 'email.whitelistDomains=["example.com"]'
synctv settings update --unset email.smtpProxy
```
An optional field named by the mask is cleared when it is absent from `settings`. Required fields named by the mask must have a value. The mask accepts leaf paths only.
The CLI builds the protobuf `FieldMask` automatically. Paths use lowerCamel leaf fields; `--unset` clears optional configuration. `--request-json` accepts a complete `UpdateSettingsRequest` ProtoJSON document for generated configuration and automated batch requests.
Check the current binary with `synctv settings --help`.
@ -218,35 +221,37 @@ Main service startup CORS is `server.cors_allowed_origins`. Runtime CORS is for
<TabItem label="Enable password signup" icon="setting">
```bash
synctv settings update --request-json '{"settings":{"user":{"enablePasswordSignup":true}},"updateMask":"user.enablePasswordSignup"}'
synctv settings update --set user.enablePasswordSignup=true
```
</TabItem>
<TabItem label="Review password signup" icon="approve-check-circle">
```bash
synctv settings update --request-json '{"settings":{"user":{"passwordSignupNeedReview":true}},"updateMask":"user.passwordSignupNeedReview"}'
synctv settings update --set user.passwordSignupNeedReview=true
```
</TabItem>
<TabItem label="Limit room count" icon="document">
```bash
synctv settings update --request-json '{"settings":{"roomCreation":{"maxRoomsPerUser":"20"}},"updateMask":"roomCreation.maxRoomsPerUser"}'
synctv settings update --set roomCreation.maxRoomsPerUser=20
```
</TabItem>
<TabItem label="Chat retention" icon="warning">
```bash
synctv settings update --request-json '{"settings":{"chat":{"maxMessagesPerRoom":"1000","messageRetentionDays":"180"}},"updateMask":"chat.maxMessagesPerRoom,chat.messageRetentionDays"}'
synctv settings update \
--set chat.maxMessagesPerRoom=1000 \
--set chat.messageRetentionDays=180
```
</TabItem>
<TabItem label="Configure OAuth2" icon="document">
```bash
synctv settings update --request-json '{"settings":{"oauth2":{"providers":[{"name":"github","enableSignup":true,"signupNeedReview":false,"github":{"clientId":"github-client-id","clientSecret":"github-client-secret","redirectUrl":"https://app.example.com/oauth2/callback"}}]}},"updateMask":"oauth2.providers"}'
synctv settings update --set 'oauth2.providers=[{"name":"github","enableSignup":true,"signupNeedReview":false,"github":{"clientId":"github-client-id","clientSecret":"github-client-secret","redirectUrl":"https://app.example.com/oauth2/callback"}}]'
```
</TabItem>

@ -128,7 +128,7 @@ Example:
```bash
synctv room settings get <ROOM_ID>
synctv room settings update <ROOM_ID> --settings-json '{"requireApproval":true}'
synctv room settings update <ROOM_ID> --set requireApproval=true --set autoPlay.mode=shuffle
```
## Member Overrides

@ -305,7 +305,8 @@ synctv room delete <ROOM_ID>
```bash
synctv room settings get <ROOM_ID>
synctv room settings update <ROOM_ID> --settings-json '{"requireApproval":true}'
synctv room settings update <ROOM_ID> --set requireApproval=true --set autoPlay.mode=shuffle
synctv room settings update <ROOM_ID> --unset autoPlay.mode
synctv room settings reset <ROOM_ID>
```
@ -463,11 +464,12 @@ synctv provider rtmp info --username alice --room-id <ROOM_ID> <MEDIA_ID>
```bash
synctv settings list
synctv settings get email
synctv settings update --request-json '{"settings":{"email":{"whitelistEnabled":true,"whitelistDomains":["example.com"]}},"updateMask":"email.whitelistEnabled,email.whitelistDomains"}'
synctv settings update --set email.whitelistEnabled=true --set 'email.whitelistDomains=["example.com"]'
synctv settings update --unset email.smtpProxy
synctv settings test-email admin@example.com
```
`--request-json` 接受标准 `UpdateSettingsRequest` ProtoJSON。`updateMask` 是逗号分隔的 lowerCamel FieldMask 字符串。
`--set PATH=VALUE` 与 `--unset PATH` 可以重复使用,CLI 自动生成 FieldMask。`--request-json` 接受标准 `UpdateSettingsRequest` ProtoJSON,并与 set/unset 模式互斥。
完整 key、默认值和校验规则见 [Runtime settings 参考](../runtime-settings/)。

@ -23,16 +23,19 @@ Runtime settings 是存储在 PostgreSQL 的热更新设置,主要用于运行
```bash
synctv settings list
synctv settings get user
synctv settings update --request-json '{"settings":{"user":{"enablePasswordSignup":true}},"updateMask":"user.enablePasswordSignup"}'
synctv settings update --set user.enablePasswordSignup=true
```
更新协议使用 protobuf `FieldMask`。`settings` 中的 key 和 `updateMask` 的 JSON 表示均为 lowerCamel;多个路径用逗号分隔:
`--set PATH=VALUE` 和 `--unset PATH` 均可重复使用。普通字符串可以直接传入,数组和对象使用 JSON:
```bash
synctv settings update --request-json '{"settings":{"email":{"whitelistEnabled":true,"whitelistDomains":["example.com"]}},"updateMask":"email.whitelistEnabled,email.whitelistDomains"}'
synctv settings update \
--set email.whitelistEnabled=true \
--set 'email.whitelistDomains=["example.com"]'
synctv settings update --unset email.smtpProxy
```
mask 中存在的可选字段在 `settings` 中缺失时会被清除。普通字段必须在 `settings` 中提供值。mask 只接受叶子路径。
CLI 会自动构造 protobuf `FieldMask`。路径使用 lowerCamel 叶子字段;`--unset` 用于清除 optional 配置。`--request-json` 接受完整的 `UpdateSettingsRequest` ProtoJSON,适合生成式配置和自动化批量请求。
具体参数以当前二进制的 `synctv settings --help` 为准。
@ -218,35 +221,37 @@ SOCKS5 代理 URL 只包含 scheme、host 和 port。代理认证信息位于 `s
<TabItem label="开启密码注册" icon="setting">
```bash
synctv settings update --request-json '{"settings":{"user":{"enablePasswordSignup":true}},"updateMask":"user.enablePasswordSignup"}'
synctv settings update --set user.enablePasswordSignup=true
```
</TabItem>
<TabItem label="密码注册审核" icon="approve-check-circle">
```bash
synctv settings update --request-json '{"settings":{"user":{"passwordSignupNeedReview":true}},"updateMask":"user.passwordSignupNeedReview"}'
synctv settings update --set user.passwordSignupNeedReview=true
```
</TabItem>
<TabItem label="限制房间数量" icon="document">
```bash
synctv settings update --request-json '{"settings":{"roomCreation":{"maxRoomsPerUser":"20"}},"updateMask":"roomCreation.maxRoomsPerUser"}'
synctv settings update --set roomCreation.maxRoomsPerUser=20
```
</TabItem>
<TabItem label="清理聊天保留" icon="warning">
```bash
synctv settings update --request-json '{"settings":{"chat":{"maxMessagesPerRoom":"1000","messageRetentionDays":"180"}},"updateMask":"chat.maxMessagesPerRoom,chat.messageRetentionDays"}'
synctv settings update \
--set chat.maxMessagesPerRoom=1000 \
--set chat.messageRetentionDays=180
```
</TabItem>
<TabItem label="配置 OAuth2" icon="document">
```bash
synctv settings update --request-json '{"settings":{"oauth2":{"providers":[{"name":"github","enableSignup":true,"signupNeedReview":false,"github":{"clientId":"github-client-id","clientSecret":"github-client-secret","redirectUrl":"https://app.example.com/oauth2/callback"}}]}},"updateMask":"oauth2.providers"}'
synctv settings update --set 'oauth2.providers=[{"name":"github","enableSignup":true,"signupNeedReview":false,"github":{"clientId":"github-client-id","clientSecret":"github-client-secret","redirectUrl":"https://app.example.com/oauth2/callback"}}]'
```
</TabItem>

@ -128,7 +128,7 @@ SyncTV 有两层角色,不能混为一谈。
```bash
synctv room settings get <ROOM_ID>
synctv room settings update <ROOM_ID> --settings-json '{"requireApproval":true}'
synctv room settings update <ROOM_ID> --set requireApproval=true --set autoPlay.mode=shuffle
```
## 成员权限覆盖

@ -232,20 +232,16 @@ pub fn room_settings_patch_from_admin_proto(
req: &admin_proto::UpdateRoomSettingsRequest,
) -> Result<RoomSettingsUpdatePatch, ApiError> {
crate::impls::validate_proto_request(req)?;
room_settings_patch_from_admin_proto_parts(&AdminUpdateRoomSettingsProtoParts {
allow_guest_join: req.allow_guest_join,
max_members: req.max_members,
require_approval: req.require_approval,
allow_auto_join: req.allow_auto_join,
chat_enabled: req.chat_enabled,
auto_play: req.auto_play,
admin_added_permissions: req.admin_added_permissions,
admin_removed_permissions: req.admin_removed_permissions,
member_added_permissions: req.member_added_permissions,
member_removed_permissions: req.member_removed_permissions,
guest_added_permissions: req.guest_added_permissions,
guest_removed_permissions: req.guest_removed_permissions,
})
let settings = req
.settings
.ok_or_else(|| ApiError::InvalidInput("settings is required".to_string()))?;
let paths = &req
.update_mask
.as_ref()
.ok_or_else(|| ApiError::InvalidInput("update_mask is required".to_string()))?
.paths;
let patch = crate::room_settings_mapping::select_room_settings_patch(settings, paths)?;
room_settings_patch_from_client_proto(patch)
}
fn email_settings_patch_from_admin_proto(
@ -278,41 +274,25 @@ fn email_settings_patch_from_admin_proto(
}
}
#[derive(Debug, Clone, Default)]
pub struct AdminUpdateRoomSettingsProtoParts {
pub allow_guest_join: Option<bool>,
pub max_members: Option<u64>,
pub require_approval: Option<bool>,
pub allow_auto_join: Option<bool>,
pub chat_enabled: Option<bool>,
pub auto_play: Option<client_proto::AutoPlaySettingsPatch>,
pub admin_added_permissions: Option<u64>,
pub admin_removed_permissions: Option<u64>,
pub member_added_permissions: Option<u64>,
pub member_removed_permissions: Option<u64>,
pub guest_added_permissions: Option<u64>,
pub guest_removed_permissions: Option<u64>,
}
pub fn room_settings_patch_from_admin_proto_parts(
parts: &AdminUpdateRoomSettingsProtoParts,
fn room_settings_patch_from_client_proto(
patch: client_proto::RoomSettingsPatch,
) -> Result<RoomSettingsUpdatePatch, ApiError> {
Ok(RoomSettingsUpdatePatch {
allow_guest_join: parts.allow_guest_join,
max_members: parts.max_members,
require_approval: parts.require_approval,
allow_auto_join: parts.allow_auto_join,
chat_enabled: parts.chat_enabled,
auto_play: parts
allow_guest_join: patch.allow_guest_join,
max_members: patch.max_members,
require_approval: patch.require_approval,
allow_auto_join: patch.allow_auto_join,
chat_enabled: patch.chat_enabled,
auto_play: patch
.auto_play
.map(auto_play_patch_from_client_proto)
.transpose()?,
admin_added_permissions: parts.admin_added_permissions,
admin_removed_permissions: parts.admin_removed_permissions,
member_added_permissions: parts.member_added_permissions,
member_removed_permissions: parts.member_removed_permissions,
guest_added_permissions: parts.guest_added_permissions,
guest_removed_permissions: parts.guest_removed_permissions,
admin_added_permissions: patch.admin_added_permissions,
admin_removed_permissions: patch.admin_removed_permissions,
member_added_permissions: patch.member_added_permissions,
member_removed_permissions: patch.member_removed_permissions,
guest_added_permissions: patch.guest_added_permissions,
guest_removed_permissions: patch.guest_removed_permissions,
})
}

@ -2505,11 +2505,15 @@ mod tests {
#[test]
fn test_update_room_settings_request_accepts_protojson_body() -> TestResult {
let json = r#"{"roomId":"room_body","allowGuestJoin":true}"#;
let json = r#"{"roomId":"room_body","settings":{"allowGuestJoin":true},"updateMask":"allowGuestJoin"}"#;
let mut req: admin::UpdateRoomSettingsRequest = serde_json::from_str(json)?;
req.room_id = "room_1".to_string();
assert_eq!(req.room_id, "room_1");
assert_eq!(req.allow_guest_join, Some(true));
assert_eq!(req.settings.expect("settings").allow_guest_join, Some(true));
assert_eq!(
req.update_mask.expect("update mask").paths,
["allow_guest_join"]
);
Ok(())
}

@ -185,7 +185,7 @@ pub(crate) fn room_settings_from_proto(
pub(crate) fn apply_room_settings_patch_from_proto(
mut settings: synctv_core::models::RoomSettings,
patch: client_proto::UpdateRoomSettingsRequest,
request: client_proto::UpdateRoomSettingsRequest,
) -> Result<synctv_core::models::RoomSettings, crate::impls::ApiError> {
use synctv_core::models::room_settings::{
AdminAddedPermissions, AdminRemovedPermissions, AllowAutoJoin, AllowGuestJoin, AutoPlay,
@ -193,6 +193,14 @@ pub(crate) fn apply_room_settings_patch_from_proto(
MemberAddedPermissions, MemberRemovedPermissions, RequireApproval, RoomSettingsPatch,
};
let patch = request
.settings
.ok_or_else(|| crate::impls::ApiError::InvalidInput("settings is required".to_string()))?;
let paths = request
.update_mask
.ok_or_else(|| crate::impls::ApiError::InvalidInput("update_mask is required".to_string()))?
.paths;
let patch = crate::room_settings_mapping::select_room_settings_patch(patch, &paths)?;
let mut typed_patch = RoomSettingsPatch::default();
if let Some(value) = patch.allow_guest_join {
typed_patch.allow_guest_join = Some(AllowGuestJoin::new(value));

@ -2519,7 +2519,7 @@ pub(crate) fn validate_update_room_settings_request(
req: &synctv_proto::client::UpdateRoomSettingsRequest,
current: synctv_core::models::RoomSettings,
) -> Result<synctv_core::models::RoomSettings, ApiError> {
apply_room_settings_patch_from_proto(current, *req)
apply_room_settings_patch_from_proto(current, req.clone())
}
#[cfg(test)]

@ -109,18 +109,19 @@ async fn test_shared_room_actor_playlist_items_rejects_guest_even_if_media_resou
);
}
#[tokio::test]
async fn update_room_settings_accepts_empty_patch_as_noop() -> TestResult {
#[test]
fn update_room_settings_rejects_empty_request() {
let current = synctv_core::models::RoomSettings::default();
let updated = super::room::validate_update_room_settings_request(
let error = super::room::validate_update_room_settings_request(
&synctv_proto::client::UpdateRoomSettingsRequest::default(),
current.clone(),
current,
)
.map_err(test_error)?;
.expect_err("empty room settings update should be rejected");
assert_eq!(updated.allow_guest_join.0, current.allow_guest_join.0);
assert_eq!(updated.max_members.0, current.max_members.0);
Ok(())
assert!(matches!(
error,
ApiError::InvalidInput(message) if message.contains("settings is required")
));
}
#[test]

@ -30,6 +30,7 @@ pub(crate) mod realtime_lifecycle;
pub(crate) mod resource_change;
pub(crate) mod room_cache_fanout;
pub(crate) mod room_lifecycle_fanout;
pub(crate) mod room_settings_mapping;
pub(crate) mod runtime;
pub(crate) mod runtime_adapters;
pub(crate) mod server_settings;

@ -0,0 +1,180 @@
use std::collections::BTreeSet;
use synctv_proto::client as client_proto;
use crate::ApiError;
pub(crate) fn select_room_settings_patch(
mut source: client_proto::RoomSettingsPatch,
paths: &[String],
) -> Result<client_proto::RoomSettingsPatch, ApiError> {
if paths.is_empty() {
return Err(ApiError::InvalidInput(
"update_mask.paths must not be empty".to_string(),
));
}
let defaults = synctv_core::models::RoomSettings::default();
let mut selected = client_proto::RoomSettingsPatch::default();
let mut seen = BTreeSet::new();
macro_rules! select_scalar {
($field:ident, $default:expr) => {{
selected.$field = Some(source.$field.take().unwrap_or($default));
}};
}
for path in paths {
if path.is_empty() {
return Err(ApiError::InvalidInput(
"update_mask paths must not be empty".to_string(),
));
}
if !seen.insert(path.as_str()) {
return Err(ApiError::InvalidInput(format!(
"duplicate update_mask path '{path}'"
)));
}
match path.as_str() {
"allow_guest_join" => {
select_scalar!(allow_guest_join, defaults.allow_guest_join.0);
}
"max_members" => select_scalar!(max_members, defaults.max_members.0),
"require_approval" => {
select_scalar!(require_approval, defaults.require_approval.0);
}
"allow_auto_join" => {
select_scalar!(allow_auto_join, defaults.allow_auto_join.0);
}
"chat_enabled" => select_scalar!(chat_enabled, defaults.chat_enabled.0),
"auto_play.enabled" => {
let source = source.auto_play.get_or_insert_default();
selected.auto_play.get_or_insert_default().enabled = Some(
source
.enabled
.take()
.unwrap_or(defaults.auto_play.value.enabled),
);
}
"auto_play.mode" => {
let source = source.auto_play.get_or_insert_default();
selected.auto_play.get_or_insert_default().mode = Some(
source
.mode
.take()
.unwrap_or(default_play_mode(&defaults.auto_play.value.mode)),
);
}
"auto_play.delay" => {
let source = source.auto_play.get_or_insert_default();
selected.auto_play.get_or_insert_default().delay = Some(
source
.delay
.take()
.unwrap_or(defaults.auto_play.value.delay),
);
}
"admin_added_permissions" => {
select_scalar!(admin_added_permissions, defaults.admin_added_permissions.0);
}
"admin_removed_permissions" => select_scalar!(
admin_removed_permissions,
defaults.admin_removed_permissions.0
),
"member_added_permissions" => select_scalar!(
member_added_permissions,
defaults.member_added_permissions.0
),
"member_removed_permissions" => select_scalar!(
member_removed_permissions,
defaults.member_removed_permissions.0
),
"guest_added_permissions" => {
select_scalar!(guest_added_permissions, defaults.guest_added_permissions.0);
}
"guest_removed_permissions" => select_scalar!(
guest_removed_permissions,
defaults.guest_removed_permissions.0
),
_ => {
return Err(ApiError::InvalidInput(format!(
"unsupported update_mask path '{path}'"
)));
}
}
}
Ok(selected)
}
fn default_play_mode(mode: &synctv_core::models::PlayMode) -> i32 {
(match mode {
synctv_core::models::PlayMode::Sequential => client_proto::PlayMode::Sequential,
synctv_core::models::PlayMode::RepeatOne => client_proto::PlayMode::RepeatOne,
synctv_core::models::PlayMode::RepeatAll => client_proto::PlayMode::RepeatAll,
synctv_core::models::PlayMode::Shuffle => client_proto::PlayMode::Shuffle,
}) as i32
}
#[cfg(test)]
mod tests {
use super::select_room_settings_patch;
#[test]
fn selects_only_masked_room_settings_leaves() {
let patch = select_room_settings_patch(
synctv_proto::client::RoomSettingsPatch {
require_approval: Some(true),
chat_enabled: Some(false),
auto_play: Some(synctv_proto::client::AutoPlaySettingsPatch {
mode: Some(synctv_proto::client::PlayMode::Shuffle as i32),
..Default::default()
}),
..Default::default()
},
&["require_approval".to_string(), "auto_play.mode".to_string()],
)
.expect("valid room settings mask");
assert_eq!(patch.require_approval, Some(true));
assert_eq!(patch.chat_enabled, None);
assert_eq!(
patch.auto_play.expect("auto play").mode,
Some(synctv_proto::client::PlayMode::Shuffle as i32)
);
}
#[test]
fn unset_room_setting_uses_server_default() {
let patch = select_room_settings_patch(
synctv_proto::client::RoomSettingsPatch::default(),
&["allow_auto_join".to_string()],
)
.expect("unset room setting");
assert_eq!(
patch.allow_auto_join,
Some(
synctv_core::models::RoomSettings::default()
.allow_auto_join
.0
)
);
}
#[test]
fn rejects_invalid_room_settings_masks() {
for paths in [
Vec::<String>::new(),
vec!["auto_play".to_string()],
vec!["chat_enabled".to_string(), "chat_enabled".to_string()],
] {
assert!(select_room_settings_patch(
synctv_proto::client::RoomSettingsPatch::default(),
&paths
)
.is_err());
}
}
}

@ -222,18 +222,8 @@ pub struct GetRoomSettingsQuery {
#[derive(Debug, Clone)]
pub struct UpdateRoomSettingsCommand {
pub room_id: String,
pub allow_guest_join: Option<bool>,
pub max_members: Option<u64>,
pub require_approval: Option<bool>,
pub allow_auto_join: Option<bool>,
pub chat_enabled: Option<bool>,
pub auto_play: Option<client_proto::AutoPlaySettingsPatch>,
pub admin_added_permissions: Option<u64>,
pub admin_removed_permissions: Option<u64>,
pub member_added_permissions: Option<u64>,
pub member_removed_permissions: Option<u64>,
pub guest_added_permissions: Option<u64>,
pub guest_removed_permissions: Option<u64>,
pub settings: client_proto::RoomSettingsPatch,
pub update_mask: synctv_proto::FieldMask,
}
#[derive(Debug, Clone)]

@ -2157,18 +2157,12 @@ impl ManagementService for ManagementServiceImpl {
.update_room_settings(
UpdateRoomSettingsCommand {
room_id: req.room_id,
allow_guest_join: req.allow_guest_join,
max_members: req.max_members,
require_approval: req.require_approval,
allow_auto_join: req.allow_auto_join,
chat_enabled: req.chat_enabled,
auto_play: req.auto_play,
admin_added_permissions: req.admin_added_permissions,
admin_removed_permissions: req.admin_removed_permissions,
member_added_permissions: req.member_added_permissions,
member_removed_permissions: req.member_removed_permissions,
guest_added_permissions: req.guest_added_permissions,
guest_removed_permissions: req.guest_removed_permissions,
settings: req
.settings
.ok_or_else(|| Status::invalid_argument("settings is required"))?,
update_mask: req
.update_mask
.ok_or_else(|| Status::invalid_argument("update_mask is required"))?,
},
&validated.user_id,
)

@ -1114,18 +1114,8 @@ message UpdateRoomSettingsRequest {
max_len: 64
pattern: "^room_[A-Za-z0-9]+$"
}];
optional bool allow_guest_join = 2;
optional uint64 max_members = 3;
optional bool require_approval = 4;
optional bool allow_auto_join = 5;
optional bool chat_enabled = 6;
synctv.client.AutoPlaySettingsPatch auto_play = 7;
optional uint64 admin_added_permissions = 8;
optional uint64 admin_removed_permissions = 9;
optional uint64 member_added_permissions = 10;
optional uint64 member_removed_permissions = 11;
optional uint64 guest_added_permissions = 12;
optional uint64 guest_removed_permissions = 13;
synctv.client.RoomSettingsPatch settings = 2 [(buf.validate.field).required = true];
google.protobuf.FieldMask update_mask = 3 [(buf.validate.field).required = true];
}
message ResetRoomSettingsRequest {

@ -3,6 +3,7 @@ syntax = "proto3";
package synctv.client;
import "proto/buf/validate/validate.proto";
import "google/protobuf/field_mask.proto";
import "proto/common.proto";
import "proto/passkey.proto";
import "proto/source_config.proto";
@ -1222,7 +1223,7 @@ message DeleteRoomResponse {
bool success = 1;
}
message UpdateRoomSettingsRequest {
message RoomSettingsPatch {
optional bool allow_guest_join = 1;
optional uint64 max_members = 2;
optional bool require_approval = 3;
@ -1237,6 +1238,11 @@ message UpdateRoomSettingsRequest {
optional uint64 guest_removed_permissions = 12;
}
message UpdateRoomSettingsRequest {
RoomSettingsPatch settings = 1 [(buf.validate.field).required = true];
google.protobuf.FieldMask update_mask = 2 [(buf.validate.field).required = true];
}
// Get room settings
message GetRoomSettingsRequest {}

@ -2,12 +2,25 @@ use std::fmt;
use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, PartialEq, Eq, prost::Message)]
#[derive(Clone, PartialEq, Eq, Hash, prost::Message)]
pub struct FieldMask {
#[prost(string, repeated, tag = "1")]
pub paths: Vec<String>,
}
#[cfg(feature = "openapi")]
impl utoipa::PartialSchema for FieldMask {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.description(Some("Comma-separated lowerCamel protobuf field paths"))
.into()
}
}
#[cfg(feature = "openapi")]
impl utoipa::ToSchema for FieldMask {}
impl Serialize for FieldMask {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where

@ -1125,17 +1125,18 @@ mod tests {
#[test]
fn http_json_update_room_settings_request_accepts_structured_settings() {
let json = r#"{"allowGuestJoin":true,"maxMembers":8,"chatEnabled":true,"autoPlay":{"enabled":true,"mode":1,"delay":0}}"#;
let json = r#"{"settings":{"allowGuestJoin":true,"maxMembers":8,"chatEnabled":true,"autoPlay":{"enabled":true,"mode":1,"delay":0}},"updateMask":"allowGuestJoin,maxMembers,chatEnabled,autoPlay.enabled,autoPlay.mode,autoPlay.delay"}"#;
let decoded: crate::admin::UpdateRoomSettingsRequest =
serde_json::from_str(json).expect("HTTP JSON should deserialize into proto request");
assert_eq!(decoded.room_id, "");
assert_eq!(decoded.allow_guest_join, Some(true));
assert_eq!(decoded.max_members, Some(8));
assert_eq!(decoded.chat_enabled, Some(true));
let settings = decoded.settings.expect("settings");
assert_eq!(settings.allow_guest_join, Some(true));
assert_eq!(settings.max_members, Some(8));
assert_eq!(settings.chat_enabled, Some(true));
assert_eq!(
decoded.auto_play,
settings.auto_play,
Some(crate::client::AutoPlaySettingsPatch {
enabled: Some(true),
mode: Some(crate::client::PlayMode::Sequential as i32),
@ -1146,16 +1147,17 @@ mod tests {
#[test]
fn http_json_client_update_room_settings_request_accepts_structured_settings() {
let json = r#"{"allowGuestJoin":true,"maxMembers":8,"chatEnabled":true,"autoPlay":{"enabled":true,"mode":1,"delay":0}}"#;
let json = r#"{"settings":{"allowGuestJoin":true,"maxMembers":8,"chatEnabled":true,"autoPlay":{"enabled":true,"mode":1,"delay":0}},"updateMask":"allowGuestJoin,maxMembers,chatEnabled,autoPlay.enabled,autoPlay.mode,autoPlay.delay"}"#;
let decoded: crate::client::UpdateRoomSettingsRequest =
serde_json::from_str(json).expect("HTTP JSON should deserialize into proto request");
assert_eq!(decoded.allow_guest_join, Some(true));
assert_eq!(decoded.max_members, Some(8));
assert_eq!(decoded.chat_enabled, Some(true));
let settings = decoded.settings.expect("settings");
assert_eq!(settings.allow_guest_join, Some(true));
assert_eq!(settings.max_members, Some(8));
assert_eq!(settings.chat_enabled, Some(true));
assert_eq!(
decoded.auto_play,
settings.auto_play,
Some(crate::client::AutoPlaySettingsPatch {
enabled: Some(true),
mode: Some(crate::client::PlayMode::Sequential as i32),
@ -1166,7 +1168,7 @@ mod tests {
#[test]
fn http_json_client_update_room_settings_request_rejects_array_settings() {
let json = r#"{"autoPlay":[1,2,3]}"#;
let json = r#"{"settings":{"autoPlay":[1,2,3]},"updateMask":"autoPlay.mode"}"#;
serde_json::from_str::<crate::client::UpdateRoomSettingsRequest>(json)
.expect_err("nested room settings patch values must be structured objects");

@ -11,7 +11,7 @@ use synctv_proto::client::{
MoveMediaRequest, MovePlaylistRequest, OAuth2ProviderInstancePathRequest,
OAuth2ProviderTypePathRequest, PasskeyAuthenticatorAssertionResponse,
RejectRoomJoinReviewRequest, RoomJoinReviewPathRequest, RoomMediaTargetPathRequest,
RoomMemberTargetPathRequest, RoomPathRequest, RoomPlaylistTargetPathRequest,
RoomMemberTargetPathRequest, RoomPathRequest, RoomPlaylistTargetPathRequest, RoomSettingsPatch,
RoomStreamListSortBy, SortDirection, StartOpaqueLoginRequest, StartPlaybackRequest,
TransferRoomOwnershipRequest, UnlinkProviderRequest, UpdatePlaybackStateRequest,
UpdatePlaylistRequest, UpdateRoomSettingsRequest, UploadUserAvatarObjectRequest,
@ -148,14 +148,17 @@ fn test_protojson_custom_json_name_rejects_proto_field_name() {
#[test]
fn test_room_settings_patch_uses_lower_camel_case_fields() {
let patch: UpdateRoomSettingsRequest =
serde_json::from_str(r#"{"chatEnabled":false,"allowGuestJoin":true}"#)
serde_json::from_str(
r#"{"settings":{"chatEnabled":false,"allowGuestJoin":true},"updateMask":"chatEnabled,allowGuestJoin"}"#,
)
.expect("lowerCamelCase room settings patch should deserialize");
assert_eq!(patch.chat_enabled, Some(false));
assert_eq!(patch.allow_guest_join, Some(true));
let settings = patch.settings.expect("settings");
assert_eq!(settings.chat_enabled, Some(false));
assert_eq!(settings.allow_guest_join, Some(true));
let error = serde_json::from_str::<UpdateRoomSettingsRequest>(
r#"{"chat_enabled":false,"allow_guest_join":true}"#,
r#"{"settings":{"chat_enabled":false,"allow_guest_join":true},"updateMask":"chatEnabled,allowGuestJoin"}"#,
)
.expect_err("snake_case room settings patch fields should be rejected");
assert!(error.to_string().contains("chat_enabled"));
@ -172,10 +175,9 @@ fn test_get_server_time_request_uses_lower_camel_case_query_field() {
#[test]
fn test_room_settings_patch_rejects_duplicate_canonical_field() {
let error = serde_json::from_str::<UpdateRoomSettingsRequest>(
r#"{"chatEnabled":true,"chatEnabled":false}"#,
)
.expect_err("duplicate room settings field should be rejected");
let error =
serde_json::from_str::<RoomSettingsPatch>(r#"{"chatEnabled":true,"chatEnabled":false}"#)
.expect_err("duplicate room settings field should be rejected");
assert!(error.to_string().contains("duplicate field"));
}
@ -183,7 +185,7 @@ fn test_room_settings_patch_rejects_duplicate_canonical_field() {
#[test]
fn test_update_room_settings_rejects_duplicate_canonical_field() {
let error = serde_json::from_str::<UpdateRoomSettingsRequest>(
r#"{"chatEnabled":true,"chatEnabled":false}"#,
r#"{"settings":{"chatEnabled":true},"settings":{"chatEnabled":false},"updateMask":"chatEnabled"}"#,
)
.expect_err("duplicate room settings field should be rejected");

@ -10,7 +10,7 @@ pub struct RoomSettingsCommand {
pub enum RoomSettingsSubcommand {
/// Get room settings
Get(RoomSettingsGetArgs),
/// Patch room settings with a partial JSON object
/// Update room settings
Update(RoomSettingsUpdateArgs),
/// Reset room settings to defaults
Reset(RoomSettingsResetArgs),
@ -45,9 +45,21 @@ pub struct RoomSettingsUpdateArgs {
#[command(flatten)]
pub room: RoomSettingsScopeArgs,
/// Partial JSON object patch merged onto the current room settings before submission
#[arg(long)]
pub settings_json: String,
/// Set a room settings leaf using PATH=VALUE; may be repeated.
#[arg(long, value_name = "PATH=VALUE", conflicts_with = "request_json")]
pub set: Vec<String>,
/// Restore a room settings leaf to its server default; may be repeated.
#[arg(long, value_name = "PATH", conflicts_with = "request_json")]
pub unset: Vec<String>,
/// Admin UpdateRoomSettingsRequest encoded as ProtoJSON; roomId is taken from ROOM_ID.
#[arg(
long = "request-json",
value_name = "JSON",
conflicts_with_all = ["set", "unset"]
)]
pub request_json: Option<String>,
}
#[derive(Debug, Args)]

@ -12,7 +12,7 @@ pub enum SettingsSubcommand {
List(SettingsListArgs),
/// Get one effective settings section
Get(SettingsGetArgs),
/// Update runtime settings using an UpdateSettingsRequest ProtoJSON document
/// Update runtime settings
Update(SettingsUpdateArgs),
/// Send a test email using the current runtime email settings
TestEmail(SettingsTestEmailArgs),
@ -34,9 +34,21 @@ pub struct SettingsGetArgs {
#[derive(Debug, Args)]
pub struct SettingsUpdateArgs {
/// Set a settings leaf using PATH=VALUE; may be repeated.
#[arg(long, value_name = "PATH=VALUE", conflicts_with = "request_json")]
pub set: Vec<String>,
/// Unset a settings leaf; may be repeated.
#[arg(long, value_name = "PATH", conflicts_with = "request_json")]
pub unset: Vec<String>,
/// UpdateSettingsRequest encoded as ProtoJSON.
#[arg(long = "request-json", value_name = "JSON", required = true)]
pub request_json: String,
#[arg(
long = "request-json",
value_name = "JSON",
conflicts_with_all = ["set", "unset"]
)]
pub request_json: Option<String>,
#[command(flatten)]
pub remote: RemoteAccessArgs,

@ -1,3 +1,4 @@
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::time::Duration;
@ -744,21 +745,15 @@ fn parse_optional_room_settings_json(
) -> Result<Option<synctv_proto::client::RoomSettings>> {
normalized_optional_cli_value(raw)
.map(|raw| {
let patch: synctv_proto::client::UpdateRoomSettingsRequest =
let patch: synctv_proto::client::RoomSettingsPatch =
serde_json::from_str(&raw).context("invalid room settings patch JSON")?;
Ok(room_settings_patch_to_full_settings(patch))
})
.transpose()
}
fn parse_required_room_settings_json(
raw: &str,
) -> Result<synctv_proto::admin::UpdateRoomSettingsRequest> {
serde_json::from_str(raw).context("invalid room settings patch JSON")
}
pub(in crate::cli) fn room_settings_patch_to_full_settings(
patch: synctv_proto::client::UpdateRoomSettingsRequest,
patch: synctv_proto::client::RoomSettingsPatch,
) -> synctv_proto::client::RoomSettings {
let defaults = synctv_core::models::RoomSettings::default();
let default_auto_play = defaults.auto_play.value;
@ -924,6 +919,134 @@ where
serde_json::from_str(raw).with_context(|| format!("Invalid {label} JSON"))
}
pub(in crate::cli) fn parse_masked_settings_request<T>(
label: &str,
request_json: Option<&str>,
set: &[String],
unset: &[String],
) -> Result<T>
where
T: DeserializeOwned,
{
if let Some(raw) = request_json {
return parse_cli_json(label, raw);
}
if set.is_empty() && unset.is_empty() {
bail!("provide at least one --set or --unset, or use --request-json");
}
let mut settings = serde_json::Map::new();
let mut paths = Vec::with_capacity(set.len() + unset.len());
let mut seen = BTreeSet::new();
for assignment in set {
let (path, raw_value) = assignment
.split_once('=')
.ok_or_else(|| anyhow!("invalid --set '{assignment}'; expected PATH=VALUE"))?;
register_mask_path(path, &mut paths, &mut seen)?;
let mut value = serde_json::from_str(raw_value)
.unwrap_or_else(|_| serde_json::Value::String(raw_value.to_string()));
if let serde_json::Value::String(name) = &value {
if let Some(enum_value) = cli_settings_enum_value(path, name) {
value = serde_json::Value::Number(enum_value.into());
}
}
if value.is_null() {
bail!("--set '{path}' cannot use null; use --unset {path}");
}
insert_json_path(&mut settings, path, value)?;
}
for path in unset {
register_mask_path(path, &mut paths, &mut seen)?;
}
serde_json::from_value(serde_json::json!({
"settings": settings,
"updateMask": paths.join(","),
}))
.with_context(|| format!("Invalid {label} values"))
}
fn cli_settings_enum_value(path: &str, raw: &str) -> Option<i32> {
let value = raw.trim().to_ascii_uppercase().replace('-', "_");
match path {
"autoPlay.mode" => match value.as_str() {
"UNSPECIFIED" | "PLAY_MODE_UNSPECIFIED" => Some(0),
"SEQUENTIAL" | "PLAY_MODE_SEQUENTIAL" => Some(1),
"REPEAT_ONE" | "PLAY_MODE_REPEAT_ONE" => Some(2),
"REPEAT_ALL" | "PLAY_MODE_REPEAT_ALL" => Some(3),
"SHUFFLE" | "PLAY_MODE_SHUFFLE" => Some(4),
_ => None,
},
"roomCreation.passwordPolicy" => match value.as_str() {
"UNSPECIFIED" | "ROOM_PASSWORD_POLICY_UNSPECIFIED" => Some(0),
"OPTIONAL" | "ROOM_PASSWORD_POLICY_OPTIONAL" => Some(1),
"REQUIRED" | "ROOM_PASSWORD_POLICY_REQUIRED" => Some(2),
"FORBIDDEN" | "ROOM_PASSWORD_POLICY_FORBIDDEN" => Some(3),
_ => None,
},
_ => None,
}
}
fn register_mask_path<'a>(
path: &'a str,
paths: &mut Vec<&'a str>,
seen: &mut BTreeSet<&'a str>,
) -> Result<()> {
if path.is_empty() || path.split('.').any(str::is_empty) {
bail!("settings paths must contain non-empty dot-separated field names");
}
if !seen.insert(path) {
bail!("duplicate settings path '{path}'");
}
if let Some(conflict) = seen.iter().copied().find(|existing| {
let existing = *existing;
existing != path
&& (existing
.strip_prefix(path)
.is_some_and(|suffix| suffix.starts_with('.'))
|| path
.strip_prefix(existing)
.is_some_and(|suffix| suffix.starts_with('.')))
}) {
bail!("conflicting settings paths '{path}' and '{conflict}'");
}
paths.push(path);
Ok(())
}
fn insert_json_path(
object: &mut serde_json::Map<String, serde_json::Value>,
path: &str,
value: serde_json::Value,
) -> Result<()> {
let mut segments = path.split('.');
let first = segments
.next()
.ok_or_else(|| anyhow!("settings path is required"))?;
insert_json_segments(object, first, &mut segments, value, path)
}
fn insert_json_segments<'a>(
object: &mut serde_json::Map<String, serde_json::Value>,
segment: &str,
remaining: &mut impl Iterator<Item = &'a str>,
value: serde_json::Value,
path: &str,
) -> Result<()> {
let Some(next) = remaining.next() else {
object.insert(segment.to_string(), value);
return Ok(());
};
let nested = object
.entry(segment.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| anyhow!("conflicting settings path '{path}'"))?;
insert_json_segments(nested, next, remaining, value, path)
}
fn parse_cli_optional_json<T>(label: &str, raw: Option<&str>) -> Result<Option<T>>
where
T: DeserializeOwned,

@ -137,9 +137,15 @@ pub(super) async fn execute_room(room_command: RoomCommand) -> Result<()> {
args.remote.print_output(&response)
}
RoomSettingsSubcommand::Update(args) => {
let session = connect_remote_access(&args.remote).await?;
let mut request = parse_required_room_settings_json(&args.settings_json)?;
let mut request: synctv_proto::admin::UpdateRoomSettingsRequest =
parse_masked_settings_request(
"room settings update request",
args.request_json.as_deref(),
&args.set,
&args.unset,
)?;
request.room_id = args.room.resolved_room_id().to_string();
let session = connect_remote_access(&args.remote).await?;
let response = management_unary_call!(
session,
"update room settings",

@ -111,9 +111,14 @@ pub(super) async fn execute_settings(settings_command: SettingsCommand) -> Resul
args.remote.print_output(&section)
}
SettingsSubcommand::Update(args) => {
let session = connect_remote_access(&args.remote).await?;
let request: synctv_proto::admin::UpdateSettingsRequest =
parse_cli_json("settings update request", &args.request_json)?;
parse_masked_settings_request(
"settings update request",
args.request_json.as_deref(),
&args.set,
&args.unset,
)?;
let session = connect_remote_access(&args.remote).await?;
let response =
management_unary_call!(session, "update settings", update_settings, request)?;
args.remote.print_output(&response)

@ -22,7 +22,7 @@ pub(in crate::cli) use execute::{
apply_root_global_overrides, batch_user_refs_to_proto, build_get_playback_cli_output,
database_summary, format_management_status_error, management_stream_item,
management_unary_response_with_timeout, normalized_provider_types, parse_cli_json,
resolve_remote_endpoint, room_settings_patch_to_full_settings,
parse_masked_settings_request, resolve_remote_endpoint, room_settings_patch_to_full_settings,
stop_stream_disconnect_can_be_treated_as_success, stop_stream_end_can_be_treated_as_success,
switch_process_working_dir_to_data_dir, synthesize_stop_completion_if_needed,
DatabaseCliOutput, StopServerEventOutput, StopServerOutput,

@ -1039,7 +1039,7 @@ fn cli_parses_room_create_minimal() {
#[test]
fn room_create_settings_json_is_applied_as_patch_to_defaults() {
let patch: synctv_proto::client::UpdateRoomSettingsRequest =
let patch: synctv_proto::client::RoomSettingsPatch =
serde_json::from_str(r#"{"chatEnabled":false}"#)
.expect("room settings patch JSON should parse");
@ -1629,15 +1629,17 @@ fn cli_parses_room_settings_get() {
}
#[test]
fn cli_parses_room_settings_update_with_json_payload() {
fn cli_parses_room_settings_update_with_set_and_unset() {
let cli = Cli::parse_from([
"synctv",
"room",
"settings",
"update",
"room-123",
"--settings-json",
"{\"chatEnabled\":false}",
"--set",
"chatEnabled=false",
"--unset",
"autoPlay.mode",
]);
match cli.command {
Commands::Room(RoomCommand {
@ -1648,7 +1650,9 @@ fn cli_parses_room_settings_update_with_json_payload() {
..
}) => {
assert_eq!(args.room.resolved_room_id(), "room-123");
assert_eq!(args.settings_json, "{\"chatEnabled\":false}");
assert_eq!(args.set, ["chatEnabled=false"]);
assert_eq!(args.unset, ["autoPlay.mode"]);
assert_eq!(args.request_json, None);
}
other => panic!("unexpected command parsed: {other:?}"),
}
@ -4734,7 +4738,7 @@ fn cli_parses_settings_update_with_standard_proto_json_request() {
Commands::Settings(SettingsCommand {
command: SettingsSubcommand::Update(args),
..
}) => assert_eq!(args.request_json, request_json),
}) => assert_eq!(args.request_json.as_deref(), Some(request_json)),
other => panic!("unexpected command parsed: {other:?}"),
}
}
@ -4780,6 +4784,82 @@ fn settings_update_parser_accepts_field_mask_clear_request() {
);
}
#[test]
fn settings_update_set_and_unset_build_standard_proto_json_request() {
let request: synctv_proto::admin::UpdateSettingsRequest = parse_masked_settings_request(
"settings update request",
None,
&[
"email.enabled=true".to_string(),
"email.whitelistDomains=[\"example.com\"]".to_string(),
"roomCreation.passwordPolicy=required".to_string(),
],
&["email.smtpProxy".to_string()],
)
.expect("set and unset should build an update request");
let settings = request.settings.expect("settings");
let email = settings.email.expect("email");
assert_eq!(email.enabled, Some(true));
assert_eq!(email.whitelist_domains, ["example.com"]);
assert_eq!(email.smtp_proxy, None);
assert_eq!(
settings
.room_creation
.expect("room creation")
.password_policy,
Some(synctv_proto::admin::RoomPasswordPolicy::Required as i32)
);
assert_eq!(
request.update_mask.expect("update mask").paths,
[
"email.enabled",
"email.whitelist_domains",
"room_creation.password_policy",
"email.smtp_proxy"
]
);
}
#[test]
fn room_settings_update_set_and_unset_build_field_mask_request() {
let request: synctv_proto::admin::UpdateRoomSettingsRequest = parse_masked_settings_request(
"room settings update request",
None,
&[
"requireApproval=true".to_string(),
"autoPlay.mode=shuffle".to_string(),
],
&["autoPlay.delay".to_string()],
)
.expect("room set and unset should build an update request");
let settings = request.settings.expect("settings");
assert_eq!(settings.require_approval, Some(true));
assert_eq!(
settings.auto_play.expect("auto play").mode,
Some(synctv_proto::client::PlayMode::Shuffle as i32)
);
assert_eq!(
request.update_mask.expect("update mask").paths,
["require_approval", "auto_play.mode", "auto_play.delay"]
);
}
#[test]
fn settings_update_rejects_conflicting_input_modes() {
Cli::try_parse_from([
"synctv",
"settings",
"update",
"--set",
"email.enabled=true",
"--request-json",
r#"{"settings":{"email":{"enabled":true}},"updateMask":"email.enabled"}"#,
])
.expect_err("set and request-json must be mutually exclusive");
}
#[test]
fn cli_parses_system_stats() {
let cli = Cli::parse_from(["synctv", "system", "stats"]);

@ -919,18 +919,8 @@ impl AdminRuntime for ManagementAdminRuntime {
.update_room_settings(
admin_proto::UpdateRoomSettingsRequest {
room_id: command.room_id,
allow_guest_join: command.allow_guest_join,
max_members: command.max_members,
require_approval: command.require_approval,
allow_auto_join: command.allow_auto_join,
chat_enabled: command.chat_enabled,
auto_play: command.auto_play,
admin_added_permissions: command.admin_added_permissions,
admin_removed_permissions: command.admin_removed_permissions,
member_added_permissions: command.member_added_permissions,
member_removed_permissions: command.member_removed_permissions,
guest_added_permissions: command.guest_added_permissions,
guest_removed_permissions: command.guest_removed_permissions,
settings: Some(command.settings),
update_mask: Some(command.update_mask),
},
admin_user_id,
)

@ -281,15 +281,6 @@ fn room_settings_value(
serde_json::to_value(settings.expect(context)).expect("room settings should encode")
}
fn room_settings_patch_json(chat_enabled: bool, allow_guest_join: bool) -> String {
serde_json::to_string(&synctv_proto::client::UpdateRoomSettingsRequest {
chat_enabled: Some(chat_enabled),
allow_guest_join: Some(allow_guest_join),
..Default::default()
})
.expect("room settings patch should serialize")
}
fn create_ticket_request(room_id: &str) -> synctv_proto::client::CreateWebSocketTicketRequest {
synctv_proto::client::CreateWebSocketTicketRequest {
room_id: room_id.to_string(),
@ -2937,8 +2928,6 @@ async fn full_stack_cli_room_settings_commands_manage_room_settings_lifecycle()
initial_settings_version > 0,
"CLI room settings get should return a persisted initial version"
);
let updated_settings_json = room_settings_patch_json(false, true);
let settings_update = run_synctv_remote_cli(
&server,
&[
@ -2946,8 +2935,10 @@ async fn full_stack_cli_room_settings_commands_manage_room_settings_lifecycle()
"settings",
"update",
&room_id,
"--settings-json",
&updated_settings_json,
"--set",
"chatEnabled=false",
"--set",
"allowGuestJoin=true",
],
)
.await;
@ -3339,8 +3330,6 @@ async fn full_stack_cli_user_batch_and_settings_commands_cover_remaining_managem
.expect("room create should include id")
.to_string();
let full_room_settings_json = room_settings_patch_json(false, true);
let updated_room_settings = run_synctv_remote_cli_json(
&server,
&[
@ -3348,8 +3337,10 @@ async fn full_stack_cli_user_batch_and_settings_commands_cover_remaining_managem
"settings",
"update",
&room_id,
"--settings-json",
&full_room_settings_json,
"--set",
"chatEnabled=false",
"--set",
"allowGuestJoin=true",
],
"update room settings for reset coverage",
)
@ -7343,8 +7334,6 @@ async fn full_stack_grpc_message_stream_watch_room_settings_receives_initial_and
assert_eq!(settings.version, expected_initial_version);
let initial_version = settings.version;
let updated_settings_json = room_settings_patch_json(false, true);
let _ = run_synctv_remote_cli_json(
&server,
&[
@ -7352,8 +7341,10 @@ async fn full_stack_grpc_message_stream_watch_room_settings_receives_initial_and
"settings",
"update",
&room_id,
"--settings-json",
&updated_settings_json,
"--set",
"chatEnabled=false",
"--set",
"allowGuestJoin=true",
],
"update room settings for grpc watch",
)
@ -8079,7 +8070,6 @@ async fn full_stack_websocket_room_messages_cover_chat_playback_media_settings_a
)
.await;
let updated_settings_json = room_settings_patch_json(false, true);
let _ = run_synctv_remote_cli_json(
&server,
&[
@ -8087,8 +8077,10 @@ async fn full_stack_websocket_room_messages_cover_chat_playback_media_settings_a
"settings",
"update",
&room_id,
"--settings-json",
&updated_settings_json,
"--set",
"chatEnabled=false",
"--set",
"allowGuestJoin=true",
],
"update room settings for websocket test",
)
@ -8591,8 +8583,6 @@ async fn full_stack_websocket_watch_room_settings_receives_initial_and_future_up
assert_eq!(settings.version, expected_initial_version);
let initial_version = settings.version;
let updated_settings_json = room_settings_patch_json(false, true);
let _ = run_synctv_remote_cli_json(
&server,
&[
@ -8600,8 +8590,10 @@ async fn full_stack_websocket_watch_room_settings_receives_initial_and_future_up
"settings",
"update",
&room_id,
"--settings-json",
&updated_settings_json,
"--set",
"chatEnabled=false",
"--set",
"allowGuestJoin=true",
],
"update room settings for websocket watch",
)

Loading…
Cancel
Save