Streaming video with Phoenix

Update 20170906: This article was written for Phoenix 1.2. Is not going to work with 1.3 without some minimal changes, but the ideas should work just the same if you still want to try it. I'll try to set apart some time to update it to Phoenix 1.3, but no promises. Sorry for the inconvenience and confusion.

This is a minimal working Phoenix app that shows how to stream videos using the Phoenix Framework.

Streaming video is tricky. You must support all browsers, enable some nice UI for the user to control the video, jump to the desired position in the video, etc. Also, you don't want to download the video to the browser until the user clicks the play button. Similarly, you don't want to download the full video only to find that the user jumped to the end of the video without watching the middle of it. Let's do it.

First, create a new Phoenix project:

mix phoenix_video_stream
cd phoenix_video_stream
mix ecto.create

And let's add a CRUD module for the videos

mix phoenix.gen.html Video videos title filename content_type path

This will create a Video model with the title, filename, content_type and path string fields. Additionally it will create a controller, view and templates to create, update, read and delete videos. The user is not going to directly specify the content_type, path and filename fields, so we need to calculate those from the file the user uploads to the server. To do that we'll use a helper field that represents the file the user uploads.

Open web/model/video.ex and add a new field:

field :video_file, :any, virtual: true

Your schema should look like this:

  schema "videos" do
    field :title, :string
    field :video_file, :any, virtual: true
    field :filename, :string
    field :content_type, :string
    field :path, :string


The video_file property represents, on the server side, the file the user uploaded. It is a structure that contains the information about the uploaded file. From it we can extract the various values we’ll store in the database like filename, content_type, etc. This field will never be stored in the database, so we mark it as virtual.

We need to change the list of required fields in the model. Change

