Integration API (chat bots)
The session server’s API for chat sidecars such as the
Discord bot. It runs on its own
listener (INTEGRATION_HOST:INTEGRATION_PORT, default 127.0.0.1:3002)
and only starts when the admin panel, Jellyfin devices and DATA_DIR are
set up and at least one sidecar token is configured.
Code: src/server/src/integration/ (api.rs HTTP, actions.rs
permissions and room operations, store.rs data file and codes, view.rs
room list), with admin endpoints in src/server/src/admin/integrations.rs.
Authentication
Every request needs Authorization: Bearer <token>. The token decides the
platform: DISCORD_INTEGRATION_TOKEN means discord. Tokens are compared
as SHA-256 digests in constant time. A wrong or missing token gets 401
{"reason":"unauthorized"}. Bodies are limited to 16 KiB.
User actions carry an actor: what the platform said about the person
asking.
{ "id": "123456789012345678", "name": "alice", "guild_id": "...", "channel_id": "...", "roles": ["..."] }
Before running an action, the server checks:
idis numeric.- The account is under its rate limit (30 requests per minute).
- The platform is enabled.
guild_idis the configured server.channel_idis allowed, if channels are restricted.- The required role is in
roles, if one is configured.
For everything except link, it then also checks that the account is
linked, and that the Jellyfin user exists and is enabled. GET /Users is
cached for 60 s.
Errors
{"error": "<message for the user>", "reason": "<code>", "retry_after_ms"?: n}
| reason | When |
|---|---|
unauthorized |
Bad token |
invalid |
Malformed request |
disabled, not_configured, wrong_guild, channel_not_allowed, missing_role |
Platform policy |
rate_limited |
Over 30 requests per minute (retry_after_ms) |
not_linked, account_disabled, target_not_linked |
Linking |
bad_credentials, locked_out, already_linked_elsewhere |
link |
not_found, member_not_found |
Room or member gone |
not_participant, not_owner, not_your_device, is_owner, owner_cannot_leave |
Permissions |
wrong_password, too_many_attempts |
Room password |
password_required, room_limit, room_full, role_not_allowed |
Limits and settings |
session_not_found, runs_web_client, no_remote_control, device_already_bridged, jellyfin_unavailable |
Adding a device |
Endpoints (/v1)
| Method and path | Body | Answer |
|---|---|---|
GET /config |
{provider, version, settings}. version changes when an admin saves settings. |
|
POST /heartbeat |
{bot_name} |
{ok, now}. Marks the bot as online in the admin panel. |
GET /rooms?since=<v> |
{version, rooms}. With since, waits up to 25 s for a change after version v. |
|
PUT /rooms/{id}/panel |
{channel_id, message_id} |
Remembers where the room’s panel message is. |
POST /link |
{actor, username, code} |
{ok, user_name} |
POST /unlink |
{actor} |
|
POST /me |
{actor} |
{user_id, user_name, is_admin, owns, joined} |
POST /devices |
{actor} |
{devices: [{session_id, device_name, client, remote_control, now_playing, bridged_as, room_id}]}. Only the actor’s own sessions. |
POST /rooms |
{actor, name, password?} |
{ok, id, name} |
POST /rooms/{id}/join |
{actor, password?} |
{ok, already?} |
POST /rooms/{id}/leave |
{actor} |
Also removes the actor’s devices from the room. |
POST /rooms/{id}/update |
{actor, name?, password?} |
password: absent keeps it, null/"" removes it. Owner only. |
POST /rooms/{id}/close |
{actor} |
Owner only. |
POST /rooms/{id}/owner |
{actor, to} |
to is a chat account id that has joined the room. Owner only. |
POST /rooms/{id}/kick |
{actor, member} or {actor, user} |
A member (client id), or a participant by account id together with their devices. Owner only. |
POST /rooms/{id}/host |
{actor, member} |
Owner only. |
POST /rooms/{id}/devices |
{actor, session_id, role: "host"\|"receiver"} |
{ok, member_id}. Participants only. host is allowed for the owner, or when the room has no host. |
POST /rooms/{id}/devices/remove |
{actor, member} |
Own devices; the owner can remove any member. |
“Owner only” also allows admins: Jellyfin administrators, or holders of the configured admin role.
A room in GET /rooms (see src/integrations/fixtures/rooms.json, which
tests on both sides check):
{
"id": "…", "name": "…", "has_password": true,
"owner": { "user_id": "…", "name": "Alice", "external_id": "…" },
"participants": [{ "user_id": "…", "name": "…", "external_id": "…" | null }],
"host": { "id": "…", "name": "…" } | null,
"members": [{ "id": "…", "name": "…", "kind": "jellyfin|web|plugin_bridge", "is_host": true,
"status": "…", "owner_user_id": "…" | null, "owner_external_id": "…" | null }],
"media_id": "…" | null, "play_state": "playing|paused",
"panel": { "channel_id": "…", "message_id": "…" } | null,
"created_at": 0, "empty_since": 0 | null
}
Chat rooms
A room created through the API (Room.chat, types::ChatRoom) has a
platform, an owner and participants. All of them are Jellyfin users,
identified by their normalized id (32 lowercase hex characters).
- Like an admin group: it starts empty and hostless, with no start
countdown. While it has no host, the next Watch Party panel user to join,
or the next device added with
role: "host", becomes host. A device added as receiver doesn’t. - Stays open when empty: when the last member leaves, the room stays
open without a host (
room/leave.rs). The integration’s reaper closes it after the platform’sempty_room_minutes. - Device ownership:
Bridges::add(.., owner)refuses a session whoseUserIdisn’t the owner (AddError::NotYourDevice). The check uses the same fresh/Sessionsresult that the device is added from. - Change signal:
events::bump()runs on every room-list or participant broadcast. It wakes the long poll.
Data file
DATA_DIR/integrations.json (mode 0600; written to a temp file, synced,
then renamed):
{ "version": 1,
"settings": { "discord": { "enabled": true, "guild_id": "…", … } },
"users": { "<jellyfin id>": { "name": "Alice", "code_hmac": "…", "assigned_at": 0,
"failed": 0, "frozen": false,
"links": { "discord": { "external_id": "…", "display_name": "…", "linked_at": 0 } } } } }
code_hmac is HMAC-SHA256 over "jwp-link-code\0" || user id || "\0" ||
code, keyed with DATA_DIR/secret.key (32 random bytes, created on first
start). A file that doesn’t parse stops the integration from starting; it is
never overwritten.
Admin endpoints (admin panel, session and CSRF header required)
| Method and path | |
|---|---|
GET /api/integrations |
Availability, whether the API is listening, and per platform: token set, bot heartbeat, settings |
PUT /api/integrations/discord |
Save settings (validated) |
GET /api/users |
Jellyfin users with code state and links. Users deleted from Jellyfin show as missing. |
POST /api/users/{id}/code |
Assign a new code: returns {code} once, drops links, resets the count and lock |
DELETE /api/users/{id}/code |
Remove the code and links |
DELETE /api/users/{id}/links/{provider} |
Unlink |
GET /api/audit |
Activity log, newest first (last 500 entries, in memory) |
Adding a platform
The server side is per platform. Add the platform and its token variable
to integration::config::PROVIDERS and a settings slot to
store::Settings. A sidecar then only needs to map its platform’s users,
groups and roles onto actor.