Content & Marketing

Summarize a YouTube Video with Python

Pull a video's caption track with Python, fall back to audio transcription when captions are off, then turn the transcript into timestamped takeaways.

By the end of this guide you will have a single Python file that takes a YouTube URL, gets a transcript one of two ways, and prints a short list of takeaways where every bullet links straight to the second of the video it came from. On a video that already has captions the whole thing runs in a few seconds and costs a fraction of a cent; on a video without captions it takes as long as the audio download plus one transcription call. Budget about thirty minutes to build it the first time.

This guide sits inside AI Transcription and Content Repurposing with Python, the section covering how to get spoken words into text and then reshape them into something useful.

Prerequisites

You need Python 3.10 or newer inside a virtual environment — an isolated folder of packages that keeps this project's libraries away from the rest of your system. If that is new to you, work through Create a Python Virtual Environment for AI first, then come back.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "youtube-transcript-api>=0.6.2,<1.0" "yt-dlp>=2024.8.6" "python-dotenv>=1.0"
pip freeze > requirements.txt

Two notes on those pins. The youtube-transcript-api version is capped below 1.0 because the 1.0 rewrite moved the entry point from a class method to an instance method; pinning keeps the code below working exactly as printed. And yt-dlp needs FFmpeg, a free command-line tool that converts audio and video formats, installed separately — brew install ffmpeg on Mac, sudo apt install ffmpeg on Debian or Ubuntu, or the official Windows build on your PATH.

You only need one API key, for the second route. Put it in a file called .env beside your script:

OPENAI_API_KEY=sk-your-key-here

Then keep it out of version control before you write another line:

echo ".env" >> .gitignore

A key that reaches a public repository gets scraped and spent by strangers, often within the hour, so this is not a formality.

Two routes to a transcript

Most videos of any length already carry a caption track — either typed by the uploader or generated automatically by YouTube. When that track exists you can simply ask for it: no download, no transcription bill, no waiting. When it does not exist, or the uploader has switched captions off, you have to do the work yourself by pulling the audio and running speech-to-text over it.

Writing your script so it tries the cheap route first and quietly falls back to the expensive one is the single most useful design decision here. It means one function signature covers every video you throw at it, and you never pay for transcription you did not need.

Choosing between the published caption track and audio transcription A decision tree that starts from a video ID, checks whether a caption track is published, and branches either to the youtube-transcript-api fetch or to a yt-dlp audio download followed by Whisper transcription, with both branches ending at the same transcript. Video ID in hand youtu.be/VIDEO_ID Caption track? available for this ID yes no Fetch captions youtube-transcript-api Download audio yt-dlp, audio only Transcribe audio Whisper API Transcript ready text + start times
Both branches end in the same shape of data — lines of text, each with a start time — so everything downstream of this point is written once.

Both routes hand you the same thing: a list of short lines, each with a start value in seconds. That shared shape is what lets the summarising code later in this guide stay simple.

Step 1 — Turn any YouTube URL into a video ID

Every YouTube link contains an 11-character video ID, but it hides in a different place depending on the link. A normal watch link puts it in the v query parameter, a share link puts it in the path, and Shorts and embeds use their own path prefixes. Parse it once, properly, and the rest of the script only ever deals with the ID.

from urllib.parse import urlparse, parse_qs


def video_id_from_url(url: str) -> str:
    """Extract the 11-character video id from any common YouTube link."""
    parsed = urlparse(url)
    host = parsed.netloc.lower().removeprefix("www.")

    if host == "youtu.be":
        return parsed.path.lstrip("/").split("/")[0]

    if host in {"youtube.com", "m.youtube.com", "music.youtube.com"}:
        if parsed.path == "/watch":
            found = parse_qs(parsed.query).get("v")
            if found:
                return found[0]
        for prefix in ("/embed/", "/shorts/", "/live/"):
            if parsed.path.startswith(prefix):
                return parsed.path[len(prefix):].split("/")[0]

    raise ValueError(f"No video id found in: {url}")


