[Write-Up: Video Hosting and Streaming Integration for Web. Bitmovin]

Summary

This write-up 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 maybe localhost/ in dev environment only); b)Copy and save Player Key and Analytics 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>