[Video Hosting and Streaming Integration for Web Apps Using Bitmovin]
Summary
This article explores the integration of video hosting and streaming tools into web apps, using Bitmovin as a primary example. The text consists of 2 parts: a story about selecting the right tool for video streaming and a detailed tutorial on setting up Bitmovin. The tutorial covers dashboard settings, and back-end and front-end integration, providing a comprehensive guide for developers.
A story of selecting the right tool
In a recent project, our team needed to create a comprehensive user flow for video uploading, encoding, hosting, playback, and analytics. The goal was to offer an experience similar to TikTok or YouTube Shorts. After evaluating several options, we chose Bitmovin as the best option for our needs.
Rejected options
- Google Drive/YouTube API: While free to some extent, these services have strict usage quotas (as of Jun 2024: max. 6 video uploads per day). Increasing these quotas involves complex procedures.
- Amazon S3: Though it offers more freedom for hosting, the video processing service development and maintenance costs were prohibitive.
Solution
We concluded that we needed to offload infrastructure responsibilities to a 3rd party and use a pay-as-you-go pricing plan to avoid external limits. Bitmovin proved to be the right tool for our needs.
Bitmovin provides a robust service for video uploading, hosting, playback, encoding, and analytics, all under one roof. It’s been around since 2012 and offers a comprehensive product called Streams, launched in 2022. Let’s focus on Streams, since it's Bitmovin’s most useful and powerful option .
Pros
- Delegated processing: Video processing and serving are handled externally, allowing my team to focus on other aspects of development.
- Extensive documentation: Bitmovin offers detailed guides, API references, code examples, and an active developer community.
- Comprehensive tools: Bitmovin’s suite of tools addresses most video-related needs.
- SDKs availability: SDKs are available for various languages, including Python, JS, C#, Java, Go, and PHP.
- Optimized Services: a) Encoding: Customizable and reasonably fast (1-5 minutes per video depending on its duration, enabled codecs, resolutions, bitrates, etc). b) Player: Configurable and responsive across devices, with customizable styles and event handlers. c) Analytics: Offers extensive options for queries, stats, and visualizations.
Cons
- Complexity: Requires knowledge of video processing and playing concepts such as codecs, muxing, manifests, etc.
- Functionality and documentation overload: the product has a long history, accumulating lots of functionality and documentation that can be overwhelming for new users.
- Integration challenges: Numerous cloud service integrations (like S3 or Akamai) can be complex and distinct from one another.
- Multiple solutions: Many problems can be solved in different ways, making it challenging to choose the best approach.
Advice on finding the right tool
Determine demands
Clarify why you need video integration and the extent of this integration. Decide if you need features like video upload, hosting, playback, or analytics.
Estimate required resources
- Small and low-budget apps: Google Drive API or YouTube API might suffice and are free, to an extent.
- Complex, large-scale projects: Consider developing an in-house system for more control and long-term cost savings.
- Startups: Tools like Bitmovin offer scalability and quick development with a pay-as-you-go pricing model.
Conduct initial research
Thoroughly research the tool you plan to integrate. Make sure the chosen tool suffices your needs and there's no issues that would lead you to abandon the tool later.
- Understand its pricing plans, usage quotas, potential discounts, and trial periods.
- Familiarize yourself with the underlying technology, dependencies, and potential risks.
- Create a clear integration plan outlining major steps and prerequisites.
Bitmovin integration tutorial
Dashboard settings
- Sign up for Bitmovin Streams Trial
- Setup an account for your organization
- Copy and save API key
- Configure video and audio encoding for your videos. By default, Bitmovin provides an extensive list of codecs ranging from FHD (width - 1920px, compression - H265, bitrate - 7.8 Mbit/s) to SD (416px, H264, 145 kbit/s). You can remove unnecessary ones and thus reduce some costs
- Create an analytics license and player license to register impressions and collect other stats: a) Add allowed domains (e.g.:
your_app.com/
, and maybelocalhost/
in dev environment only); b)Copy and savePlayer Key
andAnalytics Key
- Create a player for each platform you use (e.g. web, ios, android, smart TV)
- Store keys into env variables. My
.env
file looks like this:
# Backend envs
BITMOVIN_API_KEY=
BITMOVIN_ANALYTICS_KEY=
BITMOVIN_ORGANIZATION_ID=
# Frontend envs
BITMOVIN_PLAYER_KEY=
BITMOVIN_ANALYTICS_KEY=
Back-end
Choose tool set
See the full list of SDKs and follow the installation instructions. In my case, I didn't need to use SDK and interacted with Bitmovin API directly.
Upload video
Since my project doesn't have cloud storage, we decided to post our videos directly on Bitmovin storage (they use Amazon S3).
Firstly, we use Direct File Upload Input, i.e. post our video file directly to Bitmovin storage.
Input — configuration for Bitmovin to access a specific file storage, from which the encoder will download the source video file
Stream — processed source video ready to be played in Bitmovin player
ℹ️ Following example is written in Django 4.2.
BITMOVIN_DIRECT_UPLOAD_API = (
"<https://api.bitmovin.com/v1/encoding/inputs/direct-file-upload>"
)
UPLOAD_BITMOVIN_HEADERS = {
"accept": "application/json",
"content-type": "application/json",
"X-Api-Key": config("BITMOVIN_API_KEY"),
"X-Tenant-Org-Id": config("BITMOVIN_ORGANIZATION_ID"),
}
def _store_asset(self, video_file_path: str, video_name: str) -> str:
payload = {"name": f"input_{video_name}"}
# get a link from Bitmoivin that we can use to upload the video. Link expires in 5 min
direct_upload_response = requests.post(
BITMOVIN_DIRECT_UPLOAD_API,
headers=self.UPLOAD_BITMOVIN_HEADERS,
json=payload,
)
direct_upload_response.raise_for_status()
result = direct_upload_response.json().get("data").get("result")
# upload_url -
upload_url = result.get("uploadUrl")
input_id = result.get("id")
if not input_id or not upload_url:
raise ValueError("Failed to upload video")
# store the video file to the provided upload_url
with open(video_file_path, "rb") as file_to_upload:
response = requests.put(upload_url, data=file_to_upload)
response.raise_for_status()
return input_id
Create stream from the input we created earlier
BITMOVIN_STREAMS_API = "<https://api.bitmovin.com/v1/streams/video>"
asset_link = f"{BITMOVIN_DIRECT_UPLOAD_API}/{input_id}"
def _create_stream(self, asset_link: str, video_name: str) -> str | None:
payload = {
"encodingProfile": "PER_TITLE",
"assetUrl": asset_link,
"title": video_name,
"signed": False,
}
response = requests.post(
BITMOVIN_STREAMS_API, json=payload, headers=self.UPLOAD_BITMOVIN_HEADERS
)
response.raise_for_status()
return json.loads(response.text).get("data").get("result").get("id")
All together it looks like this:
def upload_to_cloud(self, video_file_path: str, video_name: str) -> (str, str):
# create input
input_id = self._store_asset(video_file_path, video_name)
asset_link = f"{BITMOVIN_DIRECT_UPLOAD_API}/{input_id}"
# create stream
stream_id = self._create_stream(asset_link, video_name)
return input_id, stream_id
Finally, after successfully uploading the video to Bitmovin, store the stream link to your own database to access it on user demand without querying Bitmovin API.
BITMOVIN_STREAMS_DOMAIN = "<https://streams.bitmovin.com>"
def save_video_record(
self,
owner: Owner,
input_id: str,
stream_id: str,
title: str
) -> None:
link = f"{BITMOVIN_STREAMS_DOMAIN}/{stream_id}/manifest.m3u8"
# here you can paste logic for overwriting existing video record in case of reupload
video_record = Video.objects.create(
owner=owner,
link=link,
input_id=input_id,
stream_id=stream_id,
title=title,
)
video_record.save()
Get video
Here's how you can query all streams
ACTION_BITMOVIN_HEADERS = {
"accept": "application/json",
"X-Api-Key": config("BITMOVIN_API_KEY"),
"X-Tenant-Org-Id": config("BITMOVIN_ORGANIZATION_ID"),
}
def list_all_streams(self, offset=None):
url = (
f"{BITMOVIN_STREAMS_API}?offset={offset}"
if offset
else BITMOVIN_STREAMS_API
)
response = requests.get(url, headers=self.ACTION_BITMOVIN_HEADERS)
response.raise_for_status()
items = response.json().get("data", {}).get("result", {}).get("items", [])
return items
When you just need a certain video, you can query stream from Bitmovin with stream_id
or use your Video
model:
video = Video.objects.get(stream_id=stream_id)
Also, consider adding some checks to restrict unauthorized access to the video
Delete video
Delete input and stream from the cloud
def execute_delete(
self,
input_id: str | None,
stream_id: str | None
) -> (str | None, str | None):
input_result, stream_result = None, None
# delete input
if input_id:
delete_input = requests.delete(
f"{BITMOVIN_DIRECT_UPLOAD_API}/{input_id}",
headers=self.ACTION_BITMOVIN_HEADERS,
)
delete_input.raise_for_status()
input_result = (
delete_input.json().get("data", {}).get("result", {}).get("id")
)
# delete stream
if stream_id:
delete_stream = requests.delete(
f"{BITMOVIN_STREAMS_API}/{stream_id}",
headers=self.ACTION_BITMOVIN_HEADERS,
)
delete_stream.raise_for_status()
stream_result = delete_stream.json().get("data", {}).get("result")
return input_result, stream_result
If you've stored some info about the video in your database (as in the Video
model), clear the associated record:
def cleanup_after_delete(self, video: Video) -> None:
video_object = video
try:
video.delete()
# all other necessary cleanups
except Exception as e:
print(f"Failed to delete video record: {e}")
raise e
Get video views and other stats
To get video views, use count query
BITMOVIN_ANALYTICS_QUERY = "<https://api.bitmovin.com/v1/analytics/queries>"
def get_video_views(
stream_id: str,
start_date: str | None = None,
end_date: str | None = None
) -> int:
headers = {
"accept": "application/json",
"content-type": "application/json",
"X-Api-Key": config("BITMOVIN_API_KEY"),
"X-Tenant-Org-Id": config("BITMOVIN_ORGANIZATION_ID"),
}
payload = {
"dimension": "IMPRESSION_ID", # count by IMPRESSION_ID
"includeContext": False,
"offset": 0,
"licenseKey": config("BITMOVIN_ANALYTICS_KEY"),
"filters": [
{"name": "VIDEO_STARTUPTIME", "operator": "GT", "value": 0},
{"name": "VIDEO_ID", "operator": "EQ", "value": stream_id},
],
}
payload["start"] = start_date if start_date else "2024-06-01T00:00:00.00Z" # or any other static start date
if end_date:
payload["end"] = end_date
response = requests.post(
f"{BITMOVIN_ANALYTICS_QUERY}/count",
json=payload,
headers=headers
)
response.raise_for_status()
view_count = response.json().get("data", {}).get("result", {}).get("rows", [])[0][0]
return view_count
You can make similar analytics queries with methods like: Sum, Avg, Min, Max, etc.
Possible use cases: video plays per country, per platform, average view time.
Frontend
Dependencies
Install bitmovin-player and optionally bitmovin-player-ui if you want to customize the default UI
Setup player component
ℹ️ The following example is written in Vue 3 Composition API + TS
The template is just a placeholder where the player component would be injected when the video is loaded
<template>
<div v-if="showPlayer" ref="playerContainer">
<video ref="videoTag" />
</div>
</template>
The props for this component look like this
interface Config {
autoplay?: boolean;
aspectRatio?: string;
height?: string;
width?: string;
title?: string;
fullScreen?: boolean;
onFinish?: () => Promise<void> | void;
beforePlay?: (player: PlayerAPI, video: Video) => Promise<void> | void;
}
interface Props {
video: Video;
config: Config;
}
const props = defineProps<Props>()
const { video, config } = toRefs(props);
Player configuration
Define ref values for the player element in DOM, its container, PlayerAPI and UIManager
.
Explore available attributes and methods on Player API interface
// flag to determine, whether to show or hide player
const showPlayer = ref(true);
// HTMLElements where player container and player are rendered
const playerContainer = ref<HTMLElement>();
const videoTag = ref<HTMLElement>();
const player = ref<PlayerAPI | null>(null);
const UIManager = ref<UIManager | null>(null);
Define computed values to store player settings
const playerConfig = computed(() => ({
key: BITMOVIN_KEYS.LICENSE_KEY,
playback: {
muted: false,
autoplay: config.value?.autoplay || false,
},
analytics: {
key: BITMOVIN_KEYS.ANALYTICS_KEY,
videoId: video.value.stream_id,
},
style: {
aspectratio: config.value?.aspectRatio || '9:16',
// set `playerContainer` as HTMLElement for player
container: () => playerContainer.value as HTMLElement,
},
// disable default styles. We manage it ourselves
ui: false,
}));
const source = computed(() => ({
title: config.value?.title,
hls: video.value.link,
}));
Create a player when the component is mounted
onMounted(() => {
// prevent premature attempts to create Player
if (!playerContainer.value || !videoTag.value) return
// create brand-new instance of Player class
const localPlayer = new Player(
playerContainer.value,
playerConfig.value,
);
// set it to videoTag ref, to render it in template
localPlayer.setVideoElement(videoTag.value);
// set listener for `PlaybackFinished` event
if (config.value?.onFinish)
localPlayer.on(PlayerEvent.PlaybackFinished, config.value.onFinish);
// initiate video load in Player
localPlayer.load(source.value);
// set listener for `Play` event
if (config.value?.beforePlay)
localPlayer.on(
PlayerEvent.Play,
async () => await config.value.beforePlay(localPlayer, video.value),
);
// configure UI
const uiConfig = {
enterFullscreenOnInitialPlayback: config.value?.fullScreen || false,
};
UIManager.value = UIFactory.buildDefaultUI(localPlayer, uiConfig);
// update PlayerAPI variable in parent context with created Player
player.value = localPlayer;
});
Update the player on each change in video
prop using the load
method
watch(
video,
(newVideo, oldVideo) => {
if (newVideo.link === oldVideo.link) return;
// in case of invalid link remove player component
if (!newVideo.link) {
playerContainer.value = null;
videoTag.value = null;
player.value = null;
showPlayer.value = false;
return;
}
player.value?.load({
title: config.value?.title,
hls: newVideo.link,
});
showPlayer.value = true;
},
{ deep: true },
);
Clean up ref values when the player is not needed anymore to avoid memory leaks
onBeforeUnmount(() => {
player.value = null;
UIManager.value.release();
});
Customize UI styles
Check out this demo to see a general idea of how it may look
You can use the default UI manager and just replace CSS file:
const config = {
...,
location: {
ui: '//domain.tld/path/to/bitmovinplayer-ui.js',
ui_css: 'styles/bitmovinplayer-ui.css',
},
};
const player = new Player(playerContainer.value, config);
I prefer disabling the default UI manager and creating my own to get greater control over UI:
// create Player API
const config = {
...,
ui: false, // disable the built-in UI
};
const player = new Player(playerContainer.value, config);
// create custom UI manager
const uiConfig = {
enterFullscreenOnInitialPlayback: config.value?.fullScreen || false,
};
UIManager.value = UIFactory.buildDefaultUI(localPlayer, uiConfig);
There are other ways to do this. Please, refer to bitmovin-player-ui docs
If you use a custom UI manager and still want to have the default bitmovin UI as a starting point, you should import the styles for it:
import 'bitmovin-player-ui/dist/css/bitmovinplayer-ui.css';
If you want to change just some CSS classes in the default UI, find class names you want to override using the CSS classes reference or just traverse the DOM tree with dev tools.
Here's an example of how you can override default CSS classes:
<style>
.bmpui-ui-watermark {
display: none !important;
}
.bitmovinplayer-container {
border-radius: 20px;
}
</style>
The whole setup
<template>
<div v-if="showPlayer" ref="playerContainer">
<video ref="videoTag" />
</div>
</template>
<script setup lang="ts">
import { Player, PlayerAPI, PlayerEvent } from 'bitmovin-player';
import { UIFactory, UIManager } from 'bitmovin-player-ui';
import 'bitmovin-player-ui/dist/css/bitmovinplayer-ui.css';
interface Config {
autoplay?: boolean;
aspectRatio?: string;
height?: string;
width?: string;
title?: string;
fullScreen?: boolean;
onFinish?: () => Promise<void> | void;
beforePlay?: (player: PlayerAPI, video: Video) => Promise<void> | void;
}
interface Props {
video: Video;
config: Config;
}
const props = defineProps<Props>()
const { video, config } = toRefs(props);
const showPlayer = ref(true);
const playerContainer = ref<HTMLElement>();
const videoTag = ref<HTMLElement>();
const player = ref<PlayerAPI | null>(null);
const UIManager = ref<UIManager | null>(null);
const playerConfig = computed(() => ({
key: BITMOVIN_KEYS.LICENSE_KEY,
playback: {
muted: false,
autoplay: config.value?.autoplay || false,
},
analytics: {
key: BITMOVIN_KEYS.ANALYTICS_KEY,
videoId: video.value.stream_id,
},
style: {
aspectratio: config.value?.aspectRatio || '9:16',
container: () => playerContainer.value as HTMLElement,
},
ui: false,
}));
const source = computed(() => ({
title: config.value?.title,
hls: video.value.link,
}));
onMounted(() => {
if (!playerContainer.value || !videoTag.value) return
const localPlayer = new Player(
playerContainer.value,
playerConfig.value,
);
localPlayer.setVideoElement(videoTag.value);
if (config.value?.onFinish)
localPlayer.on(PlayerEvent.PlaybackFinished, config.value.onFinish);
localPlayer.load(source.value);
if (config.value?.beforePlay)
localPlayer.on(
PlayerEvent.Play,
async () => await config.value.beforePlay(localPlayer, video.value),
);
const uiConfig = {
enterFullscreenOnInitialPlayback: config.value?.fullScreen || false,
};
UIManager.value = UIFactory.buildDefaultUI(localPlayer, uiConfig);
player.value = localPlayer;
});
watch(
video,
(newVideo, oldVideo) => {
if (newVideo.link === oldVideo.link) return;
if (!newVideo.link) {
playerContainer.value = null;
videoTag.value = null;
player.value = null;
showPlayer.value = false;
return;
}
player.value?.load({
title: config.value?.title,
hls: newVideo.link,
});
showPlayer.value = true;
},
{ deep: true },
);
onBeforeUnmount(() => {
player.value = null;
UIManager.value.release();
});
</script>
<style>
.bmpui-ui-watermark {
display: none !important;
}
.bitmovinplayer-container {
border-radius: 20px;
}
</style>