> ## Documentation Index
> Fetch the complete documentation index at: https://bunnynet-cb9733c2-nathan-draft-sep-8.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload videos to Bunny Stream from Rails

> Upload video from the browser straight to Bunny Stream over TUS, with a Rails backend that creates each video and signs the upload.

Rails never sees the video file. It creates the video in Bunny Stream and signs an upload, and the browser sends the file to us over [TUS](/stream/tus-resumable-uploads). With import maps, none of it needs a JavaScript build.

<Card title="Rails example on GitHub" icon="https://mintcdn.com/bunnynet-cb9733c2-nathan-draft-sep-8/EMGBBzipJnaVqwed/logo/frameworks/rubyonrails.svg?fit=max&auto=format&n=EMGBBzipJnaVqwed&q=85&s=d9b04e57ec1bc772606d4a8e5a3d69ce" href="https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-rails" horizontal width="24" height="24" data-path="logo/frameworks/rubyonrails.svg">
  A Rails 8 app using import maps.
</Card>

## Quickstart

<Steps>
  <Step title="Add import maps">
    A default `rails new` app already uses import maps. An app made with `rails new --minimal` has no JavaScript set up, so add them.

    ```bash theme={null}
    bundle add importmap-rails
    bin/rails importmap:install
    ```
  </Step>

  <Step title="Add your library credentials">
    ```bash .env theme={null}
    BUNNY_STREAM_LIBRARY_ID=
    BUNNY_STREAM_API_KEY=
    ```

    Copy both from your library's **API** page. Rails won't read `.env` on its own, so load it with `dotenv-rails` in development.

    ```bash theme={null}
    bundle add dotenv-rails --group development
    ```
  </Step>

  <Step title="Create the Bunny Stream module">
    `sign_upload` hashes the library ID, API key, expiry, and video ID with SHA-256. The key never leaves this module.

    ```ruby app/models/bunny_stream.rb theme={null}
    require "net/http"

    module BunnyStream
      extend self

      class Error < StandardError
        # :not_found when Bunny Stream has no such video, :bad_gateway for anything else.
        attr_reader :status

        def initialize(message, status = :bad_gateway)
          super(message)
          @status = status
        end
      end

      # The status of a video still waiting for its file.
      CREATED = 0

      # Bunny checks the expiry on every TUS request, so leave room for slow uploads.
      SIGNATURE_TTL = 24.hours

      def video(video_id)
        video = stream(Net::HTTP::Get, "/#{ERB::Util.url_encode(video_id)}")

        {
          status: video["status"],
          encodeProgress: video["encodeProgress"],
          embedUrl: "https://player.mediadelivery.net/embed/#{library_id}/#{video_id}"
        }
      end

      def create_video(title)
        stream(Net::HTTP::Post, "", { title: }).fetch("guid")
      end

      def sign_upload(video_id)
        expiration_time = SIGNATURE_TTL.from_now.to_i
        signature = Digest::SHA256.hexdigest("#{library_id}#{api_key}#{expiration_time}#{video_id}")

        { videoId: video_id, libraryId: library_id, expirationTime: expiration_time, signature: }
      end

      private

      def library_id = ENV.fetch("BUNNY_STREAM_LIBRARY_ID")
      def api_key = ENV.fetch("BUNNY_STREAM_API_KEY")

      def stream(verb, path, body = nil)
        uri = URI("https://video.bunnycdn.com/library/#{library_id}/videos#{path}")
        request = verb.new(uri, "AccessKey" => api_key, "Accept" => "application/json", "Content-Type" => "application/json")
        request.body = body.to_json if body

        response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
        unless response.is_a?(Net::HTTPSuccess)
          status = response.is_a?(Net::HTTPNotFound) ? :not_found : :bad_gateway
          raise Error.new("Bunny Stream returned #{response.code}: #{response.body}", status)
        end

        JSON.parse(response.body)
      end
    end
    ```

    One `rescue_from` turns its errors into JSON for every controller.

    ```ruby app/controllers/application_controller.rb theme={null}
    class ApplicationController < ActionController::Base
      rescue_from BunnyStream::Error do |error|
        render json: { error: error.message }, status: error.status
      end
    end
    ```
  </Step>

  <Step title="Add the routes">
    <Warning>
      Put the upload routes behind your own authentication before you deploy. The create route makes a video in your library and hands back a signature that lets the caller upload into it. Left open, anyone who finds the URL can fill your library with uploads that you pay to store, encode, and deliver.

      Server Actions and API routes are public HTTP endpoints, even when nothing in your UI links to them. Check the user on every request, and check that they own a video ID before you re-sign it or return its status.
    </Warning>

    ```ruby config/routes.rb theme={null}
    Rails.application.routes.draw do
      root "home#show"

      namespace :api do
        resources :uploads, only: :create
        resources :videos, only: :show
      end
    end
    ```

    `resumable?` checks that we're still waiting on the file before re-signing an existing video.

    ```ruby app/controllers/api/uploads_controller.rb theme={null}
    class Api::UploadsController < ApplicationController
      def create
        # Require a signed-in user here. This route is public and creates videos in your library.
        # Before re-signing a videoId, check that the user owns it.
        title = params.require(:title)
        video_id = params[:videoId]
        video_id = BunnyStream.create_video(title) unless resumable?(video_id)

        render json: BunnyStream.sign_upload(video_id)
      end

      private

      def resumable?(video_id)
        video_id.present? && BunnyStream.video(video_id)[:status] == BunnyStream::CREATED
      rescue BunnyStream::Error
        false
      end
    end
    ```

    ```ruby app/controllers/api/videos_controller.rb theme={null}
    class Api::VideosController < ApplicationController
      def show
        # Require a signed-in user here, and check that they own this video ID. The route is public.
        render json: BunnyStream.video(params[:id])
      end
    end
    ```
  </Step>

  <Step title="Upload from the browser">
    The page needs a file input and somewhere to show the result. The default layout already renders `csrf_meta_tags` and `javascript_importmap_tags`.

    ```ruby app/controllers/home_controller.rb theme={null}
    class HomeController < ApplicationController
      def show; end
    end
    ```

    ```erb app/views/home/show.html.erb theme={null}
    <input type="file" id="video-file" accept="video/*">
    <div id="video-output"></div>
    ```

    Pin the upload code and tus-js-client, and import the upload code. Keep any pins and imports you already have. jsDelivr's `+esm` build bundles tus-js-client into one file, which an import map can pin.

    ```ruby config/importmap.rb theme={null}
    pin "application"
    pin_all_from "app/javascript/components", under: "components"
    pin "tus-js-client", to: "https://cdn.jsdelivr.net/npm/tus-js-client@4.3.1/+esm"
    ```

    ```js app/javascript/application.js theme={null}
    import "components/video_uploader";
    ```

    The `X-CSRF-Token` header carries the token from `csrf_meta_tags`. Rails rejects the `POST` without it.

    ```js app/javascript/components/video_uploader.js theme={null}
    import * as tus from "tus-js-client";

    async function requestUpload(title, videoId) {
      const response = await fetch("/api/uploads", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content,
        },
        body: JSON.stringify({ title, videoId }),
      });
      const body = await response.json();
      if (!response.ok) throw new Error(body.error ?? "Could not create the upload");

      return body;
    }
    ```

    tus-js-client sends the credentials as headers with every request. The video ID goes into `localStorage` against the file, which is how a reload finds its way back to the same upload. Add this to the same file.

    ```js theme={null}
    // Remembers which Bunny video a file was going into, so a reload can resume it.
    const videoKey = (file) => `bunny-video:${file.name}:${file.size}:${file.lastModified}`;

    // Aborting `signal` cancels the upload, even while it still waits on your server.
    export async function uploadVideo(file, { signal, onProgress, onSuccess, onError }) {
      const key = videoKey(file);
      const savedVideoId = localStorage.getItem(key);
      const credentials = await requestUpload(file.name, savedVideoId);
      if (signal?.aborted) return null;
      localStorage.setItem(key, credentials.videoId);

      const upload = new tus.Upload(file, {
        endpoint: "https://video.bunnycdn.com/tusupload",
        retryDelays: [0, 3000, 5000, 10000, 20000, 60000],
        removeFingerprintOnSuccess: true,
        headers: {
          AuthorizationSignature: credentials.signature,
          AuthorizationExpire: String(credentials.expirationTime),
          VideoId: credentials.videoId,
          LibraryId: credentials.libraryId,
        },
        metadata: { filetype: file.type, title: file.name },
        onProgress: (sent, total) => onProgress(Math.floor((sent / total) * 100)),
        onSuccess: () => {
          localStorage.removeItem(key);
          onSuccess(credentials.videoId);
        },
        onError,
      });

      // A stored upload URL belongs to one video, so only resume into the same one.
      const [previous] = await upload.findPreviousUploads();
      if (signal?.aborted) return null;
      if (previous && credentials.videoId === savedVideoId) {
        upload.resumeFromPreviousUpload(previous);
      }
      signal?.addEventListener("abort", () => upload.abort());
      upload.start();

      return upload;
    }
    ```

    `abort()` pauses. `start()` picks up from the last chunk we acknowledged.

    A 401 from the TUS endpoint means the signature doesn't match the headers. Check that the library ID and API key belong to the same library. A 400 means the expiry has already passed. Re-signing keeps the upload's original expiry, as the [TUS FAQ](/stream/tus-resumable-uploads#resumable-tus-upload-faq) explains.
  </Step>
</Steps>

## Play it once it's encoded

We start encoding when the last chunk arrives. Poll your status route until `status` reaches `4` (finished), `5` or `6` (failed), then embed `embedUrl`. This goes in the same file too.

```js theme={null}
const FINISHED = 4;
const FAILED = [5, 6];

// Returns a function that stops polling.
export function watchVideo(videoId, { onChange, onError }) {
  let timer;
  let active = true;

  async function poll() {
    const response = await fetch(`/api/videos/${videoId}`);
    const video = await response.json();
    if (!active) return;
    if (!response.ok) return onError(new Error(video.error ?? "Could not read the video status"));

    onChange(video);
    if (video.status !== FINISHED && !FAILED.includes(video.status)) {
      timer = setTimeout(() => poll().catch(onError), 3000);
    }
  }

  poll().catch(onError);

  return () => {
    active = false;
    clearTimeout(timer);
  };
}
```

`encodeProgress` gives you a percentage to show in the meantime. A [webhook](/stream/webhooks) tells your server when encoding finishes.

Then wire both to the page's `#video-file` input and `#video-output` element. Picking another file cancels the current upload.

```js theme={null}
const input = document.querySelector("#video-file");
const output = document.querySelector("#video-output");
let cancel = () => {};

function show(video) {
  if (FAILED.includes(video.status)) {
    output.textContent = "Bunny Stream could not encode the video.";
  } else if (video.status !== FINISHED) {
    output.textContent = `Encoding… ${video.encodeProgress}%`;
  } else {
    const player = Object.assign(document.createElement("iframe"), {
      src: video.embedUrl,
      allow: "autoplay; encrypted-media; picture-in-picture; fullscreen",
      allowFullscreen: true,
    });
    output.replaceChildren(player);
  }
}

input.addEventListener("change", async () => {
  const file = input.files[0];
  if (!file) return;

  cancel();
  const controller = new AbortController();
  let stopWatching = () => {};
  cancel = () => {
    controller.abort();
    stopWatching();
  };

  const onError = (error) => (output.textContent = error.message);
  output.textContent = "Uploading… 0%";
  try {
    await uploadVideo(file, {
      signal: controller.signal,
      onProgress: (percent) => (output.textContent = `Uploading… ${percent}%`),
      onSuccess: (videoId) => {
        stopWatching = watchVideo(videoId, { onChange: show, onError });
      },
      onError,
    });
  } catch (error) {
    if (!controller.signal.aborted) onError(error);
  }
});
```

Start the server with `bin/rails server`, open [http://localhost:3000](http://localhost:3000), and choose a video.

## Before you deploy

Add a `before_action` that requires a signed-in user and records who owns each video ID. In production, set the two variables on the host.

## Troubleshooting

<AccordionGroup>
  <Accordion title="/api/uploads returns 422 with InvalidAuthenticityToken">
    The layout needs `<%= csrf_meta_tags %>`, and the `fetch` needs the `X-CSRF-Token` header.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.