DEV Community

Tariq Mehmood
Tariq Mehmood

Posted on

how to build a youtube clone with python

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

Enter fullscreen mode Exit fullscreen mode

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))

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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))


Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)