Tracking YouTube Video Watch Progress with Django
Your Django app serves a YouTube video. The page loads, the player appears, and your database records nothing. You have no idea whether the user pressed play, watched ten seconds, or finished the whole thing.
That gap matters. For an e-learning course, a gated module, or any feature that requires users to actually consume content, "the page loaded" is the wrong signal. You need to know how much of the video they watched, not where the scrubber sits right now, but the furthest point they have ever reached.
The YouTube IFrame Player API exposes playback position, duration, and state changes from JavaScript. Combined with a small Django backend, that is enough to track the maximum percentage each user has reached on each video and persist it per user.
This article walks through a videos app: one model, two views for tracking, one template with embedded JavaScript, and Django's built-in auth to tie saves to request.user.
A working demo is in the companion repository at github.com/nunombispo/youtube-video-player-article, clone it to follow along.
Architecture
Five logical components make up the system. The diagram shows what each one owns and which boundaries it crosses:

video_detail template - Django renders the page with the video ID from the URL and any stored progress for the current user. It owns the HTML structure: the player mount point, the progress bar, the CSRF token, and the script tags that load the IFrame API.
Progress tracker (JavaScript) - Runs in the browser after the template loads. It reads playback state from the YouTube player, maintains maxPercentage locally, updates the progress bar, and POSTs to Django when the value increases. It never talks to the database directly; every persist goes through the view.
YouTube IFrame Player - An external embed controlled through the IFrame Player API. It supplies getCurrentTime(), getDuration(), and onStateChange events. The tracker depends on it but does not own it, swap the video ID and the same tracker code works for any YouTube video.
Views - video_detail serves the page and queries existing progress on GET. update_progress receives JSON POSTs and writes to the model. @login_required on the update view ties each save to request.user through Django auth. The view is the trust boundary: it validates input and enforces the rule that stored progress only moves forward.
VideoProgress model - One row per (user, video_id) pair. Stores max_percentage and updated_at. The view reads it when rendering the page and writes it on each accepted POST.
Data model
Progress tracking needs one persistent fact per user per video: the highest percentage ever reached. A single model holds that.
unique_together on user and video_id enforces one row per pair. Without it, repeated saves could create duplicate rows for the same user watching the same video. video_id is a CharField, YouTube IDs are 11 characters, but 20 leaves room for other providers if you adapt the code later.
from django.contrib.auth import get_user_model
from django.db import models
User = get_user_model()
class VideoProgress(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
video_id = models.CharField(max_length=20)
max_percentage = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'video_id')
max_percentage only ever increases, in JavaScript on the client and again in the view on the server. The double guard handles out-of-order requests: a slow POST from 40% must not overwrite a record already at 60%. updated_at refreshes on every save via auto_now=True, which gives you a last-activity timestamp without extra view code.
Register the model in admin.py to inspect records during development:
from django.contrib import admin
from .models import VideoProgress
admin.site.register(VideoProgress)
Add 'videos' to INSTALLED_APPS in settings.py, then run makemigrations and migrate.
The model is persistence only. Next, connect it to a page the user actually loads.
Template
video_detail.html extends a shared base.html that loads Bootstrap 5 from a CDN. Layout and styling are incidental to tracking, what matters is how the template passes data from Django into the player and the JavaScript tracker.
The HTML snippets below show the tracking-relevant markup only; the demo repository wraps them in Bootstrap cards and columns.
The template expects two context variables: video_id from the URL and progress from the database. The video_detail view supplies both, which we will cover in the Views section below.
Player mount and CSRF: The player div carries the video ID as a data attribute. Django fills {{ video_id }} from the URL; JavaScript reads dataset.videoId without mixing template tags into the script logic.
{% csrf_token %}
<div class="ratio ratio-16x9 bg-dark">
<div id="player" data-video-id="{{ video_id }}"></div>
</div>
{% csrf_token %} renders a hidden input and ensures the csrftoken cookie is available. Django's CsrfViewMiddleware expects that cookie value back in the X-CSRFToken header on POST requests. Include the tag on any page that POSTs via fetch().
Seeding client state: When a user returns to a video they have partially watched, maxPercentage must start at the stored value, not zero. Otherwise a replay followed by an early tab close would under-report progress. The repo seeds maxPercentage, videoId, isAuthenticated, and updateProgressUrl at the top of a single {% block extra_js %} script, together with the IFrame API tag and the full tracker, see the JavaScript section below.
isAuthenticated lets the tracker skip POSTs for anonymous users. updateProgressUrl uses {% url %} so the path stays correct if you change URL patterns.
Progress bar: The bar renders from the same context on first load. JavaScript updates width and label as playback advances, so the user sees progress move without refreshing.
<div class="progress" style="height: 1.25rem;">
<div class="progress-bar bg-danger"
id="progress-bar"
role="progressbar"
style="width: {{ progress.max_percentage|default:0 }}%"
aria-valuenow="{{ progress.max_percentage|default:0 }}"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
<span class="badge" id="progress-label">{{ progress.max_percentage|default:0 }}%</span>
Debug panel: A sidebar table shows server-stored values alongside live client state: current position, player state, and the last POST response. Remove it in production; it saves time while wiring up the tracking logic and confirming saves land without a page refresh.
<!-- Remove in production -->
<table class="table table-sm table-striped mb-0">
<tbody>
<tr>
<th scope="row">Video ID</th>
<td id="debug-video-id">{{ video_id }}</td>
</tr>
<tr>
<th scope="row">Stored max</th>
<td id="debug-stored-max">{{ progress.max_percentage|default:0 }}%</td>
</tr>
<tr>
<th scope="row">Updated at</th>
<td id="debug-stored-updated">{% if progress %}{{ progress.updated_at }}{% else %}—{% endif %}</td>
</tr>
<tr>
<th scope="row">Client max</th>
<td id="debug-client-max">{{ progress.max_percentage|default:0 }}%</td>
</tr>
<tr>
<th scope="row">Current</th>
<td id="debug-current">—</td>
</tr>
<tr>
<th scope="row">State</th>
<td><span id="debug-state">—</span></td>
</tr>
<tr>
<th scope="row">Last save</th>
<td id="debug-last-save">—</td>
</tr>
</tbody>
</table>
IFrame API: Load the YouTube script after the player element exists, at the bottom of the template in a {% block extra_js %}:
<script src="https://www.youtube.com/iframe_api"></script>
The API loads asynchronously and calls onYouTubeIframeAPIReady when ready. Placing the script after the div and deferring initialization to that callback avoids YT is not defined errors.
Views
Three views cover the demo. home renders a landing page with a link to a sample video. video_detail and update_progress do the tracking work.
video_detail runs on GET. It looks up any existing VideoProgress row for the authenticated user and passes it to the template along with the video_id from the URL.
from django.shortcuts import render
from .models import VideoProgress
def video_detail(request, video_id):
progress = None
if request.user.is_authenticated:
progress = VideoProgress.objects.filter(
user=request.user, video_id=video_id
).first()
return render(request, 'videos/video_detail.html', {
'video_id': video_id,
'progress': progress,
})
Anonymous users get progress = None; the template seeds maxPercentage to 0. They can still watch the video. Saves are skipped in JavaScript and blocked by @login_required on the update view.
update_progress runs on POST. It parses JSON, validates the payload, and upserts with a monotonic guard.