Metadata-Version: 2.4
Name: stapel-video
Version: 0.8.0
Summary: Video calls (rooms, lobby admission, presence metering, recording-egress seam) for the Stapel framework
License: MIT
Project-URL: Homepage, https://github.com/usestapel/stapel-video
Project-URL: Repository, https://github.com/usestapel/stapel-video
Project-URL: Documentation, https://github.com/usestapel/stapel-video#readme
Project-URL: Changelog, https://github.com/usestapel/stapel-video/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/usestapel/stapel-video/issues
Keywords: django,stapel,video,livekit,webrtc
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: stapel-core<1.0,>=0.35.0
Provides-Extra: livekit
Requires-Dist: livekit-api<1,>=0.7; extra == "livekit"
Requires-Dist: requests<3,>=2.28; extra == "livekit"
Provides-Extra: channels
Requires-Dist: stapel-core[channels]; extra == "channels"
Provides-Extra: all
Requires-Dist: livekit-api<1,>=0.7; extra == "all"
Requires-Dist: requests<3,>=2.28; extra == "all"
Requires-Dist: stapel-core[channels]; extra == "all"
Dynamic: license-file

<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->

# stapel-video

[![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-video/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-video/actions/workflows/ci.yml?query=branch%3Amain)
[![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-video?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-video)
[![pypi](https://img.shields.io/pypi/v/stapel-video?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-video/)
[![downloads](https://static.pepy.tech/badge/stapel-video/month)](https://pepy.tech/project/stapel-video)
[![python](https://img.shields.io/pypi/pyversions/stapel-video?logo=python&logoColor=white)](https://pypi.org/project/stapel-video/)
[![license](https://img.shields.io/github/license/usestapel/stapel-video)](https://github.com/usestapel/stapel-video/blob/main/LICENSE)
[![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-video/blob/main/docs/llms.txt)

> Video calls: rooms with shareable join codes, an access-level admission model (public / scope-trusted / restricted lobby) with a realtime waiting room over WebSockets, host admit/deny controls, join-token minting through a pluggable video-provider seam (LiveKit by default), a recording-egress seam (start/stop + a video.egress_ended event) that integrates with stapel-recordings by event and never by import, and a presence meter — per-connection spans fed by the media server's own join/leave webhooks, reconciled by a sweeper so a crashed client cannot bill forever, and read back as unioned presence time and a co-presence matrix for whatever prices them, partitioned by an opaque scope_key carried on the join grant so a workspace administrator can read their own people's monthly call time behind their own mandate.

Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.

## Install

```bash
pip install stapel-video
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.8.0` |
| Python | `>=3.11` (3.11, 3.12, 3.13, 3.14) |
| HTTP operations | 8 |
| Config axes | 3 |
| Usage surface | 31 |
| Extension points | 13 |
| Error codes | 51 |
| Fleet dependencies | [`stapel-auth`](https://github.com/usestapel/stapel-auth) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-profiles`](https://github.com/usestapel/stapel-profiles) (optional) · [`stapel-recordings`](https://github.com/usestapel/stapel-recordings) (optional) · [`stapel-workspaces`](https://github.com/usestapel/stapel-workspaces) (optional) |

## Documentation

[OpenAPI](https://github.com/usestapel/stapel-video/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-video/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-video/blob/main/docs/llms.txt)

## What this is

- **Rooms** with human-shareable join codes (`abc-defg-hij`).
- **Admission model** — `public` (anyone with the code joins), `scope_trusted`
  (members of the room's scope join instantly, others wait), `restricted`
  (everyone but the host waits in a lobby).
- **Realtime lobby** over WebSockets (Channels) — `waiting` / `admitted` /
  `denied`, authenticated by the same Stapel JWT stack HTTP uses.
- **Host controls** — admit / deny waiting guests.
- **Provider seam** — one `VideoProvider` ABC (mint join token, create room,
  start/stop recording egress, verify webhook). Swap vendors without forking.
- **Recording** is a *seam*, not a pipeline: `start`/`stop_egress` proxy the
  provider and a `video.egress_ended` comm event carries the storage key to
  [stapel-recordings](https://github.com/usestapel/stapel-recordings) — by
  event, never by import.
- **Presence metering** — per-connection spans fed by the media server's own
  join/leave webhooks (the only departure signal that survives a closed
  laptop), reconciled by a sweeper so a lost webhook cannot bill forever, and
  read back as unioned presence time and a co-presence matrix. Raw seconds, no
  threshold: this instance meters, whatever prices it decides what counts.
- **Per-tenant usage** — spans carry an opaque `scope_key` set on the join
  grant, so "who in THIS workspace talked how much, per month" is a read this
  library answers, gated on the caller's mandate in that very scope. A foreign
  key answers 404, never 403.

Alpha. See [MODULE.md](https://github.com/usestapel/stapel-video/blob/main/MODULE.md) for the agent-facing map of seams.

## Quick start

```bash
pip install stapel-video            # core library
pip install 'stapel-video[livekit]' # + the default LiveKit backend
pip install 'stapel-video[channels]'# + the realtime lobby (WebSockets)
```

```python
# urls.py
path("video/", include("stapel_video.urls"))

# asgi.py (realtime lobby)
from channels.routing import ProtocolTypeRouter, URLRouter
from stapel_core.django.jwt.channels import JWTAuthMiddlewareStack
from stapel_video.routing import websocket_urlpatterns

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    "websocket": JWTAuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
})
```

## API

| Method | Path | What |
|---|---|---|
| POST | `/video/api/rooms` | Create a room (creator auto-admitted host, with a token) |
| GET | `/video/api/rooms/{join_code}` | Room info |
| POST | `/video/api/rooms/{join_code}/join` | Join → admitted / waiting / denied |
| GET | `/video/api/rooms/{join_code}/participants` | Participants (anchor-paginated) |
| POST | `/video/api/rooms/{join_code}/lobby/admit` | Admit a waiting guest (host-only) |
| POST | `/video/api/rooms/{join_code}/lobby/deny` | Deny a waiting guest (host-only) |
| GET | `/video/api/v1/scopes/{scope_key}/usage/` | One tenant's per-month, per-person call time (`?months=`/`?month=`, `?tz=`) — mandate-gated in that scope |
| POST | `/video/api/webhook` | Provider webhook ingress (signed, unauthenticated) |

## Configuration (`STAPEL_VIDEO`)

| Key | Default | What |
|---|---|---|
| `VIDEO_PROVIDER` | `…livekit.LiveKitProvider` | Video backend (dotted path) |
| `SCOPE_PROVIDER` | `…scope.DefaultScopeProvider` | scope_key resolution + membership |
| `DEFAULT_ACCESS_LEVEL` | `restricted` | Access level for a room created without one |
| `DEFAULT_ADMIT_REQUIRED` | `True` | Whether new rooms start with the lobby on |
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | `""` | Default-provider credentials |
| `WEBHOOK_HANDLERS` | `{}` | Provider-event → handler, merged over the builtins |
| `PRESENCE_SWEEP_INTERVAL_SECONDS` | `60` | How often open presence spans are reconciled |
| `PRESENCE_SPAN_RETENTION_DAYS` | `400` | When a span is purged (`None` = never) |
| `USAGE_MANDATE` | `video.usage.read` | Capability a caller must hold **in the scope** to read its usage |
| `USAGE_AUTHORIZER` | `…usage.staff_only_authorizer` | Fallback gate where there are no workspaces to ask |
| `USAGE_THROTTLE` | `60/min` | Rate for the `video-scope-usage` throttle scope |

`VIDEO_PROVIDER`, `DEFAULT_ACCESS_LEVEL` and `DEFAULT_ADMIT_REQUIRED` are the
three CTO-facing config axes surfaced in `docs/capabilities.json`.

## Presence metering

Turn the provider's webhooks on (they point at `POST /video/api/webhook`), and
schedule the two jobs — a meter without the sweeper measures an upper bound,
not a duration:

```python
from stapel_video.tasks import get_video_beat_schedule

CELERY_BEAT_SCHEDULE = {**get_video_beat_schedule(), ...}
```

Celery is optional: `manage.py video_sweep_presence` and `manage.py
video_purge_spans` are the cron form. Then read the numbers by comm Function:

```python
from stapel_core.comm import call

call("video.presence.aggregate", {"user_id": uid, "period": "2026-08"})
# {"presence_seconds": 5400, "rooms_count": 3, ...} — unioned, so a laptop
# and a phone are one person present, not two.

call("video.presence.pairs_export", {"period": "2026-08", "limit": 500})
# {"rows": [{"room_key", "user_a", "user_b", "co_presence_seconds"}, ...],
#  "cursor": ..., "total": None} — raw overlaps; the "counts as a real
#  conversation" threshold belongs to whoever asks, not to the meter.
```

## Per-tenant usage

Pass the partition on the grant and the provider echoes it back onto every
span it reports:

```python
provider.mint_join_token(room_ref, user.pk, name, scope_key=str(workspace_id))
```

Spans recorded before you did that are the host's to place — only you know
which tenant a `room_key` belonged to:

```bash
manage.py video_backfill_scope --resolver myapp.reporting.scope_for_room
```

Then read a workspace's own numbers, over the bus or over HTTP:

```python
call("video.presence.usage_rollup_by_month",
     {"scope_key": str(workspace_id), "months": 6, "tz": "Europe/Berlin"})
# {"months": [{"month": "2026-08", "users": [{"user_id", "presence_seconds",
#  "rooms", "connections", ...}]}]} — newest month first, buckets cut at LOCAL
#  midnight, so a DST month is genuinely an hour short.
```

`GET /video/api/v1/scopes/{scope_key}/usage/?months=6&tz=Europe/Berlin` is the
same answer for a workspace-administration screen. It is not staff-gated: the
caller must hold `STAPEL_VIDEO["USAGE_MANDATE"]` (default `video.usage.read`)
**in the workspace they are asking about**, resolved through the workspaces
access registry — register it on the roles you mean to grant it to. Asking
about somebody else's workspace answers 404, identically to a workspace that
does not exist: a 403 would confirm the id is real. Without workspaces in the
picture, `USAGE_AUTHORIZER` decides (staff-only unless you replace it).

Rows carry user **ids**. This library never learns anybody's name; the host
resolves it from the roster it already has.

## License

MIT — see [LICENSE](https://github.com/usestapel/stapel-video/blob/main/LICENSE).

---

<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
