Python-3xui 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ import json
2
+ from datetime import datetime, UTC
3
+ from typing import Generic, Literal, List, Dict
4
+
5
+ from httpx import Response
6
+ from pydantic import ValidationError
7
+ from pydantic.main import ModelT
8
+
9
+ from . import models
10
+ from .api import XUIClient
11
+ from .models import Inbound, SingleInboundClient
12
+ from .util import JsonType
13
+
14
+
15
+ class BaseEndpoint(Generic[ModelT]):
16
+ """Base class for API endpoint handlers.
17
+
18
+ Provides common functionality for making API requests to the 3X-UI panel.
19
+
20
+ Attributes:
21
+ _url: The base URL path for this endpoint group.
22
+ client: Reference to the XUIClient instance.
23
+ """
24
+ _url: str
25
+
26
+ def __init__(self, client: "XUIClient") -> None:
27
+ self.client = client
28
+
29
+ async def _simple_get(self, caller_endpoint: str) -> JsonType:
30
+ """Perform a simple GET request and return the response object.
31
+
32
+ Args:
33
+ caller_endpoint: The endpoint path to request. If it doesn't start
34
+ with the base URL, the base URL will be prepended.
35
+
36
+ Returns:
37
+ The 'obj' field from the JSON response.
38
+
39
+ Raises:
40
+ RuntimeError: If the response status code is not 200.
41
+ """
42
+ endpoint_url: str = caller_endpoint
43
+ if self._url not in caller_endpoint:
44
+ endpoint_url = f"{self._url}{caller_endpoint}"
45
+ resp = await self.client.safe_get(endpoint_url)
46
+ if resp.status_code == 200:
47
+ resp_json = resp.json()
48
+ return resp_json["obj"]
49
+ else:
50
+ raise RuntimeError(f"Error: wrong status code {resp.status_code}")
51
+
52
+
53
+ class Server(BaseEndpoint):
54
+ """Handler for server-related API endpoints.
55
+
56
+ Provides methods for generating cryptographic keys and UUIDs.
57
+
58
+ Endpoints:
59
+ - /panel/api/server/getNewUUID
60
+ - /panel/api/server/getNewX25519Cert
61
+ - /panel/api/server/getNewmldsa65
62
+ - /panel/api/server/getNewmlkem768x
63
+ """
64
+ _url = "panel/api/server"
65
+
66
+ async def new_uuid(self) -> str:
67
+ """Generate a new UUID from the server.
68
+
69
+ Returns:
70
+ A new UUID string.
71
+ """
72
+ endpoint = "/getNewUUID"
73
+ resp_json = await self._simple_get(endpoint)
74
+ return resp_json["uuid"]
75
+
76
+ async def new_x25519(self) -> dict[Literal["privateKey", "publicKey"], str]:
77
+ """Generate a new X25519 key pair.
78
+
79
+ Returns:
80
+ A dictionary containing 'privateKey' and 'publicKey' strings.
81
+ """
82
+ endpoint = "/getNewX25519Cert"
83
+ resp_json = await self._simple_get(endpoint)
84
+ return resp_json
85
+
86
+ async def new_mldsa65(self) -> dict[Literal["verify", "seed"], str]:
87
+ """Generate a new ML-DSA-65 post-quantum key pair.
88
+
89
+ ML-DSA-65 is a post-quantum signature algorithm.
90
+
91
+ Returns:
92
+ A dictionary containing 'verify' (public key) and 'seed' values.
93
+ """
94
+ endpoint = "/getNewmldsa65"
95
+ resp_json = await self._simple_get(endpoint)
96
+ return resp_json
97
+
98
+ async def new_mlkem768(self) -> dict[Literal["client", "seed"], str]:
99
+ """Generate a new ML-KEM-768 post-quantum key pair.
100
+
101
+ ML-KEM-768 is a post-quantum key encapsulation mechanism.
102
+
103
+ Returns:
104
+ A dictionary containing 'client' and 'seed' values.
105
+ """
106
+ endpoint = "/getNewmlkem768x"
107
+ resp_json = await self._simple_get(endpoint)
108
+ return resp_json
109
+
110
+
111
+ class Inbounds(BaseEndpoint):
112
+ """Handler for inbound-related API endpoints.
113
+
114
+ Provides methods for retrieving inbound configurations.
115
+
116
+ Endpoints:
117
+ - /panel/api/inbounds/list
118
+ - /panel/api/inbounds/get/{id}
119
+ """
120
+ _url = "panel/api/inbounds"
121
+
122
+ async def get_all(self) -> List[Inbound]:
123
+ """Retrieve all inbounds from the server.
124
+
125
+ Returns:
126
+ A list of Inbound model instances.
127
+ """
128
+ endpoint = "/list"
129
+ json = await self._simple_get(f"{endpoint}")
130
+ inbounds = Inbound.from_list(json, client=self.client)
131
+ return inbounds
132
+
133
+ async def get_specific_inbound(self, id) -> Inbound:
134
+ """Retrieve a specific inbound by ID.
135
+
136
+ Args:
137
+ id: The ID of the inbound to retrieve.
138
+
139
+ Returns:
140
+ An Inbound model instance for the specified ID.
141
+ """
142
+ endpoint = f"/get/{id}"
143
+ json = await self._simple_get(f"{endpoint}")
144
+ inbound = Inbound(client=self.client, **json)
145
+ return inbound
146
+
147
+
148
+ class Clients(BaseEndpoint):
149
+ """Handler for client-related API endpoints.
150
+
151
+ Provides methods for retrieving, adding, updating, and deleting clients.
152
+
153
+ Endpoints:
154
+ - /panel/api/inbounds/getClientTraffics/{email}
155
+ - /panel/api/inbounds/getClientTrafficsById/{uuid}
156
+ - /panel/api/inbounds/addClient
157
+ - /panel/api/inbounds/updateClient/{uuid}
158
+ - /panel/api/inbounds/delDepletedClients/{inbound_id}
159
+ - /panel/api/inbounds/{inbound_id}/delClient/{email|uuid}
160
+ """
161
+ _url = "panel/api/inbounds/"
162
+
163
+ #although it's the same url, they should be differentiated
164
+
165
+ async def get_client_with_email(self, email: str) -> models.ClientStats:
166
+ """Retrieve client statistics by email.
167
+
168
+ Args:
169
+ email: The client's email identifier.
170
+
171
+ Returns:
172
+ A ClientStats model instance with the client's statistics.
173
+ """
174
+ endpoint = f"getClientTraffics/{email}"
175
+ resp = await self._simple_get(endpoint)
176
+ return models.ClientStats.model_validate(resp)
177
+
178
+ async def get_client_with_uuid(self, uuid: str) -> List[models.ClientStats]:
179
+ """Retrieve client statistics by UUID.
180
+
181
+ Args:
182
+ uuid: The client's unique identifier.
183
+
184
+ Returns:
185
+ A list of ClientStats model instances matching the UUID.
186
+ """
187
+ endpoint = f"getClientTrafficsById/{uuid}"
188
+ resp = await self._simple_get(endpoint)
189
+ client_stats = models.ClientStats.from_list(resp, client=self.client)
190
+ return client_stats
191
+
192
+
193
+ async def add_client(self, client: models.InboundClients | models.SingleInboundClient | Dict,
194
+ inbound_id: int | None = None) -> Response:
195
+ """Add a new client to an inbound.
196
+
197
+ Args:
198
+ client: The client to add. Can be:
199
+ - A dict (will be parsed as JSON)
200
+ - A SingleInboundClient (requires inbound_id)
201
+ - An InboundClients object
202
+ inbound_id: The ID of the inbound to add the client to.
203
+ Required if client is a SingleInboundClient.
204
+
205
+ Returns:
206
+ The HTTP response from the API.
207
+
208
+ Raises:
209
+ ValueError: If a single client is provided without an inbound_id.
210
+ TypeError: If the client type is not supported.
211
+ """
212
+ endpoint = f"addClient"
213
+ if isinstance(client, Dict):
214
+ try:
215
+ client = str(client)
216
+ final = models.InboundClients.model_validate_json(client)
217
+ except ValidationError:
218
+ # if there is in fact an error, I want it to raise
219
+ tmp = models.SingleInboundClient.model_validate_json(client)
220
+ if inbound_id:
221
+ final = models.InboundClients(id=inbound_id,
222
+ settings=models.InboundClients.Settings(clients=[tmp]))
223
+ else:
224
+ raise ValueError("A single client was provided to be added but no parent inbound id")
225
+ elif isinstance(client, models.SingleInboundClient):
226
+ final = models.InboundClients(id=inbound_id,
227
+ settings=models.InboundClients.Settings(clients=[client]))
228
+ elif isinstance(client, models.InboundClients):
229
+ final = client
230
+ if inbound_id:
231
+ final.parent_id = inbound_id
232
+ else:
233
+ raise TypeError
234
+ # send request
235
+
236
+
237
+ data = final.model_dump(by_alias=True)
238
+
239
+
240
+
241
+ resp = await self.client.safe_post(f"{self._url}{endpoint}", data=data)
242
+
243
+ #YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU FUCKING DUMBASS!
244
+
245
+
246
+ return resp
247
+
248
+ async def _request_update_client(self, client: models.InboundClients | models.SingleInboundClient,
249
+ inbound_id: int | None = None,
250
+ *, original_uuid: str | None = None) -> Response:
251
+ """Request to update an existing client.
252
+
253
+ Args:
254
+ client: The client data to update. Can be:
255
+ - A SingleInboundClient (requires inbound_id)
256
+ - An InboundClients object (with one client)
257
+ inbound_id: The ID of the inbound the client belongs to.
258
+ Required if client is a SingleInboundClient.
259
+ original_uuid: The original UUID of the client to update.
260
+ Required if client is a SingleInboundClient.
261
+
262
+ Returns:
263
+ The HTTP response from the API.
264
+ """
265
+ if isinstance(client, models.SingleInboundClient):
266
+ if inbound_id is None:
267
+ raise ValueError("Provide a parent inbound ID or pass models.InboundClients")
268
+ client = models.InboundClients(parent_id=inbound_id,
269
+ settings=models.InboundClients.Settings(clients=[client]))
270
+ else:
271
+ if len(client.settings.clients) != 1:
272
+ raise ValueError(f"You can only update 1 client at a time, instead got {len(client.settings.clients)}")
273
+
274
+ _endpoint = f"updateClient/{original_uuid if original_uuid else client.settings.clients[0].uuid}"
275
+ resp = await self.client.safe_post(f"{self._url}{_endpoint}", json=client.model_dump_json())
276
+
277
+ return resp
278
+
279
+ async def update_single_client(self, existing_client: SingleInboundClient, inbound_id: int, /, *,
280
+ security: str | None = None,
281
+ password: str | None = None,
282
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
283
+ email: str | None = None,
284
+ limit_ip: int | None = None,
285
+ limit_gb: int | None = None,
286
+ expiry_time: models.timestamp | None = None,
287
+ enable: bool | None = None,
288
+ sub_id: str | None = None,
289
+ comment: str | None = None,
290
+ ):
291
+ """Update an existing client's details.
292
+
293
+ Args:
294
+ existing_client: The existing client object to update.
295
+ inbound_id: The ID of the inbound the client belongs to.
296
+ security: New security settings (optional).
297
+ password: New password (optional).
298
+ flow: New flow settings (optional).
299
+ email: New email address (optional).
300
+ limit_ip: New IP limit (optional).
301
+ limit_gb: New GB limit (optional).
302
+ expiry_time: New expiry time (optional).
303
+ enable: New enable status (optional).
304
+ sub_id: New subscription ID (optional).
305
+ comment: New comment (optional).
306
+
307
+ Returns:
308
+ The HTTP response from the API.
309
+ """
310
+ # Collect only the arguments that were explicitly provided (not None)
311
+ changes = {k: v for k, v in locals().items()
312
+ if k != 'self' and k != 'existing_client' and v is not None}
313
+ # Rename sub_id to subscription_id if needed
314
+ if 'sub_id' in changes:
315
+ changes['subscription_id'] = changes.pop('sub_id')
316
+ changes["updated_at"] = int(datetime.now(UTC).timestamp())
317
+ updated = existing_client.model_copy(update=changes)
318
+
319
+ resp = await self._request_update_client(updated, inbound_id)
320
+ return resp
321
+
322
+ async def delete_expired_clients(self, inbound_id: int) -> Response:
323
+ """Delete expired clients from an inbound.
324
+
325
+ Args:
326
+ inbound_id: The ID of the inbound to delete expired clients from.
327
+
328
+ Returns:
329
+ The HTTP response from the API.
330
+ """
331
+ _endpoint = f"delDepletedClients/"
332
+ resp = await self.client.safe_post(f"{self._url}{_endpoint}{inbound_id}")
333
+ return resp
334
+
335
+ async def delete_client_by_email(self, email: str, inbound_id: int) -> Response:
336
+ """Delete a client by email.
337
+
338
+ Args:
339
+ email: The email of the client to delete.
340
+ inbound_id: The ID of the inbound the client belongs to.
341
+
342
+ Returns:
343
+ The HTTP response from the API.
344
+ """
345
+ _endpoint = f"{inbound_id}/delClient/{email}"
346
+ resp = await self.client.safe_post(f"{self._url}{_endpoint}")
347
+ return resp
348
+
349
+ async def delete_client_by_uuid(self, uuid: str, inbound_id: int) -> Response:
350
+ """Delete a client by UUID.
351
+
352
+ Args:
353
+ uuid: The UUID of the client to delete.
354
+ inbound_id: The ID of the inbound the client belongs to.
355
+
356
+ Returns:
357
+ The HTTP response from the API.
358
+ """
359
+ _endpoint = f"{inbound_id}/delClient/{uuid}"
360
+ resp = await self.client.safe_post(f"{self._url}{_endpoint}")
361
+ return resp
python_3xui/models.py ADDED
@@ -0,0 +1,277 @@
1
+ import json
2
+ from types import NoneType
3
+ from datetime import datetime, UTC
4
+ from typing import Union, Optional, TypeAlias, Any, Annotated, Literal, List, Dict
5
+
6
+ from pydantic import field_validator, Field, field_serializer
7
+ import pydantic
8
+
9
+ from . import base_model
10
+ from .util import JsonType
11
+
12
+ timestamp: TypeAlias = int
13
+ ip_address: TypeAlias = str
14
+ json_string: TypeAlias = str
15
+
16
+ def exclude_if_none(field) -> bool:
17
+ """Check if a field value is None for exclusion purposes.
18
+
19
+ Args:
20
+ field: The field value to check.
21
+
22
+ Returns:
23
+ True if the field is None, False otherwise.
24
+ """
25
+ if field is None:
26
+ return True
27
+ return False
28
+
29
+ class SingleInboundClient(pydantic.BaseModel):
30
+ """Represents a single client within a VLESS/VMess inbound.
31
+
32
+ This model represents an individual VPN client with all its configuration
33
+ settings including traffic limits, expiry, and authentication details.
34
+
35
+ Attributes:
36
+ uuid: The unique identifier for the client (aliased as 'id' in API).
37
+ security: The security protocol used by the client.
38
+ password: The client's password for authentication.
39
+ flow: The VLESS flow type controlling connection behavior.
40
+ email: The client's email identifier.
41
+ limit_ip: Maximum number of simultaneous IP connections.
42
+ limit_gb: Total data limit in gigabytes.
43
+ expiry_time: Client expiry time as UNIX timestamp (0 = no expiry).
44
+ enable: Whether the client is enabled.
45
+ tg_id: Associated Telegram ID for notifications.
46
+ subscription_id: Subscription identifier for URL generation.
47
+ comment: Admin notes or comments for the client.
48
+ created_at: Timestamp of client creation.
49
+ updated_at: Timestamp of last client update.
50
+ """
51
+ uuid: Annotated[str, Field(alias="id")] #yes they really did that...
52
+ security: str = ""
53
+ password: str = ""
54
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
55
+ email: Annotated[str, Field(alias="email")]
56
+ limit_ip: Annotated[int, Field(alias="limitIp")] = 20
57
+ limit_gb: Annotated[int, Field(alias="totalGB")] # total flow
58
+ expiry_time: Annotated[timestamp, Field(alias="expiryTime")] = 0
59
+ enable: bool = True
60
+ tg_id: Annotated[Union[int, str], Field(alias="tgId")] = ""
61
+ subscription_id: Annotated[str, Field(alias="subId")]
62
+ comment: str = ""
63
+ created_at: Annotated[timestamp, Field(default_factory=(lambda: int(datetime.now(UTC).timestamp())))]
64
+ updated_at: Annotated[timestamp, Field(default_factory=(lambda: int(datetime.now(UTC).timestamp())))]
65
+
66
+ class InboundClients(pydantic.BaseModel):
67
+ """Represents a collection of clients for an inbound connection.
68
+
69
+ This model is used when adding or updating clients on an inbound,
70
+ containing the parent inbound ID and the list of clients.
71
+
72
+ Attributes:
73
+ parent_id: The ID of the parent inbound (aliased as 'id').
74
+ settings: The settings object containing the client list.
75
+ """
76
+
77
+ class Settings(pydantic.BaseModel):
78
+ """Settings container for inbound clients.
79
+
80
+ Attributes:
81
+ clients: List of SingleInboundClient objects.
82
+ """
83
+ clients: list[SingleInboundClient]
84
+
85
+ parent_id: Annotated[int|None, Field(exclude_if=exclude_if_none, alias="id")] = None
86
+ settings: Settings
87
+
88
+ @field_serializer("settings")
89
+ def stringify_settings(self, value: Settings) -> str:
90
+ """Serialize the settings object to a JSON string.
91
+
92
+ The 3X-UI API expects settings as a JSON string, not an object.
93
+
94
+ Args:
95
+ value: The Settings object to serialize.
96
+
97
+ Returns:
98
+ A JSON string representation of the settings.
99
+ """
100
+ return json.dumps(value.model_dump(by_alias=True), ensure_ascii=False)
101
+
102
+
103
+ #class InboundSettings(base_model.BaseModel):
104
+ # clients: list[Clients]
105
+ # decryption: str
106
+ # encryption: str
107
+ # selectedAuth: Annotated[Union[str|None], Field(exclude_if=exclude_if_none)] = None # "X25519, not Post-Quantum"
108
+ #
109
+ # "StreamSettings Stuff"
110
+ #
111
+ # class ExternalProxy(base_model.BaseModel):
112
+ # force_tls: Annotated[str, Field(alias="ForceTls")]
113
+ # dest: str
114
+ # port: int
115
+ # remark: str
116
+ #
117
+ # class StreamRequest(base_model.BaseModel):
118
+ # version: str
119
+ # method: str
120
+ # path: List[str]
121
+ # headers: dict[str, str|int]
122
+ #
123
+ # class StreamResponse(base_model.BaseModel):
124
+ # version: str
125
+ # status: int
126
+ # reason: str
127
+ # headers: dict[str, str|int]
128
+ #
129
+ # class TCPSettingsHeader(base_model.BaseModel):
130
+ # type: str
131
+ # request: Annotated[Optional[StreamRequest], Field(exclude_if=exclude_if_none)] = None
132
+ # response: Annotated[Optional[StreamRequest], Field(exclude_if=exclude_if_none)] = None
133
+ #
134
+ # class TCPSettings(base_model.BaseModel):
135
+ # accept_proxy_protocol: Annotated[bool, Field(alias="acceptProxyProtocol")]
136
+ #
137
+ # class SockOpt(base_model.BaseModel):
138
+ # acceptProxyProtocol: bool
139
+ # tcpFastOpen: bool
140
+ # mark: int
141
+ # tproxy: str
142
+ # tcpMptcp: bool
143
+ # penetrate: bool
144
+ # domainStrategy: str
145
+ # tcpMaxSeg: int
146
+ # dialerProxy: str
147
+ # tcpKeepAliveInterval: int
148
+ # tcpKeepAliveIdle: int
149
+ # tcpUserTimeout: int
150
+ # tcpcongestion: str
151
+ # V6Only: bool
152
+ # tcpWindowClamp: int
153
+ # interface: str
154
+ #
155
+ # class StreamSettings(base_model.BaseModel):
156
+ # network_type: Annotated[str, Field(alias="network")]
157
+ # security: str # none, reality, TLS
158
+ # external_proxy: Annotated[list[ExternalProxy], Field(alias="externalProxy")]
159
+ # tcp_settings: TCPSettings
160
+
161
+ class ClientStats(base_model.BaseModel):
162
+ """Statistics and configuration for a VPN client.
163
+
164
+ This model represents client statistics returned by the 3X-UI API,
165
+ including traffic usage, expiry information, and connection details.
166
+
167
+ Attributes:
168
+ id: Internal database ID of the client record.
169
+ inboundId: The ID of the inbound this client belongs to.
170
+ enable: Whether the client is currently enabled.
171
+ email: The client's email identifier.
172
+ uuid: The client's unique identifier.
173
+ subId: The subscription ID for URL generation.
174
+ up: Total uploaded bytes.
175
+ down: Total downloaded bytes.
176
+ allTime: Total bytes transferred (up + down).
177
+ expiryTime: Client expiry time as UNIX timestamp.
178
+ total: Total data limit in bytes.
179
+ reset: Counter for traffic resets.
180
+ lastOnline: UNIX timestamp of last connection.
181
+ """
182
+ id: int
183
+ inboundId: int
184
+ enable: bool
185
+ email: str
186
+ uuid: str
187
+ subId: str
188
+ up: int # bytes
189
+ down: int # bytes
190
+ allTime: int # bytes
191
+ expiryTime: timestamp # UNIX timestamp
192
+ total: int
193
+ reset: int
194
+ lastOnline: timestamp
195
+
196
+
197
+ class Inbound(base_model.BaseModel):
198
+ """Represents a VPN inbound connection configuration.
199
+
200
+ An inbound defines how VPN clients connect to the server, including
201
+ the protocol, port, traffic statistics, and client list.
202
+
203
+ Attributes:
204
+ id: The unique identifier for this inbound.
205
+ up: Total uploaded bytes through this inbound.
206
+ down: Total downloaded bytes through this inbound.
207
+ total: Total data limit in bytes.
208
+ allTime: Total bytes transferred (up + down).
209
+ remark: Human-readable name/description for the inbound.
210
+ enable: Whether the inbound is currently enabled.
211
+ expiryTime: Inbound expiry time as UNIX timestamp.
212
+ trafficReset: Traffic reset schedule ("Never", "Weekly", "Monthly", "Daily").
213
+ lastTrafficResetTime: UNIX timestamp of last traffic reset.
214
+ clientStats: List of client statistics, or None if no clients.
215
+ listen: The IP address the inbound listens on.
216
+ port: The port number the inbound listens on.
217
+ protocol: The VPN protocol (vless, vmess, trojan, shadowsocks, wireguard).
218
+ settings: JSON configuration for the inbound (auto-parsed from string).
219
+ streamSettings: JSON stream configuration (auto-parsed from string).
220
+ tag: Internal tag identifier for routing.
221
+ sniffing: JSON sniffing configuration (auto-parsed from string).
222
+ """
223
+ id: int
224
+ up: int # bytes
225
+ down: int # bytes
226
+ total: int # bytes
227
+ allTime: int # bytes
228
+ remark: str
229
+ enable: bool
230
+ expiryTime: timestamp # UNIX timestamp
231
+ trafficReset: str # "Never", "Weekly", "Monthly", "Daily"
232
+ lastTrafficResetTime: timestamp # UNIX timestamp
233
+ clientStats: Union[list[ClientStats], None]
234
+ listen: str
235
+ port: int
236
+ protocol: Literal["vless", "vmess", "trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
237
+ settings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
238
+ streamSettings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
239
+ tag: str
240
+ sniffing: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
241
+
242
+ # noinspection PyNestedDecorators
243
+ @field_validator('settings', 'streamSettings', 'sniffing', mode='after')
244
+ @classmethod
245
+ def parse_json_fields(cls, value: str) -> JsonType|Literal[""]:
246
+ """Parse JSON string fields into dictionaries.
247
+
248
+ The 3X-UI API returns settings, streamSettings, and sniffing as
249
+ JSON strings. This validator automatically parses them into dicts.
250
+
251
+ Args:
252
+ value: The JSON string to parse, or empty string.
253
+
254
+ Returns:
255
+ Parsed dictionary, or empty string if input was empty.
256
+ """
257
+ if value == "":
258
+ return ""
259
+ return json.loads(value)
260
+
261
+ # noinspection PyNestedDecorators
262
+ @field_serializer("settings", "streamSettings", "sniffing")
263
+ @classmethod
264
+ def stringify_json_fields(cls, value: Dict|Literal[""]) -> str:
265
+ """Serialize dictionary fields back to JSON strings.
266
+
267
+ When sending data back to the API, these fields must be JSON strings.
268
+
269
+ Args:
270
+ value: The dictionary to serialize, or empty string.
271
+
272
+ Returns:
273
+ JSON string representation, or empty string if input was empty.
274
+ """
275
+ if value == "":
276
+ return ""
277
+ return json.dumps(value, ensure_ascii=False)