if __name__ == "__main__":
    for link in [
        "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s",
        "https://youtu.be/dQw4w9WgXcQ",
        "https://www.youtube.com/shorts/dQw4w9WgXcQ",
    ]:
        print(video_id_from_url(link))

urlparse is part of Python's standard library, so there is nothing to install. Raising a clear ValueError on an unrecognised link is deliberate: when you later feed this a list of 200 URLs from a spreadsheet, you want the bad row named rather than a confusing crash three functions deeper.

Step 2 — Fetch the published caption track

This is the fast route. youtube-transcript-api asks YouTube for the caption track that is already attached to the video and returns a list of dictionaries — each with the spoken text, the start time in seconds, and how long that line stays on screen.

Three things can go wrong, and each has its own exception. TranscriptsDisabled means the uploader turned captions off. NoTranscriptFound means captions exist but not in a language you asked for. VideoUnavailable means the ID is wrong, private, or region-blocked — that one is worth letting through, because no fallback will rescue it.

from youtube_transcript_api import (
    YouTubeTranscriptApi,
    NoTranscriptFound,
    TranscriptsDisabled,
    VideoUnavailable,
)


def fetch_caption_track(video_id: str, languages=("en", "en-US", "en-GB")):
    """Return a list of {text, start, duration} dicts, or None if captions are off."""
    try:
        return YouTubeTranscriptApi.get_transcript(video_id, languages=list(languages))
    except (TranscriptsDisabled, NoTranscriptFound):
        return None
    except VideoUnavailable:
        raise RuntimeError(f"Video {video_id} is private, deleted, or region-blocked")


if __name__ == "__main__":
    lines = fetch_caption_track("dQw4w9WgXcQ")
    if lines is None:
        print("No captions published — use the audio fallback.")
    else:
        print(f"{len(lines)} caption lines")
        print(lines[0])   # {'text': '...', 'start': 0.0, 'duration': 4.2}

Returning None rather than raising on a missing caption track is what makes the fallback in the next step readable. Auto-generated captions have no punctuation and occasional mishearings, but for summarising that barely matters — a language model reads through the noise without complaint.

Step 3 — Fall back to downloading and transcribing the audio

When fetch_caption_track returns None, pull the audio instead. yt-dlp downloads only the audio stream and hands it to FFmpeg to re-encode as a small MP3, which keeps the upload well under the transcription endpoint's file-size limit. Then send that file to the Whisper speech-to-text model and ask for verbose_json, the response format that includes segment start and end times — without those you lose the timestamps that make this whole guide worthwhile.

import os
from pathlib import Path

import yt_dlp
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()   # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def download_audio(video_id: str, out_dir: str = "audio") -> Path:
    """Download just the audio stream and re-encode it as a small MP3."""
    Path(out_dir).mkdir(exist_ok=True)
    options = {
        "format": "bestaudio/best",
        "outtmpl": f"{out_dir}/{video_id}.%(ext)s",
        "quiet": True,
        "postprocessors": [{
            "key": "FFmpegExtractAudio",
            "preferredcodec": "mp3",
            "preferredquality": "64",
        }],
    }
    with yt_dlp.YoutubeDL(options) as ydl:
        ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
    return Path(out_dir) / f"{video_id}.mp3"


def transcribe_audio(path: Path):
    """Return the same {text, start, duration} shape the caption route returns."""
    with open(path, "rb") as audio_file:
        result = client.audio.transcriptions.create(
            model="whisper-1",
            file=audio_file,
            response_format="verbose_json",
            timestamp_granularities=["segment"],
        )
    return [
        {"text": seg.text.strip(), "start": seg.start, "duration": seg.end - seg.start}
        for seg in result.segments
    ]


def get_transcript(video_id: str):
    """Cheap route first, audio transcription only if it is really needed."""
    lines = fetch_caption_track(video_id)
    if lines is not None:
        return lines
    return transcribe_audio(download_audio(video_id))

