This commit is contained in:
Speng Reb 2026-06-01 14:46:42 +02:00
parent 7f7c10d02c
commit f435d2630e
5 changed files with 71 additions and 7 deletions

View file

@ -182,11 +182,17 @@ Show endpoints manage scheduled playlist runs. They support bot Bearer auth and
```python
shows = bot.list_shows() # rank >= MOD
public_shows = bot.list_public_shows() # read-only public schedule
resolved = bot.resolve_show_media([ # rank >= MOD, max 50 entries
{"type": "yt", "id": "dQw4w9WgXcQ"}
])
show = bot.get_show(show_id) # rank >= MOD
payload = {
"name": "Friday Prime",
"notes": "Opening block",
"color": "#337AB7",
"scheduled_for": "2026-05-22T19:00:00.000Z",
"estimated_end_at": "2026-05-22T21:00:00.000Z",
"timezone": "America/New_York",
"recurrence": "weekly",
"fill_mode": "replace",
@ -196,8 +202,6 @@ payload = {
{"type": "yt", "id": "dQw4w9WgXcQ", "pos": "end"}
],
"status": "scheduled",
"notes": "<p>Special guest this week.</p>",
"color": "#22AAEE",
}
created = bot.create_show(payload) # rank >= MOD
@ -212,13 +216,16 @@ bot.show_action(show_id, "cancel") # rank >= ADMIN
```
Create/update payload constraints:
- `name`: required, 1-100 chars
- `scheduled_for`: required date string or unix timestamp (ms)
- `estimated_end_at`: optional date string/timestamp; must be later than `scheduled_for` when present
- `timezone`: required non-empty IANA time zone region string from the IANA Time Zone Database (for example `Europe/Berlin`, `America/New_York`)
- `recurrence`: `none` | `daily` | `weekly`
- `fill_mode`: `append` | `replace`
- `conflict_mode`: `force` | `skip`
- `playlist`: required non-empty array of media entries with `type`, `id`, and optional `pos` (`next` | `end`)
- `status`: `draft` | `scheduled` | `paused` | `completed` | `failed` | `canceled` (`running` is internal)
- `notes`: optional `string | null` (rich HTML allowed; expected max input length 20000 chars before sanitize)
- `status`: `draft` | `scheduled` | `paused` | `running` | `completed` | `failed` | `canceled` (`running` is accepted and normalized to `scheduled` on write)
- `notes`: optional `string | null`, trimmed and capped to 20000 chars
- `color`: optional `string | null`, must match `^#[0-9A-Fa-f]{6}$` when provided
Action payload schema:

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "veretube-bot"
version = "0.1.5"
version = "0.1.6"
description = "Python bot library for veretube/sync v4.0 channels"
readme = "README.md"
license = "MIT"

View file

@ -1,5 +1,6 @@
import urllib.parse
import re
from datetime import datetime
import requests
@ -125,11 +126,47 @@ class BotAPI:
_SHOW_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
@staticmethod
def _coerce_timestamp_ms(value):
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
text = value.strip()
if not text:
return None
if text.endswith("Z"):
text = f"{text[:-1]}+00:00"
try:
return datetime.fromisoformat(text).timestamp() * 1000.0
except ValueError:
return None
return None
def _validate_show_payload(self, payload: dict) -> None:
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("name is required and must be a non-empty string")
if len(name) > 100:
raise ValueError("name must be at most 100 characters")
timezone = payload.get("timezone")
if not isinstance(timezone, str) or not timezone.strip():
raise ValueError("timezone is required and must be a non-empty IANA timezone string")
scheduled_for = payload.get("scheduled_for")
if scheduled_for is None or (isinstance(scheduled_for, str) and not scheduled_for.strip()):
raise ValueError("scheduled_for is required and must be a date string or unix timestamp (ms)")
if not isinstance(scheduled_for, (str, int, float)):
raise ValueError("scheduled_for must be a date string or unix timestamp (ms)")
estimated_end_at = payload.get("estimated_end_at")
if estimated_end_at is not None and not isinstance(estimated_end_at, (str, int, float)):
raise ValueError("estimated_end_at must be a date string or unix timestamp (ms)")
start_ts = self._coerce_timestamp_ms(scheduled_for)
end_ts = self._coerce_timestamp_ms(estimated_end_at)
if estimated_end_at is not None and start_ts is not None and end_ts is not None and end_ts <= start_ts:
raise ValueError("estimated_end_at must be later than scheduled_for")
recurrence = payload.get("recurrence")
if recurrence is not None and recurrence not in {"none", "daily", "weekly"}:
raise ValueError("recurrence must be one of: none, daily, weekly")
@ -143,8 +180,8 @@ class BotAPI:
raise ValueError("conflict_mode must be one of: force, skip")
status = payload.get("status")
if status is not None and status not in {"draft", "scheduled", "paused", "completed", "failed", "canceled"}:
raise ValueError("status must be one of: draft, scheduled, paused, completed, failed, canceled")
if status is not None and status not in {"draft", "scheduled", "paused", "running", "completed", "failed", "canceled"}:
raise ValueError("status must be one of: draft, scheduled, paused, running, completed, failed, canceled")
notes = payload.get("notes")
if notes is not None:
@ -180,6 +217,19 @@ class BotAPI:
"""List public show schedule. Read-only."""
return self._request("GET", "/shows/public")
def resolve_show_media(self, items: list[dict]) -> dict:
"""Resolve up to 50 media entries into display-ready titles. Requires rank >= MOD."""
if not isinstance(items, list) or not items:
raise ValueError("items is required and must be a non-empty list")
if len(items) > 50:
raise ValueError("items may contain at most 50 entries")
for index, item in enumerate(items):
if not isinstance(item, dict):
raise ValueError(f"items[{index}] must be an object")
if not item.get("type") or not item.get("id"):
raise ValueError(f"items[{index}] must include non-empty type and id")
return self._request("POST", "/shows/resolve-media", json={"items": items})
def get_show(self, show_id: int | str) -> dict:
"""Get a single show by id. Requires rank >= MOD."""
return self._request("GET", f"/shows/{show_id}")

View file

@ -253,6 +253,9 @@ class AsyncBot:
async def list_public_shows(self) -> list:
return await asyncio.to_thread(self.api.list_public_shows)
async def resolve_show_media(self, items: list[dict]) -> dict:
return await asyncio.to_thread(self.api.resolve_show_media, items)
async def get_show(self, show_id: int | str) -> dict:
return await asyncio.to_thread(self.api.get_show, show_id)

View file

@ -334,6 +334,10 @@ class Bot:
"""List public scheduled shows. Read-only."""
return self.api.list_public_shows()
def resolve_show_media(self, items: list[dict]) -> dict:
"""Resolve up to 50 media entries into display-ready titles. Requires rank >= MOD."""
return self.api.resolve_show_media(items)
def get_show(self, show_id: int | str) -> dict:
"""Get a single show by id. Requires rank >= MOD."""
return self.api.get_show(show_id)