YouTube has transformed the way people consume video content, making it a giant in the digital entertainment industry. But what if you wanted to create your own mini-YouTube platform where users can upload, stream, and manage videos? Thanks to Python and its powerful frameworks like Flask and Django, this is entirely possible. In this article, we will walk through building a basic YouTube clone using Python with Flask. While it won’t match the scale and features of YouTube, this project will give you a strong understanding of how video streaming platforms work under the hood.
1) Quick setup
python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows
# .venv\Scripts\activate
pip install flask sqlalchemy flask-login werkzeug
mkdir uploads instance
Create app.py
in an empty folder. We’ll add code progressively.
2) Minimal Flask app + database models
We’ll define Users, Videos, Comments, and Likes. SQLite keeps things simple.
app.py
import os
from datetime import datetime
from typing import Optional
from flask import Flask, request, redirect, url_for, render_template_string, abort, send_file, Response, flash, jsonify
from flask_login import LoginManager, UserMixin, login_user, logout_user, current_user, login_required
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
from sqlalchemy import create_engine, select, func, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
app = Flask(__name__, instance_relative_config=True)
app.config["SECRET_KEY"] = "dev-secret-change-me"
os.makedirs(app.instance_path, exist_ok=True)
# DB
engine = create_engine(f"sqlite:///{os.path.join(app.instance_path, 'app.db')}", echo=False)
class Base(DeclarativeBase): pass
class User(UserMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(unique=True, index=True)
password_hash: Mapped[str]
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
videos: Mapped[list["Video"]] = relationship(back_populates="owner")
def set_password(self, pw): self.password_hash = generate_password_hash(pw)
def check_password(self, pw): return check_password_hash(self.password_hash, pw)
class Video(Base):
__tablename__ = "videos"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(index=True)
description: Mapped[str] = mapped_column(default="")
filename: Mapped[str]
mimetype: Mapped[str]
views: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
owner: Mapped[User] = relationship(back_populates="videos")
comments: Mapped[list["Comment"]] = relationship(back_populates="video", cascade="all, delete-orphan")
likes: Mapped[list["Like"]] = relationship(back_populates="video", cascade="all, delete-orphan")
class Comment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(primary_key=True)
body: Mapped[str]
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"))
video: Mapped[Video] = relationship(back_populates="comments")
class Like(Base):
__tablename__ = "likes"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"))
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
video: Mapped[Video] = relationship(back_populates="likes")
Base.metadata.create_all(engine)
login_manager = LoginManager(app)
login_manager.login_view = "login"
@login_manager.user_loader
def load_user(uid: str) -> Optional[User]:
with Session(engine) as s:
return s.get(User, int(uid))
3) Tiniest UI (embedded templates) and homepage
We’ll embed a minimal template for speed. Later you can split to real files.
TPL_BASE = """
<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'PyTube' }}</title>
<style>
body{margin:0;font-family:system-ui,Segoe UI,Roboto,Arial;background:#0b0e12;color:#e6edf3}
.container{max-width:1000px;margin:0 auto;padding:20px}
.nav{display:flex;gap:12px;align-items:center;justify-content:space-between}
.search{flex:1;margin:0 12px}
.search input{width:100%;padding:10px;border-radius:10px;border:1px solid #253041;background:#0f141b;color:#e6edf3}
.btn{background:#22c55e;border:none;padding:10px 14px;border-radius:10px;color:#052e12;font-weight:700;cursor:pointer}
.btn.outline{background:transparent;border:1px solid #2b3543;color:#e6edf3}
.card{background:#11161d;border:1px solid #1b2430;border-radius:16px;padding:12px;margin:12px 0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
.thumb{aspect-ratio:16/9;background:#0f141b;border-radius:10px;display:flex;align-items:center;justify-content:center;border:1px dashed #2b3543}
.meta{font-size:12px;color:#a7b1c0}
.video{width:100%;background:#000;border-radius:12px;border:1px solid #1b2430}
.flash{padding:10px;border:1px solid #273141;border-radius:10px;background:#0f141b;margin:8px 0}
</style>
<div class="container">
<div class="nav">
<div style="display:flex;gap:12px;align-items:center">
<a href="/" style="font-weight:800;text-decoration:none;color:#e6edf3">PyTube</a>
<form class="search" method="get" action="{{ url_for('index') }}">
<input name="q" placeholder="Search videos..." value="{{ q or '' }}">
</form>
</div>
<div style="display:flex;gap:8px">
{% if current_user.is_authenticated %}
<a class="btn outline" href="{{ url_for('upload') }}">Upload</a>
<a class="btn outline" href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a class="btn outline" href="{{ url_for('login') }}">Log in</a>
<a class="btn" href="{{ url_for('register') }}">Sign up</a>
{% endif %}
</div>
</div>
{% for c, m in get_flashed_messages(with_categories=true) %}<div class="flash">{{ m }}</div>{% endfor %}
{% block content %}{% endblock %}
</div>
"""
TPL_INDEX = """
{% extends TPL_BASE %}
{% block content %}
<h2>Latest uploads{% if q %} matching “{{ q }}”{% endif %}</h2>
{% if videos %}
<div class="grid">
{% for v in videos %}
<a class="card" href="{{ url_for('watch', video_id=v.id) }}" style="text-decoration:none;color:#e6edf3">
<div class="thumb">▶</div>
<div style="font-weight:700">{{ v.title }}</div>
<div class="meta">{{ v.views }} views • {{ v.created_at.strftime('%b %d, %Y') }}</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="card"><div class="meta">No videos yet. Be the first to upload!</div></div>
{% endif %}
{% endblock %}
"""
app.jinja_env.globals["TPL_BASE"] = TPL_BASE
@app.route("/")
def index():
q = request.args.get("q", "").strip()
with Session(engine) as s:
stmt = select(Video).order_by(Video.created_at.desc())
if q:
stmt = select(Video).where(Video.title.ilike(f"%{q}%")).order_by(Video.created_at.desc())
videos = s.scalars(stmt).all()
return render_template_string(TPL_INDEX, videos=videos, q=q)
4) Authentication (register, login, logout)
A clean, minimal form flow using flask-login
.
TPL_AUTH = """
{% extends TPL_BASE %}
{% block content %}
<h2>{{ 'Create an account' if mode=='register' else 'Log in' }}</h2>
<form method="post" class="card">
<label>Username</label><input name="username">
<label>Password</label><input type="password" name="password">
<button class="btn" type="submit">{{ 'Sign up' if mode=='register' else 'Log in' }}</button>
</form>
{% endblock %}
"""
@app.route("/register", methods=["GET","POST"])
def register():
if request.method == "POST":
u = request.form.get("username","").strip()
p = request.form.get("password","")
if len(u) < 3 or len(p) < 6:
flash("Username or password too short.", "error")
else:
with Session(engine) as s:
exists = s.scalar(select(func.count(User.id)).where(User.username==u)) > 0
if exists:
flash("Username already taken.", "error")
else:
user = User(username=u); user.set_password(p)
s.add(user); s.commit()
flash("Account created. Please log in.", "success")
return redirect(url_for("login"))
return render_template_string(TPL_AUTH, mode="register")
@app.route("/login", methods=["GET","POST"])
def login():
if request.method == "POST":
u = request.form.get("username","").strip()
p = request.form.get("password","")
with Session(engine) as s:
user = s.scalar(select(User).where(User.username==u))
if user and user.check_password(p):
login_user(user); flash("Welcome back!", "success")
return redirect(url_for("index"))
flash("Invalid credentials.", "error")
return render_template_string(TPL_AUTH, mode="login")
@app.route("/logout")
@login_required
def logout():
logout_user(); flash("Logged out.", "success")
return redirect(url_for("index"))
5) Upload videos
We’ll accept .mp4,
.webm,
.ogg,
.m4v.
Filenames are sanitized and de-duplicated.
ALLOWED_EXTS = {".mp4", ".webm", ".ogg", ".m4v"}
def allowed(name): return os.path.splitext(name.lower())[1] in ALLOWED_EXTS
TPL_UPLOAD = """
{% extends TPL_BASE %}
{% block content %}
<h2>Upload a video</h2>
<form method="post" enctype="multipart/form-data" class="card">
<label>Title</label><input name="title">
<label>Description</label><textarea name="description" rows="3"></textarea>
<label>File</label><input type="file" name="file" accept="video/*,.mp4,.webm,.ogg,.m4v">
<button class="btn" type="submit">Upload</button>
</form>
{% endblock %}
"""
@app.route("/upload", methods=["GET","POST"])
@login_required
def upload():
if request.method == "POST":
title = request.form.get("title","").strip()
desc = request.form.get("description","").strip()
f = request.files.get("file")
if not title or not f or not f.filename:
flash("Title and file are required.", "error")
elif not allowed(f.filename):
flash("Unsupported file type.", "error")
else:
name = secure_filename(f.filename)
base, ext = os.path.splitext(name)
dest = os.path.join(UPLOAD_DIR, name)
i = 1
while os.path.exists(dest):
name = f"{base}_{i}{ext}"; dest = os.path.join(UPLOAD_DIR, name); i += 1
f.save(dest)
with Session(engine) as s:
v = Video(title=title, description=desc, filename=name, mimetype=f.mimetype or "video/mp4",
owner_id=current_user.id)
s.add(v); s.commit()
flash("Uploaded!", "success")
return redirect(url_for("watch", video_id=v.id))
return render_template_string(TPL_UPLOAD)
6) Range-aware streaming endpoint
This is the secret sauce. It supports scrubbing and fast starts.
def partial_response(path, mimetype):
size = os.path.getsize(path)
rng = request.headers.get("Range")
if rng:
# e.g. "bytes=1000-2000"
units, _, spec = rng.partition("=")
if units != "bytes": abort(416)
start_s, _, end_s = spec.partition("-")
try:
start = int(start_s) if start_s else 0
end = int(end_s) if end_s else size - 1
except ValueError:
abort(416)
start = max(0, start); end = min(size - 1, end)
length = end - start + 1
with open(path, "rb") as fp:
fp.seek(start)
data = fp.read(length)
resp = Response(data, 206, mimetype=mimetype, direct_passthrough=True)
resp.headers.add("Content-Range", f"bytes {start}-{end}/{size}")
resp.headers.add("Accept-Ranges", "bytes")
resp.headers.add("Content-Length", str(length))
return resp
return send_file(path, mimetype=mimetype, as_attachment=False)
@app.route("/stream/<int:video_id>")
def stream(video_id: int):
with Session(engine) as s:
v = s.get(Video, video_id) or abort(404)
path = os.path.join(UPLOAD_DIR, v.filename)
if not os.path.exists(path): abort(404)
return partial_response(path, v.mimetype)
7) Watch page (player + views counter)
We render a <video>
player that points to our Range endpoint.
TPL_WATCH = """
{% extends TPL_BASE %}
{% block content %}
<div class="card">
<video class="video" controls preload="metadata">
<source src="{{ url_for('stream', video_id=v.id) }}" type="{{ v.mimetype }}">
Your browser does not support the video tag.
</video>
<h2 style="margin:6px 0 0">{{ v.title }}</h2>
<div class="meta">{{ v.views }} views • {{ v.created_at.strftime('%b %d, %Y') }}</div>
{% if v.description %}<p style="margin-top:8px">{{ v.description }}</p>{% endif %}
</div>
{% endblock %}
"""
@app.route("/v/<int:video_id>")
def watch(video_id: int):
with Session(engine) as s:
v = s.get(Video, video_id) or abort(404)
v.views += 1; s.commit()
return render_template_string(TPL_WATCH, v=v)
Also if you want to download youtube videos then must download snaptube apk.
8) Likes (AJAX) and comments (form)
Small, friendly community features.
# Like toggle: returns JSON {count: <int>}
@app.route("/like/<int:video_id>", methods=["POST"])
@login_required
def like(video_id: int):
with Session(engine) as s:
v = s.get(Video, video_id) or abort(404)
existing = s.scalar(select(Like).where(Like.video_id==video_id, Like.user_id==current_user.id))
if existing:
s.delete(existing); s.commit()
else:
s.add(Like(video_id=video_id, user_id=current_user.id)); s.commit()
count = s.scalar(select(func.count(Like.id)).where(Like.video_id==video_id)) or 0
return jsonify({"count": count})
Update the watch template to show a like button and a comment box:
TPL_WATCH = """
{% extends TPL_BASE %}
{% block content %}
<div class="card">
<video class="video" controls preload="metadata">
<source src="{{ url_for('stream', video_id=v.id) }}" type="{{ v.mimetype }}">
</video>
<h2 style="margin:6px 0 0">{{ v.title }}</h2>
<div class="meta">{{ v.views }} views • {{ v.created_at.strftime('%b %d, %Y') }}</div>
<div style="display:flex;gap:10px;align-items:center;margin-top:8px">
<button class="btn" onclick="likeVideo({{ v.id }})">♥ Like</button>
<div class="meta">Likes: <span id="likeCount">{{ like_count }}</span></div>
</div>
{% if v.description %}<p style="margin-top:8px">{{ v.description }}</p>{% endif %}
</div>
<div class="card">
<h3>Comments</h3>
{% if current_user.is_authenticated %}
<form method="post" action="{{ url_for('comment', video_id=v.id) }}">
<input name="body" placeholder="Say something nice…" style="width:100%;margin-bottom:8px">
<button class="btn" type="submit">Post</button>
</form>
{% else %}
<div class="meta">Log in to comment.</div>
{% endif %}
{% for c in comments %}
<div style="padding:10px;border:1px solid #273141;border-radius:10px;background:#0f141b;margin-top:8px">
<div class="meta">{{ c.created_at.strftime('%b %d, %Y') }}</div>
<div style="margin-top:4px">{{ c.body }}</div>
</div>
{% else %}
<div class="meta">No comments yet.</div>
{% endfor %}
</div>
<script>
async function likeVideo(id){
const res = await fetch(`/like/${id}`, {method:'POST'});
const data = await res.json();
const el = document.querySelector('#likeCount');
if(el){ el.textContent = data.count; }
}
</script>
{% endblock %}
"""
@app.route("/v/<int:video_id>")
def watch(video_id: int):
with Session(engine) as s:
v = s.get(Video, video_id) or abort(404)
v.views += 1; s.commit()
comments = s.scalars(select(Comment).where(Comment.video_id==v.id).order_by(Comment.created_at.desc())).all()
like_count = s.scalar(select(func.count(Like.id)).where(Like.video_id==v.id)) or 0
return render_template_string(TPL_WATCH, v=v, comments=comments, like_count=like_count)
@app.route("/comment/<int:video_id>", methods=["POST"])
@login_required
def comment(video_id: int):
body = request.form.get("body","").strip()
if not body: flash("Comment cannot be empty.", "error")
else:
with Session(engine) as s:
v = s.get(Video, video_id) or abort(404)
s.add(Comment(body=body, user_id=current_user.id, video_id=video_id)); s.commit()
flash("Comment posted!", "success")
return redirect(url_for("watch", video_id=video_id))
9) Search on the homepage
We already wired a query param q
in Step 2. Try it: type a word and hit Enter. It filters by title using case-insensitive ilike
.
10) Run the app
python app.py
# Open http://127.0.0.1:5000
# 1) Register 2) Log in 3) Upload a small .mp4 4) Click the video
# Drag the scrubber — Range streaming should work smoothly.
Final notes
A “YouTube clone” is just well-known web primitives—uploads, byte ranges, a few tables—combined thoughtfully. Start with this skeleton, confirm streaming works, then iterate toward thumbnails, HLS, and scale.
Top comments (0)