Flask Video Streaming App Tutorial


Introduction
Hello! ๐ In this tutorial I will show you how to build a video streaming application using Flask and FFmpeg.
Overview
The application is designed as a Flask web server that manages video uploads, processes them using FFmpeg, and server adaptive streaming content to the end user. Adaptive streaming formats such as HLS (HTTP Live Streaming) and DASH (Dynamic Adaptive Streaming over HTTP) are essential for providing a smooth playback experience across varying network conditions.
File Uploading: Securely handling video file uploads, verifying file types and enforcing size limits.
Video Conversion: Converting uploaded videos to HLS and DASH formats using FFmpeg.
Streaming Endpoints: Providing endpoints that serve video streams to clients.
Modern UI: A responsive front-end that enables drag-and-drop file uploads, video listing and a dynamic video player.
Now that we have an overview of the application, let's start building the backend.๐ค
Building The Back End With Flask
Flask is chosen for its lightweight nature and simplicity. The code leverages several libraries including os, uuid, subprocess, logging and extensions such as Flask-Cors for handling cross origin.
First, we need to create a Python virtual environment. This can be done and activated with the following command:
python3 -m venv env && source env/bin/activate
Next create a new file called main.py and populate it with the required imports:
import os
import uuid
import subprocess
import logging
from flask import Flask, request, jsonify, send_from_directory, render_template
from werkzeug.utils import secure_filename
from flask_cors import CORS
Next we need to configure some variables for the directories and logging etc. Next add the following Python code:
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app) # Enable Cross-Origin Resource Sharing
# Configuration
UPLOAD_FOLDER = 'uploads'
OUTPUT_FOLDER = 'streams'
ALLOWED_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm'}
MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB max upload size
# Create necessary directories
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['OUTPUT_FOLDER'] = OUTPUT_FOLDER
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
Here we initialize the app and logging etc.
Next we need a helper function to ensure the uploaded file does not exceed 100MB and is a valid video format:
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
After that we will write a function that handles the conversion to HLS:
def convert_to_hls(input_path, output_dir):
"""Convert video to HLS format using FFmpeg"""
os.makedirs(output_dir, exist_ok=True)
hls_playlist = os.path.join(output_dir, 'playlist.m3u8')
# HLS conversion command
hls_cmd = [
'ffmpeg', '-i', input_path,
'-profile:v', 'baseline',
'-level', '3.0',
'-start_number', '0',
'-hls_time', '10',
'-hls_list_size', '0',
'-f', 'hls',
hls_playlist
]
try:
subprocess.run(hls_cmd, check=True)
logger.info(f"HLS conversion completed for {input_path}")
return True
except subprocess.CalledProcessError as e:
logger.error(f"HLS conversion failed: {e}")
return False
Now that we have a function to handle HLS we next need to handle the DASH side, below the above function add the following function:
def convert_to_dash(input_path, output_dir):
"""Convert video to DASH format using FFmpeg"""
os.makedirs(output_dir, exist_ok=True)
dash_playlist = os.path.join(output_dir, 'manifest.mpd')
# DASH conversion command
dash_cmd = [
'ffmpeg', '-i', input_path,
'-map', '0:v', '-map', '0:a',
'-c:v', 'libx264', '-x264-params', 'keyint=60:min-keyint=60:no-scenecut=1',
'-b:v:0', '1500k',
'-c:a', 'aac', '-b:a', '128k',
'-bf', '1', '-keyint_min', '60',
'-g', '60', '-sc_threshold', '0',
'-f', 'dash',
'-use_template', '1', '-use_timeline', '1',
'-init_seg_name', 'init-$RepresentationID$.m4s',
'-media_seg_name', 'chunk-$RepresentationID$-$Number%05d$.m4s',
'-adaptation_sets', 'id=0,streams=v id=1,streams=a',
dash_playlist
]
try:
subprocess.run(dash_cmd, check=True)
logger.info(f"DASH conversion completed for {input_path}")
return True
except subprocess.CalledProcessError as e:
logger.error(f"DASH conversion failed: {e}")
return False
Feel free to change any of the variables. Now that we've handled both HLS and DASH we can now start programming the API routes.
The first route is an index route that returns the index page:
@app.route('/')
def index():
return render_template('index.html')
After that we also need to handle a route for the user to upload a video file:
@app.route('/upload', methods=['POST'])
def upload_file():
# Check if the post request has the file part
if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400
file = request.files['file']
# If user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
return jsonify({'error': 'No selected file'}), 400
if file and allowed_file(file.filename):
# Generate a unique ID for this video
video_id = str(uuid.uuid4())
# Create directories for this video
video_upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], video_id)
os.makedirs(video_upload_dir, exist_ok=True)
stream_output_dir = os.path.join(app.config['OUTPUT_FOLDER'], video_id)
os.makedirs(stream_output_dir, exist_ok=True)
# Save the original file
filename = secure_filename(file.filename)
file_path = os.path.join(video_upload_dir, filename)
file.save(file_path)
logger.info(f"File uploaded: {file_path}")
# Create directories for each format
hls_output_dir = os.path.join(stream_output_dir, 'hls')
dash_output_dir = os.path.join(stream_output_dir, 'dash')
# Process video asynchronously
def process_video():
# Convert to HLS
hls_result = convert_to_hls(file_path, hls_output_dir)
# Convert to DASH
dash_result = convert_to_dash(file_path, dash_output_dir)
return hls_result and dash_result
success = process_video()
if success:
return jsonify({
'id': video_id,
'status': 'success',
'hls_url': f'/stream/{video_id}/hls/playlist.m3u8',
'dash_url': f'/stream/{video_id}/dash/manifest.mpd',
'player_url': f'/player/{video_id}'
})
else:
return jsonify({'error': 'Conversion failed'}), 500
return jsonify({'error': 'File type not allowed'}), 400
The above route c # For simplicity, we'll process synchronously in this example # In production, use a task queue like Celeryhecks that the file is valid and if so converts it to both HLS and DASH, if ok it returns both the HLS and DASH stream information.
Next we will create an endpoint for the video stream:
@app.route('/stream/<video_id>/<format_type>/<path:filename>')
def stream_file(video_id, format_type, filename):
"""Serve the video stream files"""
directory = os.path.join(app.config['OUTPUT_FOLDER'], video_id, format_type)
return send_from_directory(directory, filename)
After that we will next create an endpoint that allows for the video to be played:
@app.route('/player/<video_id>')
def player(video_id):
"""Render the video player page"""
# Make sure we're explicitly passing video_id to the template
hls_url = f'/stream/{video_id}/hls/playlist.m3u8'
dash_url = f'/stream/{video_id}/dash/manifest.mpd'
return render_template('player.html', video_id=video_id, hls_url=hls_url, dash_url=dash_url)
Lastly we will create an endpoint that allows the users to view all video files available:
@app.route('/videos')
def video_list():
"""List all available videos"""
videos = []
# Get all subdirectories in the streams folder
for video_id in os.listdir(app.config['OUTPUT_FOLDER']):
video_dir = os.path.join(app.config['OUTPUT_FOLDER'], video_id)
if os.path.isdir(video_dir):
hls_path = os.path.join(video_dir, 'hls', 'playlist.m3u8')
dash_path = os.path.join(video_dir, 'dash', 'manifest.mpd')
if os.path.exists(hls_path) or os.path.exists(dash_path):
videos.append({
'id': video_id,
'hls_url': f'/stream/{video_id}/hls/playlist.m3u8' if os.path.exists(hls_path) else None,
'dash_url': f'/stream/{video_id}/dash/manifest.mpd' if os.path.exists(dash_path) else None,
'player_url': f'/player/{video_id}'
})
return jsonify(videos)
At the end we need a main function to run the application:
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
Phew! Now that the backend is finally done we can now work on the frontend. ๐
Building The Front End
First create a new folder called "templates" and inside that directory create a new file called "index.html" and populate it with the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Streaming App</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.10.2/cdn.min.js" defer></script>
</head>
<body class="bg-gray-100 min-h-screen">
<div class="container mx-auto px-4 py-8" x-data="{
dragging: false,
file: null,
fileName: '',
uploading: false,
progress: 0,
response: null,
error: null,
videos: []
}" x-init="fetch('/videos')
.then(response => response.json())
.then(data => { videos = data })
.catch(err => { error = 'Failed to load videos' })">
<h1 class="text-3xl font-bold text-center mb-8">Video Streaming App</h1>
<!-- Upload Section -->
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">Upload Video</h2>
<!-- Drag & Drop Area -->
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="{ 'border-blue-500 bg-blue-50': dragging, 'border-gray-300': !dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="
dragging = false;
const droppedFile = $event.dataTransfer.files[0];
if (droppedFile) {
file = droppedFile;
fileName = file.name;
}
"
>
<template x-if="!file">
<div>
<svg class="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<p class="mt-2 text-sm text-gray-600">Drag & drop your video file or</p>
<label class="mt-2 inline-block px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 cursor-pointer">
Browse Files
<input type="file" class="hidden" accept="video/*" @change="
file = $event.target.files[0];
if (file) fileName = file.name;
">
</label>
</div>
</template>
<template x-if="file">
<div>
<p class="text-sm font-medium" x-text="fileName"></p>
<button
class="mt-2 px-3 py-1 bg-red-500 text-white text-xs rounded hover:bg-red-600"
@click.prevent="file = null; fileName = ''"
>
Remove
</button>
</div>
</template>
</div>
<!-- Upload Button -->
<div class="mt-4">
<button
class="w-full py-2 px-4 bg-blue-500 text-white font-medium rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
:disabled="!file || uploading"
@click="
uploading = true;
error = null;
response = null;
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progress = Math.round((e.loaded * 100) / e.total);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
response = JSON.parse(xhr.responseText);
videos.push(response);
} else {
try {
error = JSON.parse(xhr.responseText).error;
} catch (e) {
error = 'Upload failed';
}
}
uploading = false;
file = null;
fileName = '';
progress = 0;
});
xhr.addEventListener('error', () => {
error = 'Network error';
uploading = false;
progress = 0;
});
xhr.send(formData);
"
>
<span x-show="!uploading">Upload Video</span>
<span x-show="uploading">
<span x-text="`Uploading ${progress}%`"></span>
</span>
</button>
</div>
<!-- Success/Error Messages -->
<div class="mt-4">
<div x-show="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<span x-text="error"></span>
</div>
<div x-show="response" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative">
<p>Video uploaded successfully!</p>
<p class="text-sm">
<a :href="response.player_url" class="underline" target="_blank">Click here to view</a>
</p>
</div>
</div>
</div>
<!-- Video List -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold mb-4">Your Videos</h2>
<div x-show="videos.length === 0" class="text-center text-gray-500 py-8">
No videos uploaded yet
</div>
<div x-show="videos.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<template x-for="video in videos" :key="video.id">
<div class="border rounded-lg overflow-hidden">
<div class="bg-gray-200 h-40 flex items-center justify-center">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="p-4">
<h3 class="font-medium mb-2" x-text="'Video ' + video.id.substring(0, 8)"></h3>
<div class="flex space-x-2">
<a :href="video.player_url" class="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600" target="_blank">
Play
</a>
<a x-show="video.hls_url" :href="video.hls_url" class="px-3 py-1 bg-gray-500 text-white text-sm rounded hover:bg-gray-600" target="_blank">
HLS
</a>
<a x-show="video.dash_url" :href="video.dash_url" class="px-3 py-1 bg-gray-500 text-white text-sm rounded hover:bg-gray-600" target="_blank">
DASH
</a>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</body>
</html>
This page is the entry point of the application, it is built using:
Tailwind CSS: For fast and responsive styling
Alpine.js: For handling reactive state without the overhead of a full fledged framework
Drag and Drop File Upload: The drag-and-drop interface makes it easy for users to upload videos. When a file is dropped, the Alpine.js component updates the file state and displays the files name.
XHR Upload Process: The file is uploaded via an XMLHttpRequest. During the file upload, the progress is tracked and dynamically updated, providing real-time feedback to the user.
Video Listing: After a successful upload, the video list is updated with new entries. Each video entry includes buttons to play the video and view different streaming formats.
The implementation demonstrates careful attention to user feedback and error handling. For instance, if the upload fails, an error message is displayed to guide the user.
Now thats taken care of we can now code the player page, under templates create a new file called player.html and populate it with the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Player</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/video.js/7.20.3/video-js.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/video.js/7.20.3/video.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-hls/5.15.0/videojs-contrib-hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dashjs/4.0.1/dash.all.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-dash/4.2.1/videojs-dash.min.js"></script>
<style>
.video-container {
position: relative;
padding-top: 56.25%; /* 16:9 Aspect Ratio */
}
.video-container .video-js {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<div class="container mx-auto px-4 py-8">
<div class="mb-4">
<a href="/" class="text-blue-500 hover:underline">← Back to all videos</a>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<h1 class="text-2xl font-bold mb-4">Video Player</h1>
<div class="video-container">
<video
id="my-video"
class="video-js vjs-big-play-centered"
controls
preload="auto"
width="640"
height="360"
data-setup='{"html5": {"hls": {"withCredentials": true}}}'
>
<source src="{{ hls_url }}" type="application/x-mpegURL">
<source src="{{ dash_url }}" type="application/dash+xml">
<p class="vjs-no-js">
To view this video please enable JavaScript, and consider upgrading to a
web browser that supports HTML5 video
</p>
</video>
</div>
<div class="mt-4">
<h3 class="font-medium mb-2">Stream URLs:</h3>
<ul class="space-y-1">
<li>
<a href="{{ hls_url }}" class="text-blue-500 hover:underline" target="_blank">HLS Stream</a>
</li>
<li>
<a href="{{ dash_url }}" class="text-blue-500 hover:underline" target="_blank">DASH Stream</a>
</li>
</ul>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var player = videojs('my-video', {
techOrder: ["html5"],
sources: [
{
src: "{{ hls_url }}",
type: "application/x-mpegURL"
},
{
src: "{{ dash_url }}",
type: "application/dash+xml"
}
]
});
player.on('error', function() {
console.log('Video player error:', player.error());
});
});
</script>
</body>
</html>
This page is designed to offer a robust video playback experience, it uses the following:
Video.js: A popular HTML5 video player that simplifies the integration of HLS and DASH streams.
HLS and DASH Sources: Both streaming formats are provided as source elements, allowing the player to choose the best method supported by the browser
Responsive Design: The video player is contained within a responsive container, ensuring compatibility with various screen sizes.
Error Handling: The player listens for error events, and any issues are logged to the console for debugging purposes.
This setup highlights how combining modern libraries and frameworks can yield a powerful, production-ready video streaming solution.
Running The Application
Now that the application is built, we can actually run it, first create a new requirements.txt file and populate it with the following:
flask
flask-cors
Werkzeug
Then run the following command to install the modules:
pip install -r requirements.txt
Then run the server with the following:
python main.py
If you direct your browser to http://localhost:5000 you should see the index page, try uploading a video file and viewing it. ๐
Main Page:
Player Page:
Deployment Considerations
If you plan to deploy this application to a production environment, you will need to consider the following:
Async Processing
This code provides video processing synchronously, production systems should offload intensive tasks to background workers like Celery. This decouples the user experience from backend processing, ensuring the uploads are fast and responsive.
Security
File Validation: Always ensure that uploaded files are validated both by type and by content.
Directory Permissions: The upload and output directories should have strict permissions to prevent unauthorized access.
CORS and CSRF Protection: Although Flask-CORS is used, further measures (like CSRF tokens) might be needed for securing the API endpoints
Scalability
For high traffic, consider using a WSGI server such as Gunicorn or uWSGI behind a reverse proxy like Nginx. Additionally using cloud storage solutions for storing video files can offload file system storage from the application server.
Monitoring and Logging
The application uses Python's build in logging, but in production, integrate with centralized logging and monitoring systems. This ensures that errors, performance bottlenecks, and security issues are promptly identified and addressed.
Conclusion
In this tutorial I have shown you how to build a simple video streaming app using Python, Flask and FFmpeg.
I hope this tutorial has been of use to you and as always you can find the source code on my github: https://github.com/ethand91/flask-streamer
Happy Coding! ๐
Like my work? I post about a variety of topics, if you would like to see more please like and follow me. Also I love coffee.
If you are looking to learn Algorithm Patterns to ace the coding interview I recommend the [following course](https://algolab.so/p/algorithms-and-data-structure-video-course?affcode=1413380_bzrepgch
Subscribe to my newsletter
Read articles from Ethan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Ethan
Ethan
Programmer/System Admin. Currently working on media solutions. Love learning new things :) Posts include a variety of topics, mostly media related.