The list comprehension at the end of transcribe_audio is the important line: it reshapes Whisper's segments into the exact dictionary keys the caption route produces, so get_transcript returns one consistent structure either way. For a deeper look at the transcription call itself — languages, prompts, and handling files that are too big — see Transcribe Audio with the Whisper API and Python.

A 64 kbps mono MP3 of an hour-long talk is a modest file, and transcription is billed by audio minute, so it is worth checking the current pricing page before you run this over a long playlist. If you plan to re-run the same videos while developing, Cache AI Responses in Python to Cut Costs shows how to avoid paying twice for the same audio.

Step 4 — Chunk the transcript and summarise hierarchically

A forty-minute video produces several thousand words of transcript. You could try to push all of it into one request, but you will hit the model's context window — the maximum amount of text it can consider at once — and get a context_length_exceeded error. Even when it fits, a model asked to summarise a huge blob tends to over-weight the beginning and forget the middle.

The reliable pattern is hierarchical summarisation: cut the transcript into chunks, summarise each chunk on its own, then run one more pass that summarises the summaries. Each individual request stays small and focused, and the final pass sees a compressed version of the entire video.

Hierarchical summarisation of a long video transcript A data-flow diagram in which one full transcript fans out into three timestamped chunks, each chunk is summarised separately, the three chunk summaries feed into a single merging pass, and that pass produces the final timestamped takeaways. Full transcript one long text string Chunk 1 from 0:00 onward Chunk 2 middle section Chunk 3 through to the end Chunk summary 1 bullets + timestamps Chunk summary 2 bullets + timestamps Chunk summary 3 bullets + timestamps Second AI pass summary of summaries Takeaways each with a deep link
Every request in the middle row is small and independent, so a three-hour stream costs the same per chunk as a five-minute clip — only the number of chunks grows.

The trick that makes timestamps survive both passes is to write them into the text you send. Prefix every transcript line with its own [MM:SS] marker and tell the model to reuse the marker nearest to whatever it is describing. The timestamps then travel through the chunk summaries and out the other side of the merge.

def format_timestamp(seconds: float) -> str:
    """Seconds as [MM:SS] or [HH:MM:SS] for anything past an hour."""
    total = int(seconds)
    hours, minutes, secs = total // 3600, (total % 3600) // 60, total % 60
    if hours:
        return f"[{hours}:{minutes:02d}:{secs:02d}]"
    return f"[{minutes}:{secs:02d}]"


def chunk_transcript(lines, max_chars: int = 6000):
    """Group timestamped lines into chunks small enough for one request."""
    chunks, current, size = [], [], 0
    for line in lines:
        stamped = f"{format_timestamp(line['start'])} {line['text'].strip()}"
        if size + len(stamped) > max_chars and current:
            chunks.append("\n".join(current))
            current, size = [], 0
        current.append(stamped)
        size += len(stamped) + 1
    if current:
        chunks.append("\n".join(current))
    return chunks

Six thousand characters is a deliberately conservative chunk size — roughly fifteen hundred tokens, which leaves plenty of room for the instructions and the reply. If you want to reason about chunk sizes properly rather than guessing, Split Long Text into Chunks for AI APIs covers the trade-offs, and Fix the Context-Length-Exceeded Error in Python explains what happens when you get it wrong.

Now the two passes. Notice that both use the cheap gpt-4o-mini model and both put the formatting rules in the system message, where the model treats them as standing instructions rather than a suggestion buried in the data.

MAP_SYSTEM = (
    "You summarise part of a video transcript. Each line begins with a timestamp "
    "in square brackets. Write at most three bullets. Start every bullet with the "
    "timestamp of the moment it refers to, copied exactly, then a dash and one "
    "sentence. Use only what the transcript says."
)

REDUCE_SYSTEM = (
    "You are given bullets from consecutive parts of one video, in order. "
    "Keep the eight most important, merge duplicates, and preserve each bullet's "
    "original timestamp. Return only the bullets, one per line."
)


