Simple video galery with Flask, Jinja, Bootstrap and JQuery
View videos by date and time with minimal clicks with Flask, Jinja, Bootstrap and JQuery

We have a number of cameras that generate short clips when something moves in front of the camera. All the clips, videos, end up on one system. We use standard programs to view the videos.
But now we want to share these videos with others on our local network, and we don't want to copy the video files. An obvious solution is to select and install something like a video gallery server. But, hey, this is a blog about Python, and this is a small project, so here we are creating this ourselves. We're not implementing HTTPS, or login, which means anyone on the local network can see the videos. If you want, you can restrict access by IP address.
As always, I'm doing this on Ubuntu 22.04, and the other machines on the local network are also running Ubuntu.
Available video galery software vs custom made project
Can we choose something like open source video gallery software? The problem is the required functionality in this case.
We want to minimize the number of clicks to view a video, and to view the next video, etc. This requires an always visible list of videos with the time of the videos, and the list can be long. Most image and video galleries show a page of thumbnails and clicking means a new page opens and we have to go back to select another video. Not what we want!
Summarizing, our screen should look like this:
+-------------------+-------------------------------+
| << | |
| > 2024-10-27 | |
| v 2024-10-26 | |
| CAM01 18:54:48 | |
| CAM01 18:53:17 | VIDEO |
| ... | |
| ... | |
| ... | |
| > 2024-10-25 | |
| > 2024-10-24 | |
| ... | |
| | |
| | |
| | |
+-------------------+-------------------------------+
Click on a time and the video is shown and run. Oh, and this does not need to be responsive, we always watch this on a monitor.
The project
This is a perfect task for Flask and Jinja. I already have Nginx running on my development machine. The videos are 'MP4' and are stored in folders, the names of these folders are dates. Flask is only used to select a date and videos. Clicking on a video link does not open a new page but playes the selected video in the video window of the page.
Here is the project directory structure:
├── app
│ ├── main.py
│ ├── static
│ │ └── videos
│ │ ├── 2024-10-23
│ │ │ └── ...
│ │ ├── 2024-10-24
│ │ │ └── ...
│ │ ├── 2024-10-25
│ │ │ ├── CAM01_101-01-20241025031350.mp4
│ │ │ ├── CAM01_101-01-20241025161248.mp4
│ │ │ └── ...
│ │ ├── 2024-10-26
│ │ │ ├── CAM01_101-01-20241026185317.mp4
│ │ │ ├── CAM01_101-02-20241026185448.mp4
│ │ │ └── ...
│ │ ├── CAM01_101-04-20241025231354.mp4
│ │ ├── CAM02_102-05-20241025231454.mp4
│ │ └── ...
│ └── templates
│ └── index.html
├── run.py
Create a virtual environment, a project directory, and install Flask.
Nginx
We use Nginx to serve our application:
- The Flask application, using a reverse proxy server
- The videos, from a directory
- The static content, from a directory
Because not all the data is from our Flask application, we assign a hostname (server) to our application:
motion-videos
We edit:
/etc/hosts
and add the line:
127.0.0.1 motion-videos
On other machines in our local network, we also add this line, but now with the IP address of our development machine, the one with the videos and running the application.
Then, to call our application in our browser, we type in the address bar:
http://motion-videos
Now Nginx knows which server we mean, and can also serve the videos and static content.
Here is the Nginx server file, our application is at port 6395, feel free to change this.
# /etc/nginx/sites-available/motion-videos
server {
listen 80;
server_name motion-videos;
access_log /var/log/nginx/motion-videos.access.log;
error_log /var/log/nginx/motion-videos.error.log;
location /static/ {
alias <YOUR-PROJECT-DIRECTORY>/app/static/;
}
location /videos/ {
alias <YOUR-PROJECT-DIRECTORY>/app/static/videos/;
}
location / {
proxy_pass http://127.0.0.1:6395;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 1M;
}
}
Make sure to add the location of your project here!
Then enable your server:
sudo ln -s /etc/nginx/sites-available/motion-videos /etc/nginx/sites-enabled/motion-videos
And restart Nginx:
sudo systemctl restart nginx
JQuery to select the video
The Flask application creates a list of video links in the template. When we click a video link, We use JQuery to run the new video as follows:
- Get the 'video run HTML template code', this is inside a 'div'
- Substitute values for the video info and video URL
- Replace the current video run HTML code ('div') by the new video HTML code
The code
For our Flask application, we use Bootstrap and JQuery. Here we use a CDN, but you can also install this local.
Our application shows two columns:
- Date selector, with links to the videos
- Video window
The date selector also shows the times of the videos for a selected date. We create two full height columns using flexbox. We cannot use Bootstrap columns as they are using 'float-left', and we also run into trouble with the long list of videos. The date selector can have more items than can be displayed on the screen meaning that we use a 'div' with 'overflow-auto'.
Here is the code. We bind our Flask application to 0.0.0.0 meaning that it is available to other machines on our local network. If you use a firewall, then open the port to allow other machines to connect.
# run.py
from app import main
app = main.create_app()
if __name__ == '__main__':
app.run(
host= '0.0.0.0',
port=6395,
use_debugger=True,
use_reloader=True,
)
The main thing we do in our Flask application is selecting the available dates and videos.
# main.py
import datetime
import glob
import os
import pathlib
import re
from flask import Flask, request, url_for, redirect, render_template
video_files_subdir = 'videos'
video_files_ext = '.mp4'
class Video:
def __init__(self, f, d):
self.d = d
self.filename = os.path.basename(f)
if d is None:
self.page_url = url_for('index', filename=self.filename)
self.play_url = f'/videos/{self.filename}'
else:
self.page_url = url_for('date_dir', date=d, filename=self.filename)
self.play_url = f'/videos/{d}/{self.filename}'
name_parts = pathlib.Path(self.filename).stem.split('-')
self.cam = name_parts[0].split('_')[0]
self.dt = datetime.datetime.strptime(name_parts[2], '%Y%m%d%H%M%S')
self.info = self.dt.strftime('%Y-%m-%d') + ' ' + self.filename
def create_app():
app = Flask(__name__)
app.jinja_env.auto_reload = True
app.config['TEMPLATES_AUTO_RELOAD'] = True
def get_dates_videos(date_dir=None):
# get date_dirs (dates)
base_dir = os.path.join(app.static_folder, video_files_subdir)
base_path = pathlib.Path(base_dir)
date_dirs = [os.path.split(x)[-1] for x in base_path.rglob('????-??-??') if x.is_dir()]
date_dirs.sort(reverse=True)
# get files
current_dir_parts = [base_dir]
if date_dir is not None:
current_dir_parts.append(date_dir)
current_dir_parts.append('*' + video_files_ext)
path = os.path.join(*current_dir_parts)
files = glob.glob(path)
files.sort(key=os.path.getmtime, reverse=True)
app.logger.info(f'date_dirs = {date_dirs}, files = {files}')
return dict(
dates=date_dirs,
videos=[Video(f, d=date_dir) for f in files],
date_selected=date_dir,
)
@app.route('/')
def index():
dates_videos_date = get_dates_videos()
return render_template('index.html', **dates_videos_date)
@app.route('/<date>')
def date_dir(date):
dates_videos_date = get_dates_videos(date)
return render_template('index.html', **dates_videos_date)
return app
In the template we show the dates and videos. The videos use a button that fires an event when clicked.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<title>Videos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<style>
body {
height: 100%;
}
.col-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: row;
}
.col-start {
min-width: 200px;
}
.col-end {
position: relative;
flex: 1;
}
.video-selector {
max-height: 100%;
width: 100%;
position:relative;
overflow-y:auto;
}
</style>
</head>
<body>
{% macro list_videos(videos, filename) %}
{% if videos and videos|length > 0 %}
{% for video in videos %}
<p class="my-0 small">
{{ video.cam }}
<button type="button"
class="btn btn-link btn-sm my-0 py-0 small video-button"
data-video-info="{{ video.info }}"
data-video-play-url="{{ video.play_url }}">
{{ video.dt.strftime('%H:%M:%S') }}
</button>
</p>
{% endfor %}
{% else %}
No videos found
{% endif %}
{% endmacro %}
<div class="col-container">
<div class="col-start">
<div class="video-selector p-2">
{% if dates and dates|length > 0 %}
<table class="table table-sm">
<tbody>
{% if date_selected %}
<tr><td></td><td>
<a href="{{ url_for('index') }}">
<<
</a>
</td></tr>
{% endif %}
{% for date in dates %}
{% set date_is_date_selected = false %}
{% if date_selected and date == date_selected %}
{% set date_is_date_selected = true %}
{% endif %}
<tr>
<td>
{{ 'v' if date_is_date_selected else '>' }}
</td>
<td>
<a href="{{ url_for('date_dir', date=date) }}">
{{ date }}
</a>
</td>
</tr>
{% if date_is_date_selected %}
<tr>
<td>
</td>
<td>
{{ list_videos(videos, filename) }}
</td>
</tr>
{% endif %}
{% endfor %}
{% if date_selected is none %}
<tr><td colspan="2">
{{ list_videos(videos, filename) }}
</td></tr>
{% endif %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="col-end bg-black text-light p-2">
<div id="video-area"></div>
</div>
</div>
<div id="video-area-template" class="visually-hidden">
<div id="video-area">
<p class="mb-0">{VIDEO_INFO}</p>
<div class="embed-responsive embed-responsive-16by9">
<video class="embed-responsive-item" controls autoplay muted>
<source src="{VIDEO_PLAY_URL}" type="video/mp4">
</video>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){
$('.video-button').click(function(){
var video_info = $(this).data('video-info');
var video_play_url = $(this).data('video-play-url');
var video_area_template = $('#video-area-template').html();
var video_area_data = video_area_template.replace('{VIDEO_INFO}', video_info);
video_area_data = video_area_data.replace('{VIDEO_PLAY_URL}', video_play_url);
$('#video-area').replaceWith(video_area_data);
});
});
</script>
</body>
</html>
To start our application, go to the project directory and type:
python run.py
Now point your browser at:
http://motion-videos
Summary
This is a small project with much to be desired but it works well enough for our purpose. Flask, Jinja, Bootstrap and JQuery, are simply incredible building blocks to create small web applications in a very short time.
Links / credits
Basic concepts of flexbox
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout/Basic_concepts_of_flexbox
Flask
https://flask.palletsprojects.com/en/stable
Jinja
https://jinja.palletsprojects.com/en/stable
Print raw HTTP request in Flask or WSGI
https://stackoverflow.com/questions/25466904/print-raw-http-request-in-flask-or-wsgi
Most viewed
- Using PyInstaller and Cython to create a Python executable
- Reducing page response times of a Flask SQLAlchemy website
- Using Python's pyOpenSSL to verify SSL certificates downloaded from a host
- Connect to a service on a Docker host from a Docker container
- Using UUIDs instead of Integer Autoincrement Primary Keys with SQLAlchemy and MariaDb
- SQLAlchemy: Using Cascade Deletes to delete related objects