Django视频点播系统实战:数据模型、上传转码与播放进度全链路
简介:这是一套面向高校计算机及相关专业学生的视频点播网站系统完整方案,采用Python与Django框架开发,适用于毕业设计、课程设计及学期综合实践等学术场景,帮助学习者理解Web应用开发流程与视频内容管理系统的实现原理。资源包共156个文件,以48个html页面模板、40个py后端源码、20个js脚本、5个css样式表及6个png图片为主,另含数据库文件与操作指南,压缩包约3.89MB,下载后可直接部署运行,无需额外调整代码或配置环境。目前已有82人学习关注。内容涵盖环境搭建、数据初始化、功能测试与基础定制等关键环节,模块划分清晰、结构规范,便于按目录逐步掌握Django后端架构与视频点播业务的结合方式,适合希望将理论知识落地为工程实践、并需要完整参考方案的学习者使用。
1. 视频点播网站系统:从播放页反推 Django 该建哪几张表
很多人做视频点播网站系统,第一反应是打开播放器组件文档,研究怎么把 m3u8 塞进 video 标签。我一开始也这样,结果页面能播了,后台却一团乱:视频信息散在 JSON 文件里,分类靠手写死,用户看到一半刷新就丢进度。真正让项目立住的,不是播放器,而是 Django 侧的数据模型和请求链路。
这个标题讲的是用 Python 与 Django 搭一套完整的视频点播网站,附带源码、数据集和开发教程,常见于毕业设计场景。它解决的核心问题是:把视频上传、转码、分类、播放、记录观看进度串成一条可维护的链路,而不是拼一个能播的静态页。适合会一点 Python、想拿一个完整项目练手或交毕业设计的人,也适合想看清 Django 在媒体类项目里边界的老手。下面按「模型怎么定 → 上传与转码怎么做 → 播放页怎么取流 → 坑在哪 → 怎么验证」推。
2. 视频点播系统的数据模型:五张表撑起上传、分类与播放记录
2.1 为什么不用一个 Video 表塞完所有字段
新手最容易犯的错,是把视频标题、分类名、上传者昵称、播放地址全塞进一张 Video 表。这样写起来快,但改一个分类名要遍历所有视频行,删一个用户要手动清理他的视频,播放进度更是没地方放。Django 的 ORM 本来就鼓励拆表,拆开之后查询反而更清晰。
我一般会拆成五张核心表:Category(分类)、Video(视频主体)、VideoFile(转码后的多清晰度文件)、WatchRecord(观看进度)、Comment(评论,可选)。Category 和 Video 是一对多,Video 和 VideoFile 是一对多,因为一个视频通常有 360P、720P 两个文件。WatchRecord 关联用户和视频,记录秒数。
# models.py
from django.db import models
from django.contrib.auth.models import User
class Category(models.Model):
name = models.CharField(max_length=50, unique=True)
sort = models.IntegerField(default=0) # 控制前台排序
def __str__(self):
return self.name
class Video(models.Model):
title = models.CharField(max_length=200)
desc = models.TextField(blank=True)
cover = models.ImageField(upload_to='covers/') # 封面图
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
uploader = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
is_published = models.BooleanField(default=False) # 转码完成才发布
class Meta:
ordering = ['-created_at']
class VideoFile(models.Model):
video = models.ForeignKey(Video, related_name='files', on_delete=models.CASCADE)
quality = models.CharField(max_length=10) # 360p / 720p
file_path = models.CharField(max_length=255) # 相对 MEDIA_ROOT 的路径
size = models.BigIntegerField(default=0)
class WatchRecord(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
video = models.ForeignKey(Video, on_delete=models.CASCADE)
seconds = models.IntegerField(default=0) # 上次看到第几秒
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'video') # 一人一视频一条记录
逻辑说明:Video 用 is_published 控制前台可见,避免转码没完成就露出播放页。VideoFile 用 related_name='files',模板里可以 video.files.all 直接遍历清晰度。WatchRecord 的 unique_together 保证同一用户对同一视频只有一条进度,更新时用 update_or_create。
参数说明:cover 用 ImageField,需要 Pillow;file_path 存相对路径而不是完整 URL,换域名或换存储时不用改数据;seconds 用 IntegerField 足够,一部电影按秒算不会溢出。
2.2 迁移与后台注册的最小命令
模型写完,先建迁移再迁移,最后注册到 admin,这样不用写前端就能录数据。
python manage.py makemigrations video
python manage.py migrate
python manage.py createsuperuser
# admin.py
from django.contrib import admin
from .models import Category, Video, VideoFile, WatchRecord
@admin.register(Video)
class VideoAdmin(admin.ModelAdmin):
list_display = ('title', 'category', 'uploader', 'is_published', 'created_at')
list_filter = ('category', 'is_published')
search_fields = ('title',)
admin.site.register([Category, VideoFile, WatchRecord])
逻辑说明:list_display 让后台一眼看到关键字段,list_filter 按分类和发布状态筛,search_fields 支持标题搜索。这三项是后台可用性的底线,不加的话数据一多就翻不动。
参数说明:makemigrations 后面跟 app 名,避免误伤其他 app;createsuperuser 的账号只用于后台,不要和前台用户混用同一张表逻辑。
3. 上传与转码:把大文件拆成可播放的分片
3.1 上传接口为什么不能直接收整个文件
视频文件动辄几百 MB,直接走 Django 的 form 上传,会卡在 request 体解析上,超时和内存暴涨是常态。常见做法是前端分片上传,后端用 Django 的 FileField 接收分片后合并,或者干脆把上传交给对象存储的直传,Django 只存路径。毕业设计环境里没有对象存储,我一般用分片合并。
# views.py
import os
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def upload_chunk(request):
if request.method != 'POST':
return JsonResponse({'code': 405})
chunk = request.FILES.get('chunk')
index = int(request.POST.get('index'))
total = int(request.POST.get('total'))
file_id = request.POST.get('file_id') # 前端生成的唯一标识
tmp_dir = os.path.join(settings.MEDIA_ROOT, 'tmp', file_id)
os.makedirs(tmp_dir, exist_ok=True)
with open(os.path.join(tmp_dir, str(index)), 'wb+') as f:
for part in chunk.chunks():
f.write(part)
if index == total - 1: # 最后一片到达,合并
final_path = os.path.join(settings.MEDIA_ROOT, 'raw', f'{file_id}.mp4')
os.makedirs(os.path.dirname(final_path), exist_ok=True)
with open(final_path, 'wb') as out:
for i in range(total):
with open(os.path.join(tmp_dir, str(i)), 'rb') as part:
out.write(part.read())
return JsonResponse({'code': 0, 'path': f'raw/{file_id}.mp4'})
return JsonResponse({'code': 0, 'msg': 'chunk received'})
逻辑说明:每个分片按 index 存成独立文件,最后一片到达时按顺序合并。file_id 由前端生成,保证同一文件的分片落在同一目录。合并完成后返回相对路径,后续转码任务拿这个路径。
参数说明:chunk.chunks() 是 Django 推荐的读文件方式,避免一次性读入内存;tmp 目录要定期清理,否则磁盘会被分片撑满;total 由前端传,后端要校验 index 范围,防止越界写文件。
3.2 转码任务用 subprocess 调 ffmpeg 的稳妥写法
合并完的 mp4 不一定适合网页播放,常见做法是转成 H.264 + AAC 的 mp4,再按清晰度切两份。Django 里不要在主请求里跑 ffmpeg,会阻塞 worker,我一般丢到后台线程或 Celery。毕业设计用 threading 就够。
import subprocess
import threading
from .models import Video, VideoFile
def transcode(video_id, raw_path):
video = Video.objects.get(id=video_id)
for quality, scale in [('360p', '640:-2'), ('720p', '1280:-2')]:
out_name = f'{video_id}_{quality}.mp4'
out_path = os.path.join(settings.MEDIA_ROOT, 'media', out_name)
cmd = [
'ffmpeg', '-i', raw_path,
'-vf', f'scale={scale}',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart', # 关键:让 moov 前置,支持边下边播
out_path
]
subprocess.run(cmd, check=True)
VideoFile.objects.create(
video=video, quality=quality,
file_path=f'media/{out_name}',
size=os.path.getsize(out_path)
)
video.is_published = True
video.save()
def start_transcode(video_id, raw_path):
t = threading.Thread(target=transcode, args=(video_id, raw_path))
t.start()
逻辑说明:scale 里的 -2 让高度按比例自动算且保持偶数,libx264 不认奇数高度。+faststart 把 moov box 移到文件头,浏览器才能边下边播,不加这个参数播放页会等整个文件下完才出画面,这是血泪经验。
参数说明:crf 23 是画质和体积的平衡点,数值越小越清晰也越大;preset fast 在毕业设计机器上够用,slow 会明显拖长转码时间;-b:a 128k 对讲解类视频足够。
4. 播放页与进度记录:前端取流、后端存秒
4.1 播放页怎么拿到多清晰度地址
播放页的视图负责把 Video 和它的 VideoFile 一起取出来,模板里根据清晰度渲染多个 source。注意 MEDIA_URL 的拼接,不要硬编码域名。
# views.py
from django.shortcuts import render, get_object_or_404
from .models import Video, WatchRecord
def play(request, video_id):
video = get_object_or_404(Video, id=video_id, is_published=True)
files = video.files.all().order_by('quality')
record = None
if request.user.is_authenticated:
record = WatchRecord.objects.filter(user=request.user, video=video).first()
return render(request, 'play.html', {
'video': video,
'files': files,
'start_seconds': record.seconds if record else 0,
})
逻辑说明:get_object_or_404 带 is_published=True,未发布的视频直接 404,避免猜 id 访问。start_seconds 传给模板,播放器初始化时 seek 到上次位置。
参数说明:files 按 quality 排序,360p 在前,播放器默认选第一个;如果要做清晰度切换,前端监听 change 事件换 source 并保留 currentTime。
4.2 用 fetch 定时上报观看进度
进度上报不要每秒发一次,太浪费。我一般每 15 秒发一次,页面隐藏时再补一次。
// play.html 内联脚本
const videoEl = document.getElementById('player');
const videoId = {{ video.id }};
let lastSent = 0;
function report() {
const sec = Math.floor(videoEl.currentTime);
if (sec === lastSent) return;
lastSent = sec;
fetch('/api/watch/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken')},
body: JSON.stringify({video_id: videoId, seconds: sec})
});
}
setInterval(report, 15000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) report(); // 切走时补一次
});
逻辑说明:lastSent 去重,避免暂停时重复发相同秒数。visibilitychange 在用户切标签或关页时补报,减少进度丢失。
参数说明:CSRF token 从 cookie 取,Django 默认开启 CSRF 保护;接口返回 204 即可,前端不处理响应体。
后端接口用 update_or_create 落库:
@login_required
def save_watch(request):
data = json.loads(request.body)
WatchRecord.objects.update_or_create(
user=request.user, video_id=data['video_id'],
defaults={'seconds': data['seconds']}
)
return HttpResponse(status=204)
逻辑说明:update_or_create 命中 unique_together,存在就更新秒数,不存在就新建。参数说明:video_id 要校验存在性,否则外键会抛异常。
5. 避坑与排查:视频点播项目里最容易翻车的五件事
5.1 现象:播放页一直转圈,控制台报 206 但没画面
原因:ffmpeg 转码时没加 -movflags +faststart,moov box 在文件尾部,浏览器要下完整个文件才能解析。解决:转码命令补上该参数,已转码的文件用 ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4 快速修复,不用重新编码。
5.2 现象:上传大文件到一半 502
原因:Nginx 的 client_max_body_size 默认 1M,分片虽然小,但合并请求或直传会超。解决:在 Nginx 配置里调大该值,同时 Django 侧 DATA_UPLOAD_MAX_MEMORY_SIZE 也要相应调整。分片方案下每个分片控制在 5M 以内最稳。
5.3 现象:static 里的封面图在模板里显示不出来
原因:开发模式下 MEDIA_URL 没有配路由,Django 不会自动服务媒体文件。解决:在 urls.py 里加 static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ,仅开发环境用,生产交给 Nginx。这个坑在 vscode 里写 img 标签时特别隐蔽,因为路径看着没错。
5.4 现象:转码线程跑完,数据库里却没有 VideoFile
原因:threading 里抛了异常但没打印,subprocess.run 的 check=True 让 ffmpeg 非零退出直接中断。解决:在 transcode 外层包 try/except 并 logging.exception,同时确认 ffmpeg 在 PATH 里,服务器上经常没装或版本太老不认 -movflags。
5.5 现象:观看进度偶尔回退到 0
原因:update_or_create 并发时后到的旧秒数覆盖了新秒数,或者前端在 seek 时触发了上报。解决:后端加判断,只有新秒数大于已存秒数才更新;前端在 seeking 事件里暂停上报。
6. 验证与进阶:用一条命令确认整条链路通了
项目搭完,怎么确认上传、转码、播放、进度四段都通?我习惯写一个 management command,模拟一次完整流程,不依赖浏览器。
# management/commands/smoke_test.py
from django.core.management.base import BaseCommand
from video.models import Video, VideoFile, WatchRecord
from django.contrib.auth.models import User
import os
class Command(BaseCommand):
def handle(self, *args, **options):
user = User.objects.first()
video = Video.objects.filter(is_published=True).first()
assert video, '没有已发布视频,先跑一次上传转码'
files = video.files.all()
assert files.count() >= 1, 'VideoFile 为空,转码没落库'
for f in files:
full = os.path.join(settings.MEDIA_ROOT, f.file_path)
assert os.path.exists(full), f'文件缺失: {full}'
assert f.size > 0, f'文件大小为 0: {full}'
WatchRecord.objects.update_or_create(
user=user, video=video, defaults={'seconds': 42}
)
rec = WatchRecord.objects.get(user=user, video=video)
assert rec.seconds == 42, '进度写入失败'
self.stdout.write(self.style.SUCCESS('链路 OK'))
逻辑说明:断言覆盖四段——视频已发布、VideoFile 有记录、物理文件存在且非空、进度能写能读。任何一段断了都会在对应 assert 停住,比手动点页面快得多。
参数说明:settings.MEDIA_ROOT 要在文件顶部 import;smoke_test 只读不写业务数据,可以反复跑。
进阶方向有两个。一是把进度上报换成 WebSocket,Django Channels 可以做,但毕业设计里 polling 足够,别为了炫技引入复杂度。二是转码换成异步队列,Celery + Redis 是标准做法,但本地跑 Celery 在 Windows 上有一堆兼容问题,我一般建议先跑通 threading 版本,再考虑迁移。
我自己的习惯是:每加一个功能,先跑 smoke_test,再开浏览器点一遍。这个顺序帮我省了很多「页面看着对、数据其实没落库」的后悔药。希望帮到你。
更多推荐

所有评论(0)