def summarise_chunk(chunk: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": MAP_SYSTEM},
            {"role": "user", "content": chunk},
        ],
        temperature=0.2,
    )
    return response.choices[0].message.content.strip()


def summarise_transcript(lines) -> str:
    chunks = chunk_transcript(lines)
    partials = [summarise_chunk(chunk) for chunk in chunks]
    if len(partials) == 1:
        return partials[0]
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": REDUCE_SYSTEM},
            {"role": "user", "content": "\n".join(partials)},
        ],
        temperature=0.2,
    )
    return response.choices[0].message.content.strip()

A low temperature keeps the model close to the transcript instead of embellishing. Skipping the merge pass when there is only one chunk saves a request on short videos. If the loop over chunks starts failing partway through a long video, you are probably sending requests faster than your account allows — Fix the 429 Rate-Limit Error in Python shows how to back off and retry. The same map-then-merge structure works on any long document, as Summarize Long PDFs with Python and Chunking demonstrates on paperwork rather than video.

Step 5 — Turn the bullets into clickable timestamps

Right now each bullet starts with something like [12:34]. That is readable, but a reader still has to scrub the player by hand. YouTube accepts a t parameter on a watch URL, measured in whole seconds, and opens the player at exactly that moment — so converting [12:34] into 754 and appending &t=754s turns every takeaway into a one-click jump back to the source.

Anatomy of a timestamped YouTube deep link The three parts of a deep link laid end to end — the watch page path, the video id query parameter, and the start-time parameter in whole seconds — each with a callout explaining where its value comes from, feeding into the finished markdown bullet. youtube.com/watch ?v=VIDEO_ID &t=754s Player page the normal watch URL Video id from your parser Start offset whole seconds only Finished bullet 12:34 - one takeaway
The only value your script computes here is the offset in whole seconds; everything else in the link is fixed text plus the ID you parsed in step one.

The parser below accepts both [MM:SS] and [HH:MM:SS], tolerates a leading dash or asterisk from the model's bullet formatting, and silently drops any line that does not match — which is a cheap way to filter out stray preamble like "Here are the key points".

import re

BULLET = re.compile(r"^[-*•]?\s*\[(\d{1,2}):(\d{2})(?::(\d{2}))?\]\s*[-—:]?\s*(.+)$")


def to_linked_markdown(summary: str, video_id: str) -> str:
    """Rewrite [MM:SS] bullets as markdown links into the video."""
    out = []
    for raw in summary.splitlines():
        match = BULLET.match(raw.strip())
        if not match:
            continue
        first, second, third, text = match.groups()
        if third:
            seconds = int(first) * 3600 + int(second) * 60 + int(third)
        else:
            seconds = int(first) * 60 + int(second)
        label = format_timestamp(seconds).strip("[]")
        url = f"https://www.youtube.com/watch?v={video_id}&t={seconds}s"
        out.append(f"- [{label}]({url}) — {text.strip()}")
    return "\n".join(out)


if __name__ == "__main__":
    url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
    vid = video_id_from_url(url)
    transcript = get_transcript(vid)
    print(to_linked_markdown(summarise_transcript(transcript), vid))

Run it and you get markdown you can paste straight into a notes app, a wiki page, or the body of a newsletter. Each line reads as a claim plus a link that proves it. If you want the same transcript reshaped into prose instead of bullets, Turn a Podcast Episode into a Blog Post picks up from exactly this point, and Generate SRT Subtitles with Python and AI uses the same timestamps for captions instead.

Quick reference

PieceWhat it doesWatch out for
YouTubeTranscriptApi.get_transcriptFetches published captions, no key neededRaises TranscriptsDisabled when captions are off
yt_dlp.YoutubeDLDownloads the audio stream onlyNeeds FFmpeg installed separately
response_format="verbose_json"Returns segments with start and end timesPlain json gives you text with no timestamps
t=754s on a watch URLOpens the player at that secondMust be whole seconds, not 12:34

