synchra.py 0.1.0__tar.gz
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.
- synchra_py-0.1.0/PKG-INFO +119 -0
- synchra_py-0.1.0/README.md +100 -0
- synchra_py-0.1.0/pyproject.toml +38 -0
- synchra_py-0.1.0/setup.cfg +4 -0
- synchra_py-0.1.0/synchra/__init__.py +5 -0
- synchra_py-0.1.0/synchra/api/__init__.py +1 -0
- synchra_py-0.1.0/synchra/api/base.py +6 -0
- synchra_py-0.1.0/synchra/api/channels.py +78 -0
- synchra_py-0.1.0/synchra/api/kick.py +26 -0
- synchra_py-0.1.0/synchra/api/twitch.py +52 -0
- synchra_py-0.1.0/synchra/api/youtube.py +48 -0
- synchra_py-0.1.0/synchra/client.py +47 -0
- synchra_py-0.1.0/synchra/http.py +141 -0
- synchra_py-0.1.0/synchra/models/__init__.py +1 -0
- synchra_py-0.1.0/synchra/models/resources.py +4698 -0
- synchra_py-0.1.0/synchra/ws.py +117 -0
- synchra_py-0.1.0/synchra.py.egg-info/PKG-INFO +119 -0
- synchra_py-0.1.0/synchra.py.egg-info/SOURCES.txt +22 -0
- synchra_py-0.1.0/synchra.py.egg-info/dependency_links.txt +1 -0
- synchra_py-0.1.0/synchra.py.egg-info/requires.txt +11 -0
- synchra_py-0.1.0/synchra.py.egg-info/top_level.txt +1 -0
- synchra_py-0.1.0/tests/test_api_channels.py +50 -0
- synchra_py-0.1.0/tests/test_http.py +32 -0
- synchra_py-0.1.0/tests/test_ws.py +48 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: synchra.py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A modern, async-first Python SDK for the Synchra API v2.0.
|
|
5
|
+
Author-email: Bluscream <blu.pypi@minopia.de>, "Antigravity.AI" <antigravity@google.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
10
|
+
Requires-Dist: pydantic>=2.5.0
|
|
11
|
+
Requires-Dist: websockets>=12.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
15
|
+
Requires-Dist: aioresponses; extra == "dev"
|
|
16
|
+
Requires-Dist: datamodel-code-generator; extra == "dev"
|
|
17
|
+
Requires-Dist: build; extra == "dev"
|
|
18
|
+
Requires-Dist: twine; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# Synchra Python SDK (v2.0)
|
|
21
|
+
|
|
22
|
+
A modern, async-first Python SDK for the [Synchra API](https://api.synchra.net), providing robust access to channel management, real-time events, and multi-platform moderation.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Async Native**: Built from the ground up for `asyncio` using `aiohttp`.
|
|
27
|
+
- **Full Type Safety**: Comprehensive [Pydantic v2](https://pydantic.dev) models for all API resources and WebSocket events.
|
|
28
|
+
- **WebSocket Client**: Real-time event subscription and dispatcher with automatic reconnection.
|
|
29
|
+
- **Provider Support**: Specialized API groups for **Twitch**, **YouTube**, and **Kick**.
|
|
30
|
+
- **OAuth2 Ready**: Support for both manual access tokens and full OAuth2 refresh flows.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install synchra
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
### HTTP API Client
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from synchra import SynchraClient
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
# Initialize client (with token or OAuth credentials)
|
|
48
|
+
client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
|
|
49
|
+
|
|
50
|
+
# List channels
|
|
51
|
+
channels = await client.channels.list()
|
|
52
|
+
for channel in channels:
|
|
53
|
+
print(f"Channel: {channel.display_name} ({channel.id})")
|
|
54
|
+
|
|
55
|
+
# Get channel providers
|
|
56
|
+
providers = await client.channels.list_providers(channels[0].id)
|
|
57
|
+
for p in providers:
|
|
58
|
+
print(f"Provider: {p.provider} ({p.provider_channel_name})")
|
|
59
|
+
|
|
60
|
+
await client.close()
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### WebSocket Real-time Events
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
from synchra import SynchraClient
|
|
70
|
+
|
|
71
|
+
async def main():
|
|
72
|
+
client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
|
|
73
|
+
|
|
74
|
+
# Register an event handler
|
|
75
|
+
@client.ws.on("activity")
|
|
76
|
+
async def on_activity(event):
|
|
77
|
+
data = event['data']
|
|
78
|
+
print(f"[{event['type']}] New {data['type']} from {data['viewer_display_name']}")
|
|
79
|
+
|
|
80
|
+
# Connect and subscribe
|
|
81
|
+
await client.connect()
|
|
82
|
+
await client.ws.subscribe("activity", channel_id="YOUR_CHANNEL_UUID")
|
|
83
|
+
|
|
84
|
+
# Keep the client running
|
|
85
|
+
try:
|
|
86
|
+
while True:
|
|
87
|
+
await asyncio.sleep(1)
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
await client.close()
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Development & Testing
|
|
95
|
+
|
|
96
|
+
The SDK includes a full test suite using `pytest`.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Install dev dependencies
|
|
100
|
+
pip install "synchra[dev]"
|
|
101
|
+
|
|
102
|
+
# Run tests
|
|
103
|
+
python -m pytest tests/
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## API Groups
|
|
107
|
+
|
|
108
|
+
- **`client.channels`**: Main channel operations and provider listings.
|
|
109
|
+
- **`client.twitch`**: Twitch-specific features (Ban, Unban, Raid, Shoutout, Emulation).
|
|
110
|
+
- **`client.youtube`**: YouTube broadcasts and moderation.
|
|
111
|
+
- **`client.kick`**: Kick-specific moderation tools.
|
|
112
|
+
|
|
113
|
+
## Documentation
|
|
114
|
+
|
|
115
|
+
For full API documentation and endpoint details, refer to the [Synchra OpenAPI Spec](https://api.synchra.net/api/2/docs).
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Synchra Python SDK (v2.0)
|
|
2
|
+
|
|
3
|
+
A modern, async-first Python SDK for the [Synchra API](https://api.synchra.net), providing robust access to channel management, real-time events, and multi-platform moderation.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Async Native**: Built from the ground up for `asyncio` using `aiohttp`.
|
|
8
|
+
- **Full Type Safety**: Comprehensive [Pydantic v2](https://pydantic.dev) models for all API resources and WebSocket events.
|
|
9
|
+
- **WebSocket Client**: Real-time event subscription and dispatcher with automatic reconnection.
|
|
10
|
+
- **Provider Support**: Specialized API groups for **Twitch**, **YouTube**, and **Kick**.
|
|
11
|
+
- **OAuth2 Ready**: Support for both manual access tokens and full OAuth2 refresh flows.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install synchra
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### HTTP API Client
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
from synchra import SynchraClient
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
# Initialize client (with token or OAuth credentials)
|
|
29
|
+
client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
|
|
30
|
+
|
|
31
|
+
# List channels
|
|
32
|
+
channels = await client.channels.list()
|
|
33
|
+
for channel in channels:
|
|
34
|
+
print(f"Channel: {channel.display_name} ({channel.id})")
|
|
35
|
+
|
|
36
|
+
# Get channel providers
|
|
37
|
+
providers = await client.channels.list_providers(channels[0].id)
|
|
38
|
+
for p in providers:
|
|
39
|
+
print(f"Provider: {p.provider} ({p.provider_channel_name})")
|
|
40
|
+
|
|
41
|
+
await client.close()
|
|
42
|
+
|
|
43
|
+
asyncio.run(main())
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### WebSocket Real-time Events
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
from synchra import SynchraClient
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
client = SynchraClient(access_token="YOUR_ACCESS_TOKEN")
|
|
54
|
+
|
|
55
|
+
# Register an event handler
|
|
56
|
+
@client.ws.on("activity")
|
|
57
|
+
async def on_activity(event):
|
|
58
|
+
data = event['data']
|
|
59
|
+
print(f"[{event['type']}] New {data['type']} from {data['viewer_display_name']}")
|
|
60
|
+
|
|
61
|
+
# Connect and subscribe
|
|
62
|
+
await client.connect()
|
|
63
|
+
await client.ws.subscribe("activity", channel_id="YOUR_CHANNEL_UUID")
|
|
64
|
+
|
|
65
|
+
# Keep the client running
|
|
66
|
+
try:
|
|
67
|
+
while True:
|
|
68
|
+
await asyncio.sleep(1)
|
|
69
|
+
except KeyboardInterrupt:
|
|
70
|
+
await client.close()
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Development & Testing
|
|
76
|
+
|
|
77
|
+
The SDK includes a full test suite using `pytest`.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Install dev dependencies
|
|
81
|
+
pip install "synchra[dev]"
|
|
82
|
+
|
|
83
|
+
# Run tests
|
|
84
|
+
python -m pytest tests/
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## API Groups
|
|
88
|
+
|
|
89
|
+
- **`client.channels`**: Main channel operations and provider listings.
|
|
90
|
+
- **`client.twitch`**: Twitch-specific features (Ban, Unban, Raid, Shoutout, Emulation).
|
|
91
|
+
- **`client.youtube`**: YouTube broadcasts and moderation.
|
|
92
|
+
- **`client.kick`**: Kick-specific moderation tools.
|
|
93
|
+
|
|
94
|
+
## Documentation
|
|
95
|
+
|
|
96
|
+
For full API documentation and endpoint details, refer to the [Synchra OpenAPI Spec](https://api.synchra.net/api/2/docs).
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "synchra.py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A modern, async-first Python SDK for the Synchra API v2.0."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Bluscream", email = "blu.pypi@minopia.de"},
|
|
13
|
+
{name = "Antigravity.AI", email = "antigravity@google.com" }
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.9"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"aiohttp>=3.9.0",
|
|
18
|
+
"pydantic>=2.5.0",
|
|
19
|
+
"websockets>=12.0"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest",
|
|
25
|
+
"pytest-asyncio",
|
|
26
|
+
"aioresponses",
|
|
27
|
+
"datamodel-code-generator",
|
|
28
|
+
"build",
|
|
29
|
+
"twine"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["synchra*"]
|
|
34
|
+
exclude = ["synchra_cli*", "tests*"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
asyncio_mode = "auto"
|
|
38
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import List, Optional, Any
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from .base import APIGroup
|
|
5
|
+
from ..models.resources import (
|
|
6
|
+
Channel,
|
|
7
|
+
ChannelProvider,
|
|
8
|
+
Activity,
|
|
9
|
+
PageCursorChannel,
|
|
10
|
+
PageCursorActivity
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
class ChannelsAPI(APIGroup):
|
|
14
|
+
"""API for managing Synchra channels and providers."""
|
|
15
|
+
|
|
16
|
+
async def list(
|
|
17
|
+
self,
|
|
18
|
+
name: Optional[str] = None,
|
|
19
|
+
channel_id: Optional[UUID] = None,
|
|
20
|
+
provider: Optional[str] = None,
|
|
21
|
+
provider_channel_name: Optional[str] = None,
|
|
22
|
+
cursor: Optional[str] = None,
|
|
23
|
+
per_page: int = 25
|
|
24
|
+
) -> List[Channel]:
|
|
25
|
+
"""List and filter channels accessible to the user."""
|
|
26
|
+
params = {
|
|
27
|
+
"name": name,
|
|
28
|
+
"channel_id": channel_id,
|
|
29
|
+
"provider": provider,
|
|
30
|
+
"provider_channel_name": provider_channel_name,
|
|
31
|
+
"cursor": cursor,
|
|
32
|
+
"per_page": per_page
|
|
33
|
+
}
|
|
34
|
+
# Filter out None values
|
|
35
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
36
|
+
|
|
37
|
+
response = await self._http.get("/api/2/channels", params=params, response_model=PageCursorChannel)
|
|
38
|
+
return response.records
|
|
39
|
+
|
|
40
|
+
async def create(self, display_name: str, show_on_landing_page: bool = True) -> Channel:
|
|
41
|
+
"""Create a new channel."""
|
|
42
|
+
data = {
|
|
43
|
+
"display_name": display_name,
|
|
44
|
+
"show_on_landing_page": show_on_landing_page
|
|
45
|
+
}
|
|
46
|
+
return await self._http.post("/api/2/channels", json=data, response_model=Channel)
|
|
47
|
+
|
|
48
|
+
async def get(self, channel_id: UUID) -> Channel:
|
|
49
|
+
"""Get details for a specific channel."""
|
|
50
|
+
return await self._http.get(f"/api/2/channels/{channel_id}", response_model=Channel)
|
|
51
|
+
|
|
52
|
+
async def delete(self, channel_id: UUID) -> None:
|
|
53
|
+
"""Delete a channel."""
|
|
54
|
+
await self._http.delete(f"/api/2/channels/{channel_id}")
|
|
55
|
+
|
|
56
|
+
async def list_providers(self, channel_id: UUID) -> List[ChannelProvider]:
|
|
57
|
+
"""List providers linked to a channel."""
|
|
58
|
+
# Note: If this endpoint uses PageCursor but there's no PageCursorChannelProvider,
|
|
59
|
+
# we might need to handle it as a raw dict or find the correct model.
|
|
60
|
+
# Based on the spec, it returns PageCursor with ChannelProvider records.
|
|
61
|
+
# For now, let's assume it might be a list or we need to wait for a specific model.
|
|
62
|
+
# If it's paginated, it will fail validation again if we use List[ChannelProvider].
|
|
63
|
+
# Let's try to get it as a raw dict if we're unsure, or use a generic approach.
|
|
64
|
+
data = await self._http.get(f"/api/2/channels/{channel_id}/providers")
|
|
65
|
+
if isinstance(data, dict) and "records" in data:
|
|
66
|
+
# We can manually validate the records if needed
|
|
67
|
+
return [ChannelProvider.model_validate(r) for r in data["records"]]
|
|
68
|
+
return await self._http.get(f"/api/2/channels/{channel_id}/providers", response_model=List[ChannelProvider])
|
|
69
|
+
|
|
70
|
+
async def list_activities(self, channel_id: UUID, limit: int = 100, offset: int = 0) -> List[Activity]:
|
|
71
|
+
"""List activities for a channel."""
|
|
72
|
+
params = {"limit": limit, "offset": offset}
|
|
73
|
+
response = await self._http.get(
|
|
74
|
+
f"/api/2/channels/{channel_id}/activities",
|
|
75
|
+
params=params,
|
|
76
|
+
response_model=PageCursorActivity
|
|
77
|
+
)
|
|
78
|
+
return response.records
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import List, Optional, Any
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from .base import APIGroup
|
|
5
|
+
|
|
6
|
+
class KickAPI(APIGroup):
|
|
7
|
+
"""API for Kick-specific features in Synchra."""
|
|
8
|
+
|
|
9
|
+
async def ban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None, reason: Optional[str] = None):
|
|
10
|
+
"""Ban a user from a Kick channel."""
|
|
11
|
+
data = {
|
|
12
|
+
"provider_viewer_id": provider_viewer_id,
|
|
13
|
+
"duration_seconds": duration,
|
|
14
|
+
"reason": reason
|
|
15
|
+
}
|
|
16
|
+
await self._http.post(
|
|
17
|
+
f"/api/2/channels/{channel_id}/kick/{provider_id}/ban",
|
|
18
|
+
json=data
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def unban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str):
|
|
22
|
+
"""Unban a user from a Kick channel."""
|
|
23
|
+
await self._http.delete(
|
|
24
|
+
f"/api/2/channels/{channel_id}/kick/{provider_id}/ban",
|
|
25
|
+
params={"provider_viewer_id": provider_viewer_id}
|
|
26
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import List, Optional, Any
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from .base import APIGroup
|
|
5
|
+
from ..models.resources import (
|
|
6
|
+
BodyEmulateChannelChatMessageApi2TwitchEventsubEmulateChannelChatMessagePost as EmulateChatData,
|
|
7
|
+
BodyBanUserApi2ChannelsChannelIdTwitchChannelProviderIdBanPost as BanUserBody
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
class TwitchAPI(APIGroup):
|
|
11
|
+
"""API for Twitch-specific features in Synchra."""
|
|
12
|
+
|
|
13
|
+
async def emulate_chat_message(self, data: EmulateChatData, channel_id: UUID) -> None:
|
|
14
|
+
"""Emulate a Twitch channel chat message for testing."""
|
|
15
|
+
await self._http.post(
|
|
16
|
+
"/api/2/twitch/eventsub/emulate-channel-chat-message",
|
|
17
|
+
json=data.model_dump(exclude_none=True),
|
|
18
|
+
params={"channel_id": str(channel_id)}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def ban_user(self, channel_id: UUID, channel_provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None, reason: Optional[str] = None):
|
|
22
|
+
"""Ban a user from a Twitch channel."""
|
|
23
|
+
data = BanUserBody(
|
|
24
|
+
provider_viewer_id=provider_viewer_id,
|
|
25
|
+
duration_seconds=duration,
|
|
26
|
+
reason=reason
|
|
27
|
+
)
|
|
28
|
+
await self._http.post(
|
|
29
|
+
f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/ban",
|
|
30
|
+
json=data.model_dump(exclude_none=True)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async def unban_user(self, channel_id: UUID, channel_provider_id: UUID, provider_viewer_id: str):
|
|
34
|
+
"""Unban a user from a Twitch channel."""
|
|
35
|
+
await self._http.delete(
|
|
36
|
+
f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/ban",
|
|
37
|
+
params={"provider_viewer_id": provider_viewer_id}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def raid(self, channel_id: UUID, channel_provider_id: UUID, to_provider_channel_id: str):
|
|
41
|
+
"""Start a raid on another Twitch channel."""
|
|
42
|
+
await self._http.post(
|
|
43
|
+
f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/raid",
|
|
44
|
+
json={"to_provider_channel_id": to_provider_channel_id}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
async def shoutout(self, channel_id: UUID, channel_provider_id: UUID, to_provider_channel_id: str):
|
|
48
|
+
"""Send a shoutout to another Twitch channel."""
|
|
49
|
+
await self._http.post(
|
|
50
|
+
f"/api/2/channels/{channel_id}/twitch/{channel_provider_id}/shoutout",
|
|
51
|
+
json={"to_provider_channel_id": to_provider_channel_id}
|
|
52
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from typing import List, Optional, Any
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from .base import APIGroup
|
|
5
|
+
# Note: Specific YouTube/Kick models might be deep in resources.py
|
|
6
|
+
# Using Any or dict for complex nested structures if model names are not obvious
|
|
7
|
+
|
|
8
|
+
class YouTubeAPI(APIGroup):
|
|
9
|
+
"""API for YouTube-specific features in Synchra."""
|
|
10
|
+
|
|
11
|
+
async def create_broadcast(self, channel_id: UUID, provider_id: UUID, snippet: dict, status: dict, content_details: Optional[dict] = None) -> Any:
|
|
12
|
+
"""Create a YouTube broadcast."""
|
|
13
|
+
data = {
|
|
14
|
+
"snippet": snippet,
|
|
15
|
+
"status": status,
|
|
16
|
+
}
|
|
17
|
+
if content_details:
|
|
18
|
+
data["contentDetails"] = content_details
|
|
19
|
+
|
|
20
|
+
return await self._http.post(
|
|
21
|
+
f"/api/2/channels/{channel_id}/providers/{provider_id}/youtube/broadcast",
|
|
22
|
+
json=data
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async def ban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None):
|
|
26
|
+
"""Ban a user from a YouTube channel."""
|
|
27
|
+
data = {"provider_viewer_id": provider_viewer_id}
|
|
28
|
+
if duration:
|
|
29
|
+
data["duration_seconds"] = duration
|
|
30
|
+
await self._http.post(
|
|
31
|
+
f"/api/2/channels/{channel_id}/youtube/{provider_id}/ban",
|
|
32
|
+
json=data
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
class KickAPI(APIGroup):
|
|
36
|
+
"""API for Kick-specific features in Synchra."""
|
|
37
|
+
|
|
38
|
+
async def ban_user(self, channel_id: UUID, provider_id: UUID, provider_viewer_id: str, duration: Optional[int] = None, reason: Optional[str] = None):
|
|
39
|
+
"""Ban a user from a Kick channel."""
|
|
40
|
+
data = {
|
|
41
|
+
"provider_viewer_id": provider_viewer_id,
|
|
42
|
+
"duration_seconds": duration,
|
|
43
|
+
"reason": reason
|
|
44
|
+
}
|
|
45
|
+
await self._http.post(
|
|
46
|
+
f"/api/2/channels/{channel_id}/kick/{provider_id}/ban",
|
|
47
|
+
json=data
|
|
48
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from .http import SynchraAuth, HTTPClient
|
|
6
|
+
from .ws import SynchraWSClient
|
|
7
|
+
from .api.channels import ChannelsAPI
|
|
8
|
+
from .api.twitch import TwitchAPI
|
|
9
|
+
from .api.youtube import YouTubeAPI
|
|
10
|
+
from .api.kick import KickAPI
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("synchra")
|
|
13
|
+
|
|
14
|
+
class SynchraClient:
|
|
15
|
+
"""The main client for the Synchra API."""
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
access_token: Optional[str] = None,
|
|
19
|
+
client_id: Optional[str] = None,
|
|
20
|
+
client_secret: Optional[str] = None,
|
|
21
|
+
refresh_token: Optional[str] = None,
|
|
22
|
+
base_url: str = "https://api.synchra.net",
|
|
23
|
+
ws_url: str = "wss://api.synchra.net/api/2/ws"
|
|
24
|
+
):
|
|
25
|
+
self.auth = SynchraAuth(
|
|
26
|
+
access_token=access_token,
|
|
27
|
+
client_id=client_id,
|
|
28
|
+
client_secret=client_secret,
|
|
29
|
+
refresh_token=refresh_token
|
|
30
|
+
)
|
|
31
|
+
self.http = HTTPClient(self.auth, base_url=base_url)
|
|
32
|
+
self.ws = SynchraWSClient(self.auth, base_url=ws_url)
|
|
33
|
+
|
|
34
|
+
# API Groups
|
|
35
|
+
self.channels = ChannelsAPI(self.http)
|
|
36
|
+
self.twitch = TwitchAPI(self.http)
|
|
37
|
+
self.youtube = YouTubeAPI(self.http)
|
|
38
|
+
self.kick = KickAPI(self.http)
|
|
39
|
+
|
|
40
|
+
async def connect(self):
|
|
41
|
+
"""Connect the WebSocket client."""
|
|
42
|
+
await self.ws.connect()
|
|
43
|
+
|
|
44
|
+
async def close(self):
|
|
45
|
+
"""Close both HTTP and WebSocket connections."""
|
|
46
|
+
await self.http.close()
|
|
47
|
+
await self.ws.close()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, Optional, Type, TypeVar, Union
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from pydantic import BaseModel, ValidationError, TypeAdapter
|
|
8
|
+
|
|
9
|
+
from .models.resources import Error as SynchraErrorModel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("synchra.http")
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound=BaseModel)
|
|
14
|
+
|
|
15
|
+
class SynchraError(Exception):
|
|
16
|
+
"""Base exception for Synchra SDK."""
|
|
17
|
+
def __init__(self, message: str, status: int = 0, data: Any = None):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
self.status = status
|
|
21
|
+
self.data = data
|
|
22
|
+
|
|
23
|
+
class SynchraAuth:
|
|
24
|
+
"""Handles authentication for Synchra API."""
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
access_token: Optional[str] = None,
|
|
28
|
+
client_id: Optional[str] = None,
|
|
29
|
+
client_secret: Optional[str] = None,
|
|
30
|
+
refresh_token: Optional[str] = None,
|
|
31
|
+
token_url: str = "https://api.synchra.net/api/2/auth/token" # Placeholder
|
|
32
|
+
):
|
|
33
|
+
self.access_token = access_token
|
|
34
|
+
self.client_id = client_id
|
|
35
|
+
self.client_secret = client_secret
|
|
36
|
+
self.refresh_token = refresh_token
|
|
37
|
+
self.token_url = token_url
|
|
38
|
+
self._lock = asyncio.Lock()
|
|
39
|
+
|
|
40
|
+
async def get_auth_header(self) -> Dict[str, str]:
|
|
41
|
+
"""Returns the Authorization header, refreshing if necessary."""
|
|
42
|
+
if not self.access_token and self.refresh_token:
|
|
43
|
+
async with self._lock:
|
|
44
|
+
await self.refresh()
|
|
45
|
+
|
|
46
|
+
if self.access_token:
|
|
47
|
+
return {"Authorization": f"Bearer {self.access_token}"}
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
async def refresh(self):
|
|
51
|
+
"""Refreshes the access token using the refresh token."""
|
|
52
|
+
if not self.refresh_token or not self.client_id or not self.client_secret:
|
|
53
|
+
raise SynchraError("Missing credentials for token refresh")
|
|
54
|
+
|
|
55
|
+
# Implementation of OAuth2 refresh flow
|
|
56
|
+
# This is a placeholder until the exact endpoint is confirmed
|
|
57
|
+
async with aiohttp.ClientSession() as session:
|
|
58
|
+
payload = {
|
|
59
|
+
"grant_type": "refresh_token",
|
|
60
|
+
"refresh_token": self.refresh_token,
|
|
61
|
+
"client_id": self.client_id,
|
|
62
|
+
"client_secret": self.client_secret,
|
|
63
|
+
}
|
|
64
|
+
async with session.post(self.token_url, data=payload) as resp:
|
|
65
|
+
if resp.status != 200:
|
|
66
|
+
text = await resp.text()
|
|
67
|
+
raise SynchraError(f"Failed to refresh token: {text}", status=resp.status)
|
|
68
|
+
|
|
69
|
+
data = await resp.json()
|
|
70
|
+
self.access_token = data.get("access_token")
|
|
71
|
+
self.refresh_token = data.get("refresh_token") or self.refresh_token
|
|
72
|
+
|
|
73
|
+
class HTTPClient:
|
|
74
|
+
"""Low-level HTTP client for Synchra API."""
|
|
75
|
+
def __init__(self, auth: SynchraAuth, base_url: str = "https://api.synchra.net"):
|
|
76
|
+
self.auth = auth
|
|
77
|
+
self.base_url = base_url.rstrip("/")
|
|
78
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
79
|
+
|
|
80
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
81
|
+
if self._session is None or self._session.closed:
|
|
82
|
+
self._session = aiohttp.ClientSession()
|
|
83
|
+
return self._session
|
|
84
|
+
|
|
85
|
+
async def close(self):
|
|
86
|
+
if self._session and not self._session.closed:
|
|
87
|
+
await self._session.close()
|
|
88
|
+
|
|
89
|
+
async def request(
|
|
90
|
+
self,
|
|
91
|
+
method: str,
|
|
92
|
+
path: str,
|
|
93
|
+
*,
|
|
94
|
+
response_model: Optional[Type[T]] = None,
|
|
95
|
+
params: Optional[Dict[str, Any]] = None,
|
|
96
|
+
json: Optional[Any] = None,
|
|
97
|
+
**kwargs
|
|
98
|
+
) -> Union[T, Any, None]:
|
|
99
|
+
session = await self._get_session()
|
|
100
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
101
|
+
headers = await self.auth.get_auth_header()
|
|
102
|
+
|
|
103
|
+
if "headers" in kwargs:
|
|
104
|
+
headers.update(kwargs.pop("headers"))
|
|
105
|
+
|
|
106
|
+
async with session.request(method, url, params=params, json=json, headers=headers, **kwargs) as resp:
|
|
107
|
+
if resp.status == 204:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
data = await resp.json()
|
|
111
|
+
|
|
112
|
+
if resp.status >= 400:
|
|
113
|
+
try:
|
|
114
|
+
error_data = SynchraErrorModel.model_validate(data)
|
|
115
|
+
raise SynchraError(f"{error_data.type}: {error_data.message}", status=resp.status, data=error_data)
|
|
116
|
+
except Exception:
|
|
117
|
+
raise SynchraError(f"HTTP {resp.status}: {data}", status=resp.status, data=data)
|
|
118
|
+
|
|
119
|
+
if response_model:
|
|
120
|
+
try:
|
|
121
|
+
return TypeAdapter(response_model).validate_python(data)
|
|
122
|
+
except ValidationError as e:
|
|
123
|
+
logger.error(f"Validation error for {url}: {e}")
|
|
124
|
+
raise SynchraError(f"Failed to validate response: {e}", status=resp.status, data=data)
|
|
125
|
+
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
async def get(self, path: str, **kwargs) -> Any:
|
|
129
|
+
return await self.request("GET", path, **kwargs)
|
|
130
|
+
|
|
131
|
+
async def post(self, path: str, **kwargs) -> Any:
|
|
132
|
+
return await self.request("POST", path, **kwargs)
|
|
133
|
+
|
|
134
|
+
async def put(self, path: str, **kwargs) -> Any:
|
|
135
|
+
return await self.request("PUT", path, **kwargs)
|
|
136
|
+
|
|
137
|
+
async def patch(self, path: str, **kwargs) -> Any:
|
|
138
|
+
return await self.request("PATCH", path, **kwargs)
|
|
139
|
+
|
|
140
|
+
async def delete(self, path: str, **kwargs) -> Any:
|
|
141
|
+
return await self.request("DELETE", path, **kwargs)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|