Packages

An Elixir client for LiveKit, providing tools for interacting with LiveKit servers, including access token generation, room management, and webhook verification.

Current section

Files

Jump to
livekitex README.md
Raw

README.md

# LiveKitEx

A comprehensive Elixir SDK for [LiveKit](https://livekit.io/), an open-source WebRTC infrastructure for building real-time video and audio applications. This library provides a complete set of tools for integrating with LiveKit servers using the modern Twirp RPC protocol over HTTP.

[![Hex Version](https://img.shields.io/hexpm/v/livekitex.svg)](https://hex.pm/packages/livekitex)
[![Documentation](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/livekitex)

## ๐Ÿš€ Features

- **Room Management** - Create, delete, and list rooms
- **Participant Control** - Manage participants and their permissions
- **Access Token Generation** - JWT tokens for client authentication
- **Webhook Verification** - Verify and process LiveKit webhooks
- **Track Management** - Mute/unmute audio and video tracks
- **Modern HTTP-based Communication** - Uses Twirp over HTTP instead of gRPC
- **Comprehensive Error Handling** - Detailed error responses and logging
- **Production Ready** - Built with Tesla and Finch for reliable HTTP communication

## ๐Ÿ“ฆ Installation

Add `livekitex` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:livekitex, "~> 0.1.0"}
  ]
end
```

## โš™๏ธ Configuration

Configure your LiveKit server credentials in `config/config.exs`:

```elixir
import Config

config :livekitex,
  api_key: "your_api_key",
  api_secret: "your_api_secret",
  host: "localhost",  # Your LiveKit server host
  port: 7880          # LiveKit HTTP API port (default: 7880)

# Tesla HTTP client configuration
config :tesla, Tesla.Adapter.Finch, name: Livekitex.Finch
```

Or use environment variables:

```elixir
config :livekitex,
  api_key: System.get_env("LIVEKIT_API_KEY"),
  api_secret: System.get_env("LIVEKIT_API_SECRET"),
  host: System.get_env("LIVEKIT_HOST", "localhost"),
  port: String.to_integer(System.get_env("LIVEKIT_PORT", "7880"))
```

## ๐Ÿš€ Quick Start

### Basic Room Management

```elixir
# Create a room service client
room_service = Livekitex.RoomService.create("your_api_key", "your_api_secret")

# Create a new room
{:ok, room} = Livekitex.RoomService.create_room(room_service, "my-room")
IO.inspect(room)

# List all rooms
{:ok, rooms} = Livekitex.RoomService.list_rooms(room_service)
IO.inspect(rooms)

# Delete a room
:ok = Livekitex.RoomService.delete_room(room_service, "my-room")
```

### Generate Access Tokens

```elixir
# Create an access token for a user
token = Livekitex.AccessToken.create(
  "your_api_key",
  "your_api_secret",
  identity: "user123",
  name: "John Doe"
)

# Set video permissions
video_grant = %Livekitex.Grants.VideoGrant{
  room_join: true,
  room: "my-room",
  can_publish: true,
  can_subscribe: true
}

token = Livekitex.AccessToken.set_video_grant(token, video_grant)

# Generate JWT
{:ok, jwt, _claims} = Livekitex.AccessToken.to_jwt(token)
IO.puts("Access token: #{jwt}")
```

## ๐Ÿ“š Comprehensive Usage Guide

### 1. Room Management

#### Creating Rooms with Options

```elixir
room_service = Livekitex.RoomService.create("api_key", "api_secret")

# Create a room with custom settings
options = [
  max_participants: 50,
  empty_timeout: 600,        # Auto-delete after 10 minutes of being empty
  departure_timeout: 30,     # Wait 30 seconds after last participant leaves
  metadata: "Conference Room A"
]

{:ok, room} = Livekitex.RoomService.create_room(room_service, "conference-room", options)

# Room details
IO.puts("Room created: #{room.name}")
IO.puts("Room SID: #{room.sid}")
IO.puts("Max participants: #{room.max_participants}")
IO.puts("Creation time: #{room.creation_time}")
```

#### Listing and Filtering Rooms

```elixir
# List all rooms
{:ok, all_rooms} = Livekitex.RoomService.list_rooms(room_service)

# Filter rooms by name
{:ok, filtered_rooms} = Livekitex.RoomService.list_rooms(
  room_service, 
  names: ["room1", "room2", "room3"]
)

Enum.each(filtered_rooms, fn room ->
  IO.puts("Room: #{room.name} (#{room.num_participants} participants)")
end)
```

### 2. Participant Management

#### Listing Participants

```elixir
# List all participants in a room
{:ok, participants} = Livekitex.RoomService.list_participants(room_service, "my-room")

Enum.each(participants, fn participant ->
  IO.puts("Participant: #{participant.identity} (#{participant.name})")
  IO.puts("  State: #{participant.state}")
  IO.puts("  Joined at: #{participant.joined_at}")
  IO.puts("  Is publisher: #{participant.is_publisher}")
  IO.puts("  Tracks: #{length(participant.tracks)}")
end)
```

#### Removing Participants

```elixir
# Remove a participant from a room
:ok = Livekitex.RoomService.remove_participant(room_service, "my-room", "user123")
```

#### Managing Published Tracks

```elixir
# Mute a participant's audio track
{:ok, track} = Livekitex.RoomService.mute_published_track(
  room_service,
  "my-room",
  "user123",
  "track_sid_audio",
  true  # muted = true
)

# Unmute a participant's video track
{:ok, track} = Livekitex.RoomService.mute_published_track(
  room_service,
  "my-room", 
  "user123",
  "track_sid_video",
  false  # muted = false
)

IO.puts("Track #{track.sid} mute status: #{track.muted}")
```

### 3. Access Token Generation

#### Basic User Token

```elixir
# Simple user token with room access
token = Livekitex.AccessToken.create(
  "api_key",
  "api_secret",
  identity: "user123",
  name: "John Doe",
  metadata: %{role: "presenter", department: "engineering"},
  ttl: 3600  # Token valid for 1 hour
)

video_grant = %Livekitex.Grants.VideoGrant{
  room_join: true,
  room: "my-room",
  can_publish: true,
  can_subscribe: true,
  can_publish_data: true
}

token = Livekitex.AccessToken.set_video_grant(token, video_grant)
{:ok, jwt, claims} = Livekitex.AccessToken.to_jwt(token)
```

#### Admin Token for Room Management

```elixir
# Create admin token for room management operations
admin_token = Livekitex.AccessToken.create(
  "api_key",
  "api_secret",
  identity: "admin"
)

admin_grant = %Livekitex.Grants.VideoGrant{
  room_admin: true,
  room_list: true,
  room_create: true,
  room_record: true
}

admin_token = Livekitex.AccessToken.set_video_grant(admin_token, admin_grant)
{:ok, admin_jwt, _} = Livekitex.AccessToken.to_jwt(admin_token)
```

#### Role-Based Tokens

```elixir
defmodule MyApp.TokenGenerator do
  alias Livekitex.{AccessToken, Grants}

  def generate_token(user_id, role, room_name) do
    token = AccessToken.create(
      Application.get_env(:livekitex, :api_key),
      Application.get_env(:livekitex, :api_secret),
      identity: user_id,
      name: get_user_name(user_id),
      ttl: 7200  # 2 hours
    )

    video_grant = case role do
      :host ->
        %Grants.VideoGrant{
          room_join: true,
          room: room_name,
          can_publish: true,
          can_subscribe: true,
          can_publish_data: true,
          can_update_metadata: true
        }

      :presenter ->
        %Grants.VideoGrant{
          room_join: true,
          room: room_name,
          can_publish: true,
          can_subscribe: true,
          can_publish_data: false
        }

      :viewer ->
        %Grants.VideoGrant{
          room_join: true,
          room: room_name,
          can_publish: false,
          can_subscribe: true,
          can_publish_data: false
        }

      :recorder ->
        %Grants.VideoGrant{
          room_join: true,
          room: room_name,
          can_publish: true,
          can_subscribe: true,
          hidden: true,
          recorder: true
        }
    end

    token
    |> AccessToken.set_video_grant(video_grant)
    |> AccessToken.to_jwt()
  end

  defp get_user_name(user_id) do
    # Fetch user name from your database
    "User #{user_id}"
  end
end

# Usage
{:ok, host_token, _} = MyApp.TokenGenerator.generate_token("host123", :host, "meeting-room")
{:ok, viewer_token, _} = MyApp.TokenGenerator.generate_token("viewer456", :viewer, "meeting-room")
```

### 4. Webhook Processing

#### Webhook Verification and Processing

```elixir
defmodule MyApp.WebhookController do
  use MyAppWeb, :controller
  alias Livekitex.Webhook

  def receive_webhook(conn, _params) do
    # Get the raw body and authorization header
    {:ok, raw_body, _conn} = Plug.Conn.read_body(conn)
    auth_header = get_req_header(conn, "authorization") |> List.first()

    case Webhook.validate_webhook(raw_body, auth_header, get_api_secret()) do
      {:ok, event} ->
        process_webhook_event(event)
        json(conn, %{status: "ok"})

      {:error, reason} ->
        Logger.error("Webhook validation failed: #{inspect(reason)}")
        conn
        |> put_status(400)
        |> json(%{error: "Invalid webhook"})
    end
  end

  defp process_webhook_event(%{event: event_type} = event) do
    case event_type do
      "room_started" ->
        Logger.info("Room started: #{event.room.name}")
        # Handle room started event

      "room_finished" ->
        Logger.info("Room finished: #{event.room.name}")
        # Handle room finished event

      "participant_joined" ->
        Logger.info("Participant joined: #{event.participant.identity}")
        # Handle participant joined event

      "participant_left" ->
        Logger.info("Participant left: #{event.participant.identity}")
        # Handle participant left event

      "track_published" ->
        Logger.info("Track published: #{event.track.sid}")
        # Handle track published event

      "track_unpublished" ->
        Logger.info("Track unpublished: #{event.track.sid}")
        # Handle track unpublished event

      _ ->
        Logger.info("Unhandled webhook event: #{event_type}")
    end
  end

  defp get_api_secret do
    Application.get_env(:livekitex, :api_secret)
  end
end
```

### 5. Advanced Usage Patterns

#### Connection Pool and Client Management

```elixir
defmodule MyApp.LiveKitManager do
  use GenServer
  alias Livekitex.RoomService

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def init(_opts) do
    # Create a persistent room service client
    room_service = RoomService.create(
      Application.get_env(:livekitex, :api_key),
      Application.get_env(:livekitex, :api_secret),
      host: Application.get_env(:livekitex, :host),
      port: Application.get_env(:livekitex, :port)
    )

    {:ok, %{room_service: room_service}}
  end

  def create_room(name, opts \\ []) do
    GenServer.call(__MODULE__, {:create_room, name, opts})
  end

  def list_rooms do
    GenServer.call(__MODULE__, :list_rooms)
  end

  def delete_room(name) do
    GenServer.call(__MODULE__, {:delete_room, name})
  end

  def handle_call({:create_room, name, opts}, _from, state) do
    result = RoomService.create_room(state.room_service, name, opts)
    {:reply, result, state}
  end

  def handle_call(:list_rooms, _from, state) do
    result = RoomService.list_rooms(state.room_service)
    {:reply, result, state}
  end

  def handle_call({:delete_room, name}, _from, state) do
    result = RoomService.delete_room(state.room_service, name)
    {:reply, result, state}
  end
end

# Add to your application supervision tree
children = [
  MyApp.LiveKitManager
]
```

#### Error Handling and Retry Logic

```elixir
defmodule MyApp.RoomManager do
  alias Livekitex.RoomService
  require Logger

  def create_room_with_retry(room_service, name, opts \\ [], max_retries \\ 3) do
    do_create_room_with_retry(room_service, name, opts, max_retries, 0)
  end

  defp do_create_room_with_retry(room_service, name, opts, max_retries, attempt) do
    case RoomService.create_room(room_service, name, opts) do
      {:ok, room} ->
        {:ok, room}

      {:error, {:twirp_error, :econnrefused}} when attempt < max_retries ->
        Logger.warn("Connection refused, retrying in #{backoff_delay(attempt)}ms")
        Process.sleep(backoff_delay(attempt))
        do_create_room_with_retry(room_service, name, opts, max_retries, attempt + 1)

      {:error, {:unavailable, _}} when attempt < max_retries ->
        Logger.warn("Server unavailable, retrying in #{backoff_delay(attempt)}ms")
        Process.sleep(backoff_delay(attempt))
        do_create_room_with_retry(room_service, name, opts, max_retries, attempt + 1)

      {:error, reason} ->
        Logger.error("Failed to create room after #{attempt + 1} attempts: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp backoff_delay(attempt) do
    # Exponential backoff: 1s, 2s, 4s
    :math.pow(2, attempt) * 1000 |> round()
  end
end
```

### 6. Real-World Integration Examples

#### Phoenix LiveView Integration

```elixir
defmodule MyAppWeb.RoomLive do
  use MyAppWeb, :live_view
  alias MyApp.{TokenGenerator, LiveKitManager}

  @impl true
  def mount(%{"room_id" => room_id}, %{"user_id" => user_id}, socket) do
    # Ensure room exists
    case LiveKitManager.create_room(room_id) do
      {:ok, _room} ->
        # Generate access token for the user
        {:ok, token, _} = TokenGenerator.generate_token(user_id, :presenter, room_id)

        socket = 
          socket
          |> assign(:room_id, room_id)
          |> assign(:user_id, user_id)
          |> assign(:access_token, token)
          |> assign(:connected, false)

        {:ok, socket}

      {:error, reason} ->
        {:error, "Failed to create room: #{inspect(reason)}"}
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div id="livekit-room" class="w-full h-screen">
      <div class="p-4 bg-gray-100">
        <h1 class="text-2xl font-bold">Room: <%= @room_id %></h1>
        <p>User: <%= @user_id %></p>
      </div>
      
      <div id="video-container" class="flex-1" phx-hook="LiveKitRoom" 
           data-room-id={@room_id} data-access-token={@access_token}>
        <!-- LiveKit will render video elements here -->
      </div>
    </div>
    """
  end
end
```

#### Background Job for Room Cleanup

```elixir
defmodule MyApp.Workers.RoomCleanup do
  use Oban.Worker, queue: :default, max_attempts: 3
  alias MyApp.LiveKitManager
  require Logger

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"action" => "cleanup_empty_rooms"}}) do
    case LiveKitManager.list_rooms() do
      {:ok, rooms} ->
        empty_rooms = Enum.filter(rooms, fn room -> room.num_participants == 0 end)
        
        Enum.each(empty_rooms, fn room ->
          case LiveKitManager.delete_room(room.name) do
            :ok ->
              Logger.info("Cleaned up empty room: #{room.name}")
            {:error, reason} ->
              Logger.error("Failed to cleanup room #{room.name}: #{inspect(reason)}")
          end
        end)

        {:ok, "Cleaned up #{length(empty_rooms)} empty rooms"}

      {:error, reason} ->
        Logger.error("Failed to list rooms for cleanup: #{inspect(reason)}")
        {:error, reason}
    end
  end

  # Schedule cleanup job every hour
  def schedule_cleanup do
    %{action: "cleanup_empty_rooms"}
    |> __MODULE__.new(schedule_in: 3600)
    |> Oban.insert()
  end
end
```

## ๐Ÿ”ง Configuration Options

### Room Service Configuration

```elixir
# Create room service with custom options
room_service = Livekitex.RoomService.create(
  "api_key",
  "api_secret",
  host: "livekit.example.com",
  port: 443  # Use 443 for HTTPS
)
```

### Tesla HTTP Client Configuration

```elixir
# In config/config.exs
config :tesla,
  adapter: {Tesla.Adapter.Finch, name: Livekitex.Finch},
  # Optional: Configure request timeout
  timeout: 30_000  # 30 seconds
```

## ๐Ÿ“Š Error Handling

LiveKitEx provides comprehensive error handling for various scenarios:

```elixir
case Livekitex.RoomService.create_room(room_service, "test-room") do
  {:ok, room} ->
    # Success
    IO.puts("Room created: #{room.name}")

  {:error, {:already_exists, message}} ->
    # Room already exists
    IO.puts("Room already exists: #{message}")

  {:error, {:unauthenticated, message}} ->
    # Invalid API credentials
    IO.puts("Authentication failed: #{message}")

  {:error, {:not_found, message}} ->
    # Resource not found
    IO.puts("Not found: #{message}")

  {:error, {:twirp_error, reason}} ->
    # Connection or communication error
    IO.puts("Connection error: #{inspect(reason)}")

  {:error, reason} ->
    # Other errors
    IO.puts("Error: #{inspect(reason)}")
end
```

## ๐Ÿงช Testing

Run the test suite:

```bash
# Run all tests
mix test

# Run tests with coverage
mix test --cover

# Run specific test file
mix test test/livekitex/room_service_test.exs
```

The test suite includes integration tests that can run against a live LiveKit server. To run integration tests:

1. Start a LiveKit server in development mode:
   ```bash
   livekit-server --dev
   ```

2. Run the tests:
   ```bash
   mix test
   ```

## ๐Ÿค Contributing

1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

## ๐Ÿ“„ License

This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details.

## ๐Ÿ™ Acknowledgments

- [LiveKit](https://livekit.io/) for the excellent WebRTC infrastructure
- [Twirp](https://github.com/keathley/twirp-elixir) for the Elixir Twirp implementation  
- [Tesla](https://github.com/elixir-tesla/tesla) for the HTTP client
- [Finch](https://github.com/sneako/finch) for HTTP connection pooling

## ๐Ÿ“š Additional Resources

- [LiveKit Documentation](https://docs.livekit.io/)
- [LiveKit Client SDKs](https://docs.livekit.io/guides/client-sdk-reference/)
- [Twirp Protocol](https://twitchtv.github.io/twirp/docs/intro.html)
- [HexDocs](https://hexdocs.pm/livekitex)