Troubleshooting

  • youtube_transcript_api._errors.TranscriptsDisabled — the uploader disabled captions, or the video is age-restricted so the track is not served. Cause: there is nothing to fetch. Fix: catch the exception and route to download_audio plus transcribe_audio as shown in step 3.
  • ERROR: ffprobe and ffmpeg not foundyt-dlp downloaded the audio but cannot convert it. Cause: FFmpeg is missing or not on your PATH. Fix: install FFmpeg with your system package manager, close and reopen the terminal, then confirm with ffmpeg -version.
  • ModuleNotFoundError: No module named 'yt_dlp' — the package is installed somewhere your interpreter is not looking. Cause: an inactive or wrong virtual environment. Fix: activate .venv and reinstall; Fix ModuleNotFoundError: No Module Named openai walks through the same diagnosis in detail.
  • The summary comes back with no timestamps at all — every bullet fails the BULLET regex and to_linked_markdown returns an empty string. Cause: the model paraphrased the markers away, usually because the chunk text lost its [MM:SS] prefixes. Fix: print one chunk before sending it and confirm the prefixes are there, then restate the timestamp rule in the system message. Write System Prompts that Control Output Format has stronger phrasings for format rules.

Summarise for yourself, not for republication

A transcript and the video it came from belong to whoever made them. A summary you write for your own notes, research, or study is ordinary fair use in most places; a scraped transcript republished as a page on your site, or a summary detailed enough that nobody needs to watch the original, is a different thing entirely and can land you a takedown.

Three habits keep you on the right side of this. Always link back to the source video, which the timestamps in step 5 do for you automatically. Keep summaries genuinely short — takeaways, not a retelling. And read the terms of service of any platform before you point a script at it in bulk, because downloading audio in particular is treated differently from reading a public caption track. If you are summarising your own uploads or clips you have permission to use, none of this bites, and running the script across your own back catalogue is the safest place to start.

When to use this vs. alternatives

  • Use this script when you need the summary inside another program — a database row, a Slack message, a weekly digest — or when you are processing more than a handful of videos and clicking through a web tool would take longer than writing the loop.
  • Use YouTube's own summary features or a browser extension when you just want to skim one video once. A script you have to maintain is a poor trade for a single reading session.
  • Use a full transcription pipeline instead when you need the exact words rather than the gist: legal quoting, subtitle files, or search across a whole archive. In that case skip the summarising passes and keep the raw segments, and read Estimate OpenAI API Costs with Python before you start, because transcription of a large library adds up.

Once the script runs end to end, the obvious next moves are to loop it over a list of URLs from a spreadsheet, to store the transcripts locally so re-summarising costs nothing, and to swap gpt-4o-mini for gpt-4o on the final merge pass when a video really deserves a sharper summary. Back to AI Transcription and Content Repurposing with Python.

Frequently asked questions

Can Python get a YouTube transcript without downloading the video?

Yes, whenever the uploader has published a caption track. The youtube-transcript-api package asks YouTube for that already-published text and hands you a list of lines with start times, no audio download and no API key involved. You only need to download audio when the captions are switched off.

What happens if captions are disabled on a video?

The library raises a TranscriptsDisabled error. Catch it and switch to the second route: download the audio track with yt-dlp, send that file to a speech-to-text API, and carry on with the same summarising code. The rest of your script does not need to change.

How do I summarise a video that is longer than the model's context window?

Split the transcript into chunks of a few thousand characters, summarise each chunk on its own, then run a second pass that summarises those summaries. This two-stage approach is called hierarchical summarisation, and it works for a ten-minute clip or a three-hour stream.

How do I make each takeaway link to the right moment in the video?

Keep the start time that comes with every transcript line, round it to whole seconds, and append it to the watch URL as a t parameter, like watch?v=VIDEO_ID&t=754s. YouTube opens the player at that second, so a reader can verify any point in one click.

Is it legal to summarise someone else's video with a script?

Summarising for your own research or notes is normally fine, but the underlying video and its captions stay the property of the creator. Do not republish a full transcript or a summary that substitutes for watching. Check the platform's terms of service before you automate anything at scale.