[@required_fields]( ~w(title filename content_type path)


[@required_fields]( ~w(title video_file)

Now the templates only need to send those two properties and the server will calculate the remaining fields from it.

We need to add the videos resources to the router. Open web/router.ex and add

resources "/videos", VideoController

just after the

get "/", PageController, :index


Now we can create the table in the database:

mix ecto.migrate

Let's now modify the templates to allow file uploading. Open web/templates/video/form.html.eex and change its contents to

<%= form_for [@changeset](, [@action](, [multipart: true], fn f -> %>
  <%= if [@changeset]( do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
  <% end %>

  <div class="form-group">
    <%= label f, :title, class: "control-label" %>
    <%= text_input f, :title, required: true, class: "form-control" %>
    <%= error_tag f, :title %>

<div class="form-group">
    <%= label f, :video_file, "Video", class: "control-label" %>
    <%= file_input f, :video_file, required: true, class: "form-control" %>
    <%= error_tag f, :video_file %>

  <div class="form-group">
    <%= submit "Submit", class: "btn btn-primary" %>
<% end %>

This form has only two fields, the title for the video and the video file itself. The form has a multipart: true specified so that the browser correctly encodes the file when posting this form to the server.

Now let's head to the video controller. Open web/controllers/video_controller.ex and change the create function to read like this:

def create(conn, %{"video" => video_params}) do
  changeset = Video.changeset(%Video{}, video_params)

  case Repo.insert(changeset) do
    {:ok, video} ->
      persist_file(video, video_params["video_file"])

      |> put_flash(:info, "Video created successfully.")
      |> redirect(to: video_path(conn, :index))
    {:error, changeset} ->
      render(conn, "new.html", changeset: changeset)

Two things happen here, first, the Video changeset will extract the metadata from the video_params and will use them to populate the other fields we are going to save to the database. Then we use that info to save the file uploaded to the storage.

Let's implement the first part. Modify the changeset function in the web/model/video.ex file:

def changeset(model, params \\ :empty) do
 |> cast(params, [@required_fields](, [@optional_fields](
 |> put_video_file()

And add the put_video_file/1 function to the same file:

def put_video_file(changeset) do
 case changeset do
   %Ecto.Changeset{valid?: true, changes: %{video_file: video_file}} ->
     path = Ecto.UUID.generate() <> Path.extname(video_file.filename)
     |> put_change(:path, path)
     |> put_change(:filename, video_file.filename)
     |> put_change(:content_type, video_file.content_type)
   _ ->

This function validates the changeset and then uses the filename and content_type fields of the video_file %Plug.Upload struct to build the filename path that we'll use for storage. As you can see, the filename is a UUID concatenated to the extension name of the uploaded file.

Now to the second part. We are using the video that was returned by the Repo.insert(changeset) to persist the file in the final location in storage.

Add the persist_file/2 function at the end of the video_controller.ex:

defp persist_file(video, %{path: temp_path}) do
  video_path = build_video_path(video)
  unless File.exists?(video_path) do
    video_path |> Path.dirname() |> File.mkdir_p()
    File.copy!(temp_path, video_path)

This function creates the uploads directory if necessary and then moves the uploaded file form the temporary location to the final destination in the storage.

We need to import the build_video_path/1 function at the beginning of the controller:

import PhoenixVideoStream.Util, only: [build_video_path: 1]

And create the PhoenixVideoStream.Util module. Create web/controllers/util.ex and write this:

defmodule PhoenixVideoStream.Util do
  def build_video_path(video) do
    Application.get_env(:phoenix_video_stream, :uploads_dir) |> Path.join(video.path)

As you can see, we're getting the uploads directory path from an configuration setting. Let's add it at the end of config/dev.exs:

config :phoenix_video_stream, :uploads_dir, "/tmp/uploads/"

We're almost done with the file upload part. Just one little bit remains. We need to config Phoenix to allow large file uploads (the default setting allows uploading files 8MB size or less). Open lib/phoenix_video_stream/endpoint.ex and modify the Plug.Parsers config like this:

plug Plug.Parsers,
 parsers: [:urlencoded, :multipart, :json],
 pass: ["*/*"],
 json_decoder: Poison,
 length: 400_000_000

Here we're allowing files that are no bigger than 400MB.

Right, time to test the file upload part. Run the server:

mix phoenix.server

and go to:


and you'll see the video list page.

Click the "New video" link and fill the title field and select a video file in the video input field.

Press submit and the file will be uploaded to the server

If you check your /tmp/uploads/ directory (it was created automatically). You'll see your file stored there with a UUID + original extension name.


Now to the objective of this article, reproducing the video back to the user. We'll use the fantastic video.js library from You could just use the HTML5 video tag if you are sure your users have a compatible modern web browser. If that is not the case and you want to also provide a nicer, configurable, user interface, video.js is amazing.

Add the css dependency to the web/templates/layout/app.html.eex file, as the last line before the closing head tag:

<link href="[](" rel="stylesheet">

And the JS dependency

<script src="[]("></script>

just before the last <script> tag already in place at the bottom of app.html.eex.

Now lets add the video tag to the web/templates/video/show.html.eex. Replace its contents with:

<h2>Show video</h2>

    <%= @video.title %>

<video id="my-video" class="video-js" controls preload="none" width="640" height="264" data-setup="{}">
  <source src="<%= watch_path(@conn, :show, @video) %>" type='<%= @video.content_type %>'>
  <p class="vjs-no-js">
    To view this video please enable JavaScript, and consider upgrading to a web browser that
    <a href="" target="_blank">supports HTML5 video</a>

<%= link "Back", to: video_path(@conn, :index) %>

One thing to notice here is that is using the watch_path helper to request the video data. We need to create a route for this and the corresponding controller to send the video data to the browser.

First the route. Add this to web/router.ex, just after the line for the /videos resources:

get "/watch/:id", WatchController, :show

Now create web/controllers/watch_controller.ex and write this in it:

defmodule PhoenixVideoStream.WatchController do
  use PhoenixVideoStream.Web, :controller

  import PhoenixVideoStream.Util

  alias PhoenixVideoStream.Video

  def show(%{req_headers: headers} = conn, %{"id" => id}) do
    video = Repo.get!(Video, id)
    send_video(conn, headers, video)

We need to add the send_video/3 to the PhoenixVideoStream.Util module. Open the web/controllers/util.ex and add:

def send_video(conn, headers, video) do
 video_path = build_video_path(video)

 |> Plug.Conn.put_resp_header("content-type", video.content_type)
 |> Plug.Conn.send_file(200, video_path)

We are just building the video path and sending it to the browser with a 200 OK status response.

Go now to:


and click on the "Show" button of any of the videos in the list. You should see something like this:

Click on the Play button and voilà, the video starts!!

Time to go home…

Not so fast! You'll notice that you can't jump to specific time positions of the video. In fact, every time you click anywhere on the video seek bar, you'll see that the video starts over again. Bummer!

Let's correct that. The problem is that we're sending the file with a 200 response and the whole video data in the response to the browser. Each time you try to seek, the browser does a new request to the server asking for data at the position the user selected but the server always sends the whole data, no matter what. What we need to do is to send only partial content and then honor the requests that arrive to the server asking for the video data at a specific offset from the beginning of the file.

Change the send_video function in web/controllers/video.ex to:

def send_video(conn, headers, video) do
  video_path = build_video_path(video)
  offset = get_offset(headers)
  file_size = get_file_size(video_path)

  |> Plug.Conn.put_resp_header("content-type", video.content_type)
  |> Plug.Conn.put_resp_header("content-range", "bytes #{offset}-#{file_size-1}/#{file_size}")
  |> Plug.Conn.send_file(206, video_path, offset, file_size - offset)

We are getting the offset requested by the browser with the get_offset/1 function:

def get_offset(headers) do
  case List.keyfind(headers, "range", 0) do
    {"range", "bytes=" <> start_pos} ->
      String.split(start_pos, "-") |> hd |> String.to_integer
    nil ->

This function looks for a "range" header in the request and, if exists, parses the position (in bytes) that the browser requested. The range is in the format start-end so we take the start part and convert it to a number. If no header exists in the request, we assume that the browser wants the data of the video from the very beginning.

Then we find the file size in the storage in bytes, with the get_file_size/1 function:

def get_file_size(path) do
  {:ok, %{size: size}} = File.stat path


As the last step, we prepare the response headers so that the browser knows that this response contains only partial data and which part of the whole data it corresponds to. To that end we add a "content-range" header with the format:

"bytes #{offset}-#{file_size-1}/#{file_size}"

Then we use the extra parameters of Plug.Conn.send_file/4 to send the payload with a 206 Partial Content status, the offset and number of bytes we're sending.

Try again and the seek bar should work correctly now.

If you open your browser developer tools and inspect the requests that the browser sends to the server each time you click on the seek bar you will see the headers being sent back and forth to control the point where the video should be reproduced.

So, that's it!

You can find the source code at