diff --git a/README.md b/README.md index bd51b49..76f37f2 100644 --- a/README.md +++ b/README.md @@ -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": "
Special guest this week.
", - "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: diff --git a/pyproject.toml b/pyproject.toml index 9b3d2ef..6544bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/veretube_bot/_api.py b/veretube_bot/_api.py index 685dade..46cc5c6 100644 --- a/veretube_bot/_api.py +++ b/veretube_bot/_api.py @@ -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}") diff --git a/veretube_bot/async_bot.py b/veretube_bot/async_bot.py index f20f610..d5e395b 100644 --- a/veretube_bot/async_bot.py +++ b/veretube_bot/async_bot.py @@ -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) diff --git a/veretube_bot/bot.py b/veretube_bot/bot.py index c1b2e59..82fa3bf 100644 --- a/veretube_bot/bot.py +++ b/veretube_bot/bot.py @@ -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)