youtubetodoc.mp4
Turn any YouTube video into a comprehensive documentation link that AI coding tools and LLMs can easily index and understand.
- 📺 YouTube Video Processing: Extract video metadata, descriptions, and thumbnails
- 📝 Transcript Extraction: Automatically extract video transcripts in multiple languages
- 💬 Comments Integration: Optional inclusion of video comments for additional context
- 🤖 AI-Friendly Output: Generate structured documentation perfect for LLM consumption
- ⚡ Fast Processing: Efficient video processing with rate limiting and caching
- 🌍 Multi-Language Support: Support for transcripts in 9+ languages
- 📱 Responsive Design: Beautiful, modern UI built with Tailwind CSS
- 🔧 API Access: RESTful API for programmatic access
- 🐳 Docker Ready: Easy deployment with Docker and Docker Compose
- Backend: FastAPI + Python 3.11+
- Frontend: Tailwind CSS + Jinja2 templates
- Video Processing: yt-dlp, pytube, youtube-transcript-api
- Token Estimation: tiktoken
- Rate Limiting: slowapi
- Deployment: Docker, Docker Compose
# Clone the repository
git clone https://github.com/filiksyos/Youtube-to-Doc.git
cd youtubedoc
# Run with Docker Compose
docker-compose up -d# Clone the repository
git clone https://github.com/filiksyos/Youtube-to-Doc.git
cd youtubedoc
# Install dependencies (using pnpm as specified in requirements)
pip install -r requirements.txt
# Run the application
uvicorn src.server.main:app --host 0.0.0.0 --port 8000 --reload- Open your browser and navigate to
http://localhost:8000 - Enter a YouTube video URL
- Configure processing options:
- Transcript Length: Maximum characters to include
- Language: Preferred transcript language
- Include Comments: Whether to extract video comments
- Click "Create Docs" to process the video
Three equivalent ways to call it:
POST /api/video— JSON bodyGET /api/video?url=...— query paramsGET /api/video/{video_id}— path param, same query params as above
Duration eligibility. Before doing any expensive work, the API checks the video's
duration (read straight from yt-dlp metadata — no AI involved) against
[min_duration_seconds, max_duration_seconds]. 0 means "no bound" on either end, and
both default to 0, so out of the box any video is eligible.
A video outside that range is not turned into a doc at all. The response is still
200 and still carries the video's metadata — that's the evidence for the decision — but
allowed is false, not_allowed_reason says which bound it missed, and summary,
transcript.text, and comments are all null. No transcript is fetched and no summary
is generated, so a rejected video costs nothing. Branch on allowed, not on the status code.
If the duration can't be determined (metadata extraction degraded to the minimal fallback,
duration_seconds: 0), the video is allowed through rather than rejected on a limit that
can't be evaluated.
For an eligible video, all three routes always return metadata and a summary. The full
transcript is opt-in via include_transcript / content_mode. max_transcript_length
remains a separate cap on transcript size (applied server-side during extraction, and
reflected in limits.max_transcript_length).
The transcript and description are always fetched server-side for an eligible video.
The include_* flags only decide what the response carries — they never change what is
fetched, and never change how the summary is produced.
The summary is always derived from the transcript: an AI summary of it when a provider
is configured, otherwise an extractive excerpt of it. summary_source says which
("ai" or "transcript_excerpt"). The video description is never used as a summary — it is
channel-authored copy about the video rather than an account of what is said in it, and
returning it verbatim leaked text callers had asked to omit with include_description=false.
The transcript itself has two sources, tried in order: youtube_transcript_api, then
yt-dlp's caption tracks. They reach YouTube over different sessions, so one can work when
the other is throttled. processing.transcript_source reports which one produced the text
("youtube_transcript_api" or "yt-dlp"), and is null when there's no transcript.
So with no transcript there is nothing to summarize: summary and summary_source are
null, and processing.warnings carries transcript_unavailable +
summary_generation_failed. That warning pair is also emitted when the transcript wasn't
requested — a failed fetch still means no summary, so it's worth reporting either way.
| Parameter | Type | Default | Description |
|---|---|---|---|
url |
string | — | YouTube URL (required for /api/video; not used for the path-param route) |
content_mode |
summary | transcript | both |
derived | Shorthand for picking the content body. When set it overrides include_summary/include_transcript; when omitted it's derived from them and echoed back in limits.content_mode |
include_transcript |
boolean | false |
Include the full transcript text |
max_transcript_length |
integer | 10000 |
Max transcript size in characters |
transcript_max_words |
integer, optional | env default (0) |
Max transcript size in words, applied after the character limit. 0 means no word limit. Clamped to TRANSCRIPT_MAX_WORDS_CAP |
include_comments |
boolean | false |
Include video comments |
include_summary |
boolean | true |
Include the summary field; when false it's omitted entirely from the response |
summary_max_words |
integer, optional | env default (250) |
Max summary length in words. 0 means no word limit. Clamped to SUMMARY_MAX_WORDS_CAP. The summary is always generated from the full transcript, so a low cap shortens it without degrading it |
include_tags |
boolean | true |
Include video.tags; when false it's returned empty (and the Markdown ## Tags section is dropped) |
include_description |
boolean | true |
Include the raw video.description; when false it's null (and the Markdown ## Description section is dropped) |
include_chapters |
boolean | true |
Include video.chapters (the video's own chapter markers); when false it's returned empty (and the Markdown ## Chapters section is dropped) |
language |
string | en |
Preferred transcript language |
max_duration_seconds |
integer, optional | env default (0) |
Longest video eligible to be turned into a doc. 0 means no upper limit (any video). A positive value is clamped to MAX_VIDEO_DURATION_SECONDS_CAP (21600s / 6h); an explicit 0 bypasses that cap |
min_duration_seconds |
integer, optional | env default (0) |
Shortest video eligible to be turned into a doc, for skipping very short clips. 0 means no lower limit |
output_format |
json | markdown |
json |
Response format |
# Metadata + summary only (default — no transcript)
curl -X POST "http://localhost:8000/api/video" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'
# Include the full transcript (video must be within the eligible duration range)
curl -X POST "http://localhost:8000/api/video" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "include_transcript": true, "max_transcript_length": 20000}'
# Per-request duration override (still capped by MAX_VIDEO_DURATION_SECONDS_CAP)
curl -X POST "http://localhost:8000/api/video" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "include_transcript": true, "max_duration_seconds": 7200}'
# Summary only, capped at 100 words, without tags or the raw description
curl -X POST "http://localhost:8000/api/video" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "content_mode": "summary", "summary_max_words": 100, "include_tags": false, "include_description": false}'
# Chapters + tags only, no summary body (chapters come from the video's own markers)
curl "http://localhost:8000/api/video/dQw4w9WgXcQ?include_summary=false&include_description=false"
# Full transcript instead of a summary, capped at 2000 words
curl -X POST "http://localhost:8000/api/video" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "content_mode": "transcript", "transcript_max_words": 2000}'
# Only process videos between 2 and 30 minutes; anything else comes back allowed=false
curl -X POST "http://localhost:8000/api/video" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "min_duration_seconds": 120, "max_duration_seconds": 1800}'
# GET query-param convenience variant
curl "http://localhost:8000/api/video?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&include_transcript=true"
# GET path-param convenience variant
curl "http://localhost:8000/api/video/dQw4w9WgXcQ?include_transcript=true"
# Markdown output (e.g. for piping straight into a doc/notes tool)
curl -X POST "http://localhost:8000/api/video" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "include_transcript": true, "output_format": "markdown"}'Example JSON response:
{
"request_id": "3b28d908c3f547bd8f3e2a1c56e58911",
"processed_at": "2026-07-25T16:03:17.786044+00:00",
"allowed": true,
"not_allowed_reason": null,
"video": {
"video_id": "dQw4w9WgXcQ",
"title": "...",
"description": "...",
"duration_seconds": 212,
"duration_iso8601": "PT3M32S",
"duration_human": "3m 32s",
"view_count": 1000000,
"like_count": 50000,
"channel": "...",
"channel_id": "...",
"upload_date": "2009-10-25",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"thumbnail_url": "...",
"categories": ["Music"],
"tags": ["..."],
"chapters": [
{"title": "Intro", "start_seconds": 0, "end_seconds": 65, "start_human": "0s"},
{"title": "Main point", "start_seconds": 65, "end_seconds": 180, "start_human": "1m 5s"}
]
},
"transcript": {
"included": false,
"inclusion_status": "excluded",
"text": null,
"language": "en",
"length": 4381,
"word_count": 730,
"truncated": false,
"reason": "transcript_not_requested",
"preview": "First 200 characters of the transcript, for a quick look without pulling the full text..."
},
"summary": "...",
"summary_source": "ai",
"summary_truncated": true,
"comments": null,
"limits": {
"max_video_duration_seconds": 0,
"min_video_duration_seconds": 0,
"max_transcript_length": 10000,
"summary_max_words": 250,
"transcript_max_words": 0,
"content_mode": "summary"
},
"processing": {
"duration_ms": 842,
"metadata_source": "yt-dlp",
"transcript_source": "youtube_transcript_api",
"cache_hit": false,
"warnings": []
}
}transcript.reason is one of transcript_not_requested, transcript_unavailable,
video_exceeds_max_duration, video_below_min_duration, or null when the transcript is
included.
allowed is false only when the duration check rejected the video, in which case
not_allowed_reason is video_exceeds_max_duration or video_below_min_duration:
{
"request_id": "9c1f...",
"processed_at": "2026-07-30T09:12:03.114+00:00",
"allowed": false,
"not_allowed_reason": "video_exceeds_max_duration",
"video": { "title": "...", "duration_seconds": 7200, "duration_human": "2h 0m 0s", "...": "..." },
"transcript": { "included": false, "inclusion_status": "excluded", "text": null,
"reason": "video_exceeds_max_duration" },
"summary": null,
"comments": null,
"limits": { "max_video_duration_seconds": 1800, "min_video_duration_seconds": 0, "...": "..." }
}processing.warnings surfaces non-fatal degradations the request still succeeded through
(same vocabulary as the error codes below, but softer): transcript_unavailable,
transcript_language_unavailable (requested language not found, a different one was used),
summary_generation_failed (no AI summary was produced — either the provider was
unavailable and the summary fell back to a transcript excerpt, or there was no transcript
to summarize and summary is null),
comments_fetch_failed.
Metadata failure is not in that list: if every extractor fails there is no real title,
no duration (which would silently bypass the duration gate), and nothing worth summarizing,
so the request fails with 502 upstream_service_error rather than returning placeholder
data dressed up as a result. These failures are usually YouTube rate-limiting the server
and succeed on retry.
When output_format=markdown, the response is Content-Type: text/markdown; charset=utf-8
rendered from that same JSON DTO: title/metadata, ## Chapters (unless
include_chapters=false), ## Tags (unless include_tags=false),
## Description (unless include_description=false), summary (unless
include_summary=false / content_mode=transcript), and a ## Transcript section — either the full transcript, or a
line stating why it was excluded plus a short preview.
Errors are always JSON on /api/* routes:
{"error": {"type": "invalid_youtube_url", "message": "Invalid request parameters.", "details": [...]}}error.type |
HTTP status | Meaning |
|---|---|---|
invalid_youtube_url |
422 | url isn't a recognized YouTube URL |
invalid_video_id |
422 | Path-param video_id isn't 11 valid characters |
invalid_request_parameter |
422 | Any other invalid request parameter (bad output_format, negative max_duration_seconds, etc.) |
video_not_found |
404 | Video doesn't exist / was deleted |
video_unavailable |
404 | Video is private, age-restricted, or region-locked |
rate_limit_exceeded |
429 | Too many requests from this IP |
upstream_service_error |
502 | Video metadata could not be retrieved (every extractor failed — usually YouTube rate-limiting; retry), or another unrecognized extraction failure |
internal_error |
500 | Unexpected server error |
transcript_unavailable, transcript_language_unavailable, transcript_fetch_failed,
summary_generation_failed, and comments_fetch_failed are not hard failures in this API —
by design, the endpoint degrades gracefully and still returns 200 with metadata + a
summary. They instead surface in processing.warnings (see above) so callers can tell
something was degraded without the whole request failing.
metadata_fetch_failed is the exception: metadata is the one thing the response can't be
useful without, so a total metadata failure is reported as 502 upstream_service_error
instead of a warning.
import requests
response = requests.post(
"http://localhost:8000/api/video",
json={
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"include_transcript": True,
"max_transcript_length": 10000,
},
)
data = response.json()
print(data["video"]["title"], data["summary"])If every request comes back with transcript_unavailable and a null summary while metadata
resolves fine, YouTube is almost certainly rate-limiting or IP-blocking the host. Metadata and
caption content come from different endpoints, and only the latter tends to get blocked —
which is why the title and duration look perfect while the transcript is empty.
Both transcript sources go through the same blocked endpoint, so the fallback won't save you here. Confirm with:
yt-dlp --skip-download --write-auto-subs --sub-langs en --sub-format json3 -o - "<video url>"An HTTP Error 429 confirms it. The fix is to route requests through a residential proxy —
set USE_PROXY=true with PROXY_USERNAME/PROXY_PASSWORD, or the YTA_WEBSHARE_*
variables. Cloud hosts (AWS, GCP, Azure, Render) are blocked especially aggressively, so a
proxy is effectively required in production.
The duration limit changed from a transcript gate to an eligibility gate:
MAX_VIDEO_DURATION_SECONDSnow defaults to0(unlimited), not 1800. Set it explicitly if you relied on the old ceiling.- Previously, a video over the limit still returned metadata plus a summary, withholding
only the transcript. Now it is not processed at all:
allowed: false, andsummary,transcript.text, andcommentsarenull. Callers that readsummaryunconditionally must checkallowedfirst. max_duration_seconds=0is now valid and means unlimited; it used to be rejected as a non-positive value. Negative values are still rejected.transcript.reasoncan now also bevideo_below_min_duration.
The summary source changed:
- The video description is no longer used as a summary. It previously took priority over
the transcript, so a video whose transcript couldn't be fetched came back with its
description in
summary— including wheninclude_description=falsehad asked for that exact text to be omitted. With no transcript,summaryis nownull. - New
summary_sourcefield ("ai"|"transcript_excerpt"|null) says how the summary was produced. transcript_unavailableis now warned even wheninclude_transcript=false. It used to be suppressed, so a failed transcript fetch was invisible.summary_generation_failednow also fires when there was no transcript to summarize, not only when the AI provider failed.processing.transcript_sourcecan now be"yt-dlp", and isnullrather than"youtube_transcript_api"when no transcript was retrieved (it was previously hardcoded).- With
include_summary=false,summary_sourceandsummary_truncatedare now omitted alongsidesummaryinstead of being left behind describing an absent field.
POST / and POST /video/{video_id} remain available and power the browser UI. They
return rendered HTML, always include the full transcript (legacy behavior), and are
unaffected by the duration range — MIN/MAX_VIDEO_DURATION_SECONDS gate /api/video only.
curl -X POST "http://localhost:8000/" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "input_text=https://www.youtube.com/watch?v=dQw4w9WgXcQ"Create a .env file based on .env.example:
cp .env.example .envALLOWED_HOSTS: Comma-separated list of allowed hostsYOUTUBE_API_KEY: Optional YouTube Data API key for enhanced featuresDEEPINFRA_API_KEY: Optional DeepInfra API key for AI-generated summaries. Without it,/api/videofalls back to an extractive excerpt of the transcript (summary_source: "transcript_excerpt") and warnssummary_generation_failed.DEEPINFRA_MODEL: Optional DeepInfra model ID (default: meta-llama/Meta-Llama-3-70B-Instruct)RATE_LIMIT_PER_MINUTE: Number of requests per minute per IPMAX_VIDEO_DURATION_SECONDS: Longest video eligible to be turned into a doc, in seconds. 0 (the default) means no upper limit. Overridable per-request viamax_duration_seconds.MIN_VIDEO_DURATION_SECONDS: Shortest video eligible to be turned into a doc, in seconds. 0 (the default) means no lower limit. Overridable per-request viamin_duration_seconds.MAX_VIDEO_DURATION_SECONDS_CAP: Hard upper bound for a positive per-requestmax_duration_seconds; requests above it are silently clamped, not rejected. An explicitmax_duration_seconds=0(unlimited) bypasses it (default: 21600)DEFAULT_INCLUDE_TRANSCRIPT: Default forinclude_transcripton/api/video(default: False)DEFAULT_MAX_TRANSCRIPT_LENGTH: Defaultmax_transcript_lengthon/api/video(default: 10000)DEFAULT_INCLUDE_COMMENTS: Default forinclude_commentson/api/video(default: False)DEFAULT_INCLUDE_SUMMARY: Default forinclude_summaryon/api/video(default: True)DEFAULT_INCLUDE_TAGS: Default forinclude_tagson/api/video(default: True)DEFAULT_INCLUDE_DESCRIPTION: Default forinclude_descriptionon/api/video(default: True)DEFAULT_INCLUDE_CHAPTERS: Default forinclude_chapterson/api/video(default: True)DEFAULT_SUMMARY_MAX_WORDS: Default word cap for the summary; 0 disables the word limit (default: 250)SUMMARY_MAX_WORDS_CAP: Hard upper bound forsummary_max_words; higher requests are clamped, not rejected (default: 2000)DEFAULT_TRANSCRIPT_MAX_WORDS: Default word cap for the returned transcript; 0 disables the word limit (default: 0)TRANSCRIPT_MAX_WORDS_CAP: Hard upper bound fortranscript_max_words(default: 200000)DEFAULT_LANGUAGE: Default transcript language on/api/video(default: en)
To publish generated docs to S3 and show "View Documentation" and "Copy Documentation Link" buttons (as used on youtubetodoc.com), configure an S3 bucket and environment variables.
- Create an S3 bucket
- Region: choose your region (e.g., eu-north-1)
- Object Ownership: ACLs disabled (Bucket owner enforced)
- Public access: turn OFF “Block all public access” if you want public S3 URLs
- Add a read-only bucket policy (recommended to scope to
docs/):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPublicReadDocs",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET/docs/*"
}
]
}- Set environment variables in
.env:
AWS_S3_BUCKET=YOUR_BUCKET
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=eu-north-1- Restart the server so
.envis reloaded.
Notes
- The app auto-detects the bucket's real region to construct the correct URL, avoiding PermanentRedirect.
- If you prefer not to expose public S3, keep Block Public Access on and serve via CloudFront instead.
When deploying to cloud providers, YouTube often blocks requests from cloud IPs, causing IpBlocked or RequestBlocked errors. To fix this, configure rotating residential proxies:
-
Decodo Residential Proxies: Set
USE_PROXY=trueand provide credentials. Use the rotating endpoint (gate.decodo.com:7000, a fresh IP per request); port10001is the sticky pool and keeps the same IP, which stays blocked once YouTube rate-limits it:USE_PROXY=true PROXY_USERNAME=your_decodo_username PROXY_PASSWORD=your_decodo_password PROXY_URL=http://gate.decodo.com:7000 # rotating; port 10001 is stickyYouTube intermittently rate-limits residential exit IPs too. The app retries blocked requests (5 retries), and each retry rotates to a fresh IP.
-
Sign up for Webshare (recommended by
youtube-transcript-api):- Visit webshare.io and create an account
- Purchase a "Rotating Residential" plan (NOT "Proxy Server" or "Static Residential")
- Go to Proxies → Rotating Residential → Proxy Settings to get your credentials
-
Webshare Environment Variables in your deployment:
# Webshare credentials (base username/password)
YTA_WEBSHARE_USERNAME=your_webshare_username
YTA_WEBSHARE_PASSWORD=your_webshare_password
YTA_WEBSHARE_LOCATIONS=jp,kr,tw # optional: filter by countries
# Proxy URLs for yt-dlp/pytube (use specific pod usernames from your proxy list)
YTA_HTTP_PROXY=http://your_pod_username:your_password@p.webshare.io:80
YTA_HTTPS_PROXY=http://your_pod_username:your_password@p.webshare.io:80- Alternative: Use any HTTP/HTTPS proxy provider:
YTA_HTTP_PROXY=http://user:pass@proxy-host:port
YTA_HTTPS_PROXY=https://user:pass@proxy-host:portThe app will automatically route YouTube API calls through the proxy when these variables are set.
https://www.youtube.com/watch?v=VIDEO_IDhttps://youtu.be/VIDEO_IDhttps://www.youtube.com/embed/VIDEO_IDhttps://www.youtube.com/v/VIDEO_ID
- English (en)
- Spanish (es)
- French (fr)
- German (de)
- Italian (it)
- Portuguese (pt)
- Japanese (ja)
- Korean (ko)
- Chinese (zh)
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/video |
JSON API. Video metadata + summary; transcript opt-in via include_transcript, gated by max_duration_seconds/MAX_VIDEO_DURATION_SECONDS. Supports output_format=markdown |
GET |
/api/video |
Convenience GET variant of the above (query params) |
GET |
/api/video/{video_id} |
Convenience GET variant of the above (path param) |
GET |
/api/process |
Legacy: cache-check only (check_cache_only=true) |
GET |
/api/process/stream |
Legacy: Server-Sent Events processing stream |
GET |
/ |
Home page with video processing form (HTML) |
POST |
/ |
Process a YouTube video, full transcript, HTML response |
GET |
/video/{video_id} |
Get processing form for specific video (HTML) |
POST |
/video/{video_id} |
Process specific video, full transcript, HTML response |
GET |
/watch?v={video_id} |
YouTube-style watch redirect (HTML) |
GET |
/api |
API documentation page (HTML) |
GET |
/health |
Health check endpoint |
/api/* routes always return JSON, including on errors. All other routes render HTML for the browser UI.
- Main endpoint: 10 requests per minute per IP
- Video-specific endpoint: 5 requests per minute per IP
docker-compose up -d# Build and run
docker build -t youtubedoc .
docker run -p 8000:8000 \
-e ALLOWED_HOSTS=yourdomain.com \
-e DEBUG=False \
youtubedocThe generated documentation includes:
- Video Metadata: Title, duration, view count, channel info
- Description: Full video description
- Transcript: Complete video transcript with timestamps
- Comments: Top video comments (if enabled)
- Token Estimation: Estimated token count for LLM usage
pip install -r requirements-dev.txt
python -m pytestTests mock all YouTube network calls (yt-dlp/pytube/transcript-api), so the suite runs offline.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
- Inspired by Gittodoc for the overall architecture and design
- Built with FastAPI for the web framework
- Uses yt-dlp for robust YouTube video processing
- Styled with Tailwind CSS for the modern UI
If you encounter any issues or have questions:
- Check the API documentation
- Review the issues page
- Create a new issue if needed
Made with ❤️ for the AI and developer community