简介:这是一份基于Flask框架的在线电影视频播放网站毕业设计源码,适合计算机相关专业学生用于毕设、课程设计或项目演示。网站前端采用HTML5与Bootstrap,后端使用Python3与Flask,数据库为MySQL,涵盖视频浏览、搜索筛选、登录注册、收藏评论等前台功能,后台支持对视频、用户、评论、轮播图、操作日志和管理员进行管理。压缩包共2000个文件,以JS、HTML、CSS等前端资源为主,另有Python源码、SQL数据库脚本及说明文档,整体大小21.02MB,结构清晰便于二次开发。资源附带了管理员与用户测试账号,代码已通过运行测试,答辩评审平均分达96分,已有550人学习下载。对于需要完成相似课题或学习Flask全栈开发的同学,这份源码能提供完整项目框架、配置思路与可直接运行的示例,具备较高参考价值。
1. Flask视频播放网站:别急着看代码,先想清楚视频怎么发出去
网上能搜到大量「Python Flask 电影网站源代码」的zip包,解压后结构基本一致:一个app.py、一个models.py、一个templates目录、一个sqlite数据库文件。文档说明写得比代码还长,但真正能拖动进度条、播放不卡的项目十个里挑不出两三个。这套东西的本质是毕业设计和数据库课程设计里的常客,核心难点从来不在增删改查,而是视频文件如何通过HTTP正确到达浏览器。Flask本身不擅长文件服务,但配合Range协议和Nginx就能把视频播放做得像模像样。这篇博文不负责帮你交作业,只帮你把这个zip里的每个模块落成自己能跑、能改、能上线的小型视频网站。
2. 数据库先行:电影信息、播放记录与用户体系如何落表
2.1 影视站的表结构,先分清主数据和行为数据
一个小型在线电影播放站的数据库远比想象中简单,主数据就三张:分类、电影、用户。行为数据是播放记录和评论。很多课程设计把字段堆进一张表里,导致后面做搜索、做分页、做播放记录时全糊在一起。
主数据的核心是电影表,字段里最容易忽略的是status。它控制着电影是否在前台展示,做上下架功能时不需要删行,改个数字就行。播放记录表要存「看到哪里」,用户下次进入时能从上次进度继续。评论表则绑定用户和电影两个外键。表与表之间的关系,决定了后面ORM建模时relationship的写法,也决定了查询时的性能。
2.2 Flask-SQLAlchemy模型定义,五张表的关系映射
# models.py from datetime import datetime from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash db = SQLAlchemy() class Category(db.Model): __tablename__ = 'category' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(32), unique=True, nullable=False) movies = db.relationship('Movie', backref='category', lazy='dynamic') class Movie(db.Model): __tablename__ = 'movie' id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(128), nullable=False, index=True) # 标题建索引,搜索走索引 cover = db.Column(db.String(256)) video_path = db.Column(db.String(256), nullable=False) # 相对路径,不含域名 category_id = db.Column(db.Integer, db.ForeignKey('category.id')) summary = db.Column(db.Text) play_count = db.Column(db.Integer, default=0) status = db.Column(db.SmallInteger, default=1) # 1上架 0下架 created_at = db.Column(db.DateTime, default=datetime.now) class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, nullable=False) password_hash = db.Column(db.String(128), nullable=False) is_admin = db.Column(db.Boolean, default=False) def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) class PlayHistory(db.Model): __tablename__ = 'play_history' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) movie_id = db.Column(db.Integer, db.ForeignKey('movie.id')) progress = db.Column(db.Integer, default=0) # 播放进度,单位秒 updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)这里用video_path而不是video_url,刻意区分一个概念:库里存相对路径,域名和前缀交给模板拼接。这样将来换存储目录、换CDN,不需要回改数据库。play_count字段在热门排行榜里要用到,它属于冗余计数,可以在播放视图里累加,也可以后台上架时初始化。lazy='dynamic'表示获取分类下的电影列表时返回一个查询对象,支持继续叠加过滤条件,而不是一次性把全部结果加载进内存。
2.3 从建库到初始化,数据库课程设计的三个命令
# 以 MySQL 为例,创建数据库 mysql -uroot -p -e "CREATE DATABASE movie_site DEFAULT CHARSET utf8mb4;" # 建表 python -c "from app import app, db; app.app_context().push(); db.create_all()" # 写入初始分类和测试电影 python seed.py第一行建库时指定utf8mb4,是为了中文标题和简介不出乱码,这是数据库课程设计里最常扣分的点。第二行通过应用上下文建表,Flask 2.x必须在app_context()里执行create_all(),直接跑会报Working outside of application context。第三行的seed.py是初始化数据脚本,插入默认分类和几个测试电影,方便打开站点就有内容可看。数据库表结构建好后,接下来要解决的核心问题就是视频文件怎么发出去。
| 对比项 | SQLite | MySQL |
|---|---|---|
| 初始化成本 | 零配置,复制即用 | 需要单独安装并建库 |
| 并发能力 | 写并发差,单写多读 | 读写并发稳定 |
| 备份部署 | 备份一个文件即可 | 需要mysqldump和权限管理 |
| 适合场景 | 课程设计、单机演示 | 多人访问的真实站点 |
3. 播放的核心:Flask如何把视频流送到浏览器
3.1 视频拖不动进度条,多半是Range请求没处理
浏览器里的<video>标签播放MP4时,不会一次性把整个文件拉完。它先发一个不带Range的请求探测文件大小,然后根据用户拖动进度条,用Range: bytes=1234-5678这种请求头向服务器要文件的某一段。服务器返回206 Partial Content和对应的数据段,播放器才能拖动跳转。
很多课程设计代码直接用send_file(path),文件小的时候一切正常,文件上到几百兆,播放器会卡在缓冲、进度条拖不动。所以视频播放视图要自己解析Range头。下面是完整实现:
# video_stream.py import os from flask import request, Response, abort, current_app def iter_file(path, start, end, chunk_size=65536): """按块读取文件区间 [start, end] 的数据""" with open(path, 'rb') as f: f.seek(start) remaining = end - start + 1 while remaining > 0: size = min(chunk_size, remaining) data = f.read(size) if not data: break yield data remaining -= len(data) def parse_range(range_header, file_size): """解析 Range 头,返回 (start, end),越界时限制到文件大小内""" if not range_header or not range_header.startswith('bytes='): return 0, file_size - 1 try: start_s, end_s = range_header[6:].split('-', 1) start = int(start_s) end = int(end_s) if end_s else file_size - 1 except (ValueError, KeyError): abort(416) if start >= file_size or end < start: abort(416) return start, min(end, file_size - 1) @app.route('/play/<int:movie_id>') def play_stream(movie_id): movie = Movie.query.get_or_404(movie_id) path = os.path.join(current_app.config['MEDIA_ROOT'], movie.video_path) if not os.path.exists(path): abort(404) file_size = os.path.getsize(path) range_header = request.headers.get('Range') start, end = parse_range(range_header, file_size) resp = Response(iter_file(path, start, end), status=206 if range_header else 200, content_type='video/mp4', direct_passthrough=True) resp.headers['Content-Range'] = f'bytes {start}-{end}/{file_size}' resp.headers['Accept-Ranges'] = 'bytes' resp.headers['Content-Length'] = str(end - start + 1) return resp几个参数要说明:iter_file的chunk_size=65536是64KB,这个值太小会让TCP小包过多,太大会占用内存和磁盘突发IO,64KB是视频流服务里的常见折中;direct_passthrough=True告诉Flask跳过默认的Etag和缓存逻辑,让响应变成裸文件流,避免每次请求都对整个文件做哈希计算。parse_range里abort(416)对应Range Not Satisfiable,这是播放器拖动到文件尾端时需要的正确响应码,写错成404会导致播放器直接停止播放。文件路径用MEDIA_ROOT配置拼接,这要求config.py里必须有这个常量,否则os.path.join(None, path)会抛异常。
3.2 全片直出、HLS还是DASH,按场景选传输方案
Range方案适合MP4、WebM这类「可随机访问」的容器格式。Flask项目里最常见的坑是拿了RMVB或AVI格式硬往<video>里塞,浏览器直接不认。最常见的做法是统一转成H.264编码的MP4,一条ffmpeg命令搞定:
ffmpeg -i input.avi -c:v libx264 -c:a aac -movflags +faststart output.mp4+faststart参数会把moov元数据块挪到文件头部,这样播放器不用等文件全部下载完就能开始播放,对MP4直出方案几乎是必加的参数。不转封装直接改扩展名是无效的,浏览器按容器格式解析,后缀名只影响服务器返回的Content-Type。
| 方案 | 服务端工作量 | 进度条支持 | 兼容性 |
|---|---|---|---|
| MP4 + Range | 中,需要实现Range逻辑 | 好 | 所有现代浏览器 |
| HLS/m3u8 | 低,返回静态文件即可 | 靠播放器实现 | iOS原生支持,Web端需要hls.js |
| DASH | 低 | 好 | 需要dash.js,标准较复杂 |
HLS切片适合视频量大的场景,ffmpeg切片成m3u8加ts分片,天然支持码率自适应。代价是课程设计级别的前端要额外引hls.js,且切片文件数量多,管理略麻烦。大多数课程设计和中小型影视站,MP4加Range是性价比最高的方案。
3.3 防盗链:Referrer校验入门,签名Token才是正解
视频网站上线后第一个被薅的就是视频地址。用户拿<video>的src直接下载,或者别的站点把视频地址嵌到自己页面上播放。防外部站点盗用,最简单的是校验Referer头:
@app.route('/play/<int:movie_id>') def play_stream(movie_id): referer = request.headers.get('Referer', '') if not referer.startswith(request.host_url): abort(403) # 后续Range处理逻辑同上这个方案对「拿地址浏览器直接下载」无效,因为浏览器直接访问地址时Referer为空。更可靠的是生成带有效期的签名URL,在电影详情页点击播放时拿到?token=xxx&expire=xxx的临时地址,服务端校验通过才允许进入流文件逻辑。签名生成代码放在模板路由里:
import hmac, hashlib, time def sign_video_url(movie_id): expire = int(time.time()) + 3600 * 6 # 6小时有效 msg = f'{movie_id}:{expire}'.encode() token = hmac.new(current_app.config['SECRET_KEY'], msg, hashlib.sha1).hexdigest() return f'/play/{movie_id}?expire={expire}&token={token}'校按时重新按相同参数计算一遍HMAC,相等才放行。注意SECRET_KEY不能用默认的'dev'或空值,否则等于签名形同虚设。签名URL过期后播放器重新拉一次详情页就能刷新,对用户体验影响很小。
4. 页面与播放器:模板渲染、搜索分页和后台上线的联动
4.1 播放器选型:原生video标签配合续播接口
前台播放页用原生<video>标签搭底,需要弹幕、倍速、画中画的时候再引入DPlayer或video.js。原生标签结构如下:
<video id="player" controls preload="auto" width="100%" height="420"> <source src="{{ play_url }}" type="video/mp4"> </video> <script> const player = document.getElementById('player'); player.currentTime = {{ history_progress }}; // 续播:定位到上次进度 player.addEventListener('pause', () => { fetch('/api/progress', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ movie_id: {{ movie.id }}, progress: Math.floor(player.currentTime) }) }); }); </script>preload="auto"让页面加载后就拉少量数据,便于用户快速看到第一帧。续播通过player.currentTime直接定位,把播放进度回写到PlayHistory表。注意浏览器自动播放政策下,带声音的视频不能自动启动播放,所以这里只定位进度不做play()调用。播放过程中定期保存进度比只在pause时保存更可靠,用户直接关标签页时pause事件不一定会触发。
4.2 搜索和分页:条件查询、排序、翻页一次写全
前台首页的搜索要按标题模糊查、按分类筛选、按播放数排序。Flask-SQLAlchemy的查询写法需要把过滤条件累积起来:
@app.route('/') def index(): page = request.args.get('page', 1, type=int) keyword = request.args.get('kw', '').strip() cid = request.args.get('cid', 0, type=int) query = Movie.query.filter(Movie.status == 1) if keyword: query = query.filter(Movie.title.like(f'%{keyword}%')) if cid: query = query.filter(Movie.category_id == cid) movies = query.order_by(Movie.play_count.desc()) \ .paginate(page=page, per_page=12, error_out=False) return render_template('index.html', movies=movies, pages=movies.pages, page=page, kw=keyword, cid=cid)paginate是Flask-SQLAlchemy内置的分页对象,返回的movies.items是当前页数据,movies.pages是总页数,error_out=False防止页码越界时抛404。排序用play_count.desc()让热播电影靠前。搜索词kw和分类cid要回传到模板里,否则翻页后筛选条件会丢掉。分页链接生成时用url_for('index', page=p, kw=kw, cid=cid)带上原参数。
4.3 后台发布电影,大文件上传要避开Flask的内存陷阱
后台发布电影的字段和Movie表一一对应,但video_path不放文件上传的结果,而是放「服务器上已有的视频文件相对路径」。这样避免Flask接收大文件时把内存打爆。视频上传采用保存到临时目录加秒传映射的方式,文件名用时间戳加随机串,防止中文文件名导致路径解析错误:
# admin.py 上传功能的简化版 from werkzeug.utils import secure_filename import time, os, uuid @app.route('/admin/movie/add', methods=['POST']) def admin_add_movie(): if not current_user.is_authenticated or not current_user.is_admin: abort(403) file = request.files.get('video') if not file or not file.filename.endswith('.mp4'): flash('仅支持MP4文件') return redirect(request.referrer) # 按月划分子目录,避免单目录文件过多 sub_dir = time.strftime('%Y%m') os.makedirs(os.path.join(current_app.config['MEDIA_ROOT'], sub_dir), exist_ok=True) base = f"{int(time.time())}_{uuid.uuid4().hex[:6]}.mp4" avatar_path = os.path.join(sub_dir, base) file.save(os.path.join(current_app.config['MEDIA_ROOT'], avatar_path)) movie = Movie(title=request.form['title'], video_path=avatar_path, category_id=int(request.form['category_id']), summary=request.form['summary'], status=1) db.session.add(movie) db.session.commit() return redirect('/admin/movies')文件名里时间戳加uuid4前6位,保证一个时间点内并发上传不撞名,也避免直接用原始文件名让secure_filename处理中文时被截断成空串。按月建子目录是防止文件系统单目录出现几万文件的寻址瓶颈。前端上传超时是另一个坑,Nginx的client_max_body_size默认1m,必须在server块里调大,同时proxy_read_timeout也要放宽到300秒以上,否则大视频传到一半就被断掉。
5. 权限与安全:登录、防刷与数据库增删改查的隐藏风险
5.1 登录态用session,配合装饰器控制前后台角色
前台用户和后台管理员共用一张User表,通过is_admin字段区分角色。Flask自带的session底层是签名Cookie,不占用服务端内存,课程设计规模下足够。登录逻辑里必须注意:session.permanent决定会话生命周期,前端用户要持久登录,后台要短会话。登录接口和权限装饰器拆开写:
from functools import wraps from flask import session, redirect, url_for, abort def login_required(view_func): @wraps(view_func) def wrapper(*args, **kwargs): if not session.get('user_id'): return redirect(url_for('login', next=request.url)) return view_func(*args, **kwargs) return wrapper def admin_required(view_func): @wraps(view_func) def wrapper(*args, **kwargs): if not session.get('is_admin'): abort(403) return view_func(*args, **kwargs) return wrapper装饰器的实现要点:@wraps保留原视图的元信息,next=request.url让登录成功后跳回原地址,abort(403)返回403而不是重定向,避免后台地址被爆破尝试时混淆判断。session里的is_admin在登录成功时写入,退出登录时用session.clear()清空全部会话数据。
5.2 不只是防SQL注入:表单、模板与请求头的三个入口
Flask-SQLAlchemy的ORM参数绑定天然过滤SQL注入,但仍有三个容易忽略的入口。第一个是手写text()或execute(sql)时直接拼接参数,正确写法是db.session.execute(text('SELECT * FROM movie WHERE title LIKE :kw'), {'kw': f'%{kw}%'})。第二个是模板里的XSS,Jinja2对{{ movie.summary }}默认转义HTML,但用|safe过滤器的地方要严格控制数据来源。第三个是重定向攻击,request.referrer用于后台跳转时不要直接透传。
数据库增删改查在后台的体现是标准的四个视图:列表、新增、编辑、删除。删除用db.session.delete(movie)提交后,还要把对应的视频文件从磁盘删除,否则数据库删了文件还在,慢慢占满磁盘。这个「两级删除」是课程设计里代码和文档都很少提到、但上线一定遇到的事。删除前先取movie.video_path拼出绝对路径,os.path.exists检查后再os.remove。
5.3 播放量防刷:用一张计数表做幂等累加
play_count每次播放视图加一,刷新一次页面就多一次。常见的防刷方案是同一IP在60秒内对同一部电影只计一次数。直接依赖一张计数表加联合主键:
CREATE TABLE play_count_log ( movie_id INT NOT NULL, ip_hash CHAR(40) NOT NULL, first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (movie_id, ip_hash) );在播放视图里先执行INSERT IGNORE,影响行数为1说明是第一次访问,再累加play_count。ip_hash不存原始IP,用SHA1做一次散列,既满足聚类统计又避免敏感信息明文落库。这个方案对多实例部署同样有效,瓶颈只在数据库单行插入的并发量。要防止同一用户换IP刷量,可以叠加session里的user_id生成校验值,双因子判断更稳。
6. 部署与排错:zip里的项目能跑起来,更要能上线
6.1 Gunicorn加Nginx,视频站点的最小上线配置
开发环境里flask run自带单进程服务,页面最多几十个并发视频请求,很容易卡死。上线的常见结构是Nginx当反向代理加静态文件服务,Gunicorn跑Flask应用。启动命令如下:
pip install gunicorn gunicorn -w 2 -b 127.0.0.1:8000 app:app --timeout 30Nginx侧的配置有两段关键location:
location /media/ { alias /data/movie/media/; sendfile on; sendfile_max_chunk 1m; aio on; } location /play/ { proxy_pass http://127.0.0.1:8000; proxy_buffering off; }sendfile_max_chunk 1m防止单次sendfile占住worker太久,aio on对视频文件走异步IO。proxy_buffering off对/play/必须关闭,否则Nginx缓冲视频流导致Range响应被吞掉,进度条又拖不动。Gunicorn的-w 2只开两个worker,视频流IO密集,worker太多反而在GIL上互相争抢。--timeout 30防止某个慢请求把worker整个拖死,超过30秒直接被杀了重启。
6.2 一次Range请求出错的排查路线
视频播放中最常见的故障是「前几秒能放,拖动就转圈」。按这个顺序排查:先确认浏览器发出的请求头里有Range且Nginx没有剥离它;再确认Flask返回的Content-Range格式是bytes start-end/total;最后确认Content-Length和实际响应体字节数一致。一个隐蔽的坑是Response里设置了Content-Encoding: gzip,视频流一旦被压缩编码,播放器无法解析Range,必须在Nginx对视频location关闭gzip。用curl直接验证是最快的方式:
curl -I -H "Range: bytes=0-1023" http://127.0.0.1:8000/play/1看到HTTP/1.1 206 Partial Content和正确的Content-Range就是Range链路正常。如果返回200,说明Flask响应没走流媒体分支,检查request.headers.get('Range')拿到的值是否为None,以及路径拼接是否正确。顺便确认MEDIA_ROOT配置在config.py里有值,别拿None拼进os.path.join。
6.3 磁盘IO扛不住时,用X-Accel-Redirect把文件传输交给Nginx
单机视频站最大的瓶颈是Flask逐块读文件再转发。Nginx提供了X-Accel-Redirect机制,Flask校验权限通过后只返回一个特殊响应头,Nginx直接接管文件传输,Gunicorn worker瞬间释放。改造/play/视图的返回逻辑:
@app.route('/play/<int:movie_id>') def play_stream(movie_id): movie = Movie.query.get_or_404(movie_id) path = os.path.join(current_app.config['MEDIA_ROOT'], movie.video_path) if not os.path.exists(path): abort(404) resp = Response('', status=200) resp.headers['X-Accel-Redirect'] = f'/media/{movie.video_path}' resp.headers['Content-Type'] = 'video/mp4' return respNginx收到这个响应头后,会从/media/内部location去取静态文件,整个过程对Flask来说是瞬时的。这个方案让Flask只做鉴权和计数,文件传输全部交给Nginx,CPU和内存占用立刻降下来。要注意X-Accel-Redirect指向的必须是Nginx可解析的内部路径,不能直接暴露文件系统绝对路径。配合前面的PlayHistory续播接口,单机撑住几百个并发视频请求没有问题。
本文还有配套的精品资源,点击获取