Build TranscriptAPI
KINDAreplaces $5/mosaves $60/yrback to the verdict
A personal caption store whose hard problems are that the only supported fetch needs edit permission on the video, that automatic caption segments are phrase fragments rather than sentences, and that the API bills quota units per call so a playlist loop can exhaust the day before lunch.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the service and the importer.
Get it python.org or your version manager. open ↗
Verify
python3 --version prints 3.12 or higher - accountfree within the default quota
Why The only supported caption fetch runs through it, and the project is also where the daily quota lives.
Get it Create a project, enable the API, configure the consent screen and create an OAuth client for a desktop application. open ↗
Verify
A consent flow completes and captions.list returns tracks for a video you own - have readyfree
Why Without it you cannot exercise the official source at all, and the whole authorisation path stays untested.
Get it Upload an unlisted test video to a channel you control and add captions to it.
Verify
The official source returns its track and segments - have readyfree
Why These are the importer's real inputs and the sentence heuristic needs an automatic-caption fixture with no punctuation to be tested honestly.
Get it Export captions from a video you control in each format, and keep one deliberately unpunctuated.
Verify
All three import to identical row shapes - decidefree
Why Deciding what the service may fetch after you have built the fetcher means deciding it from whatever already works.
Get it Write down which sources are enabled, what each one requires, and that no evasion layer will be added.
Verify
The policy endpoint and the README carry the same text
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- videos(id, title, channel_id, duration_ms, first_seen_at, added_by_source) - tracks(id, video_id, language, kind, is_default, source, fetched_at, revision) · kind is manual or automatic, and one language can have both - segments(track_id, ordinal, start_ms, duration_ms, text) · the fetched truth, integers only, never rewritten - sentences(track_id, ordinal, start_ms, end_ms, text, first_segment, last_segment) · derived, with offsets back into segments so a search hit maps to a timestamp - fetches(id, track_id, at, source, outcome, quota_units, http_status, retry_after_s) · every attempt including the refusals, because the refusals are the interesting ones - quota(day, units_used, units_limit) · counted locally before the call rather than discovered by the failure - clients(id, label, created_at, daily_request_limit, revoked_at) · only if this ever serves someone other than you - There is no column anywhere holding 'the transcript'. A video has tracks, a track has segments, and every response names the track it came from.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/transcripts.db | Videos, tracks, segments, sentences and the fetch log. Back this up; refetching is not always possible. |
GOOGLE_CLIENT_ID | optional | 1234-abcd.apps.googleusercontent.com | The OAuth client for the official captions source. Omit and only the file importer runs. |
GOOGLE_CLIENT_SECRETsecret | optional | GOCSPX-...redacted | Paired with the client id. Never in the repository. |
GOOGLE_TOKEN_PATHsecret | optional | ./data/oauth-token.json | Stored refresh token for the account that owns the videos. |
DAILY_QUOTA_UNITS | required | 10000 | Your project's published daily limit. Check it in the console rather than trusting this default. |
TRACK_MAX_AGE_DAYS | required | 30 | How stale a cached track may be before a refresh is allowed. |
CLIENT_KEYS_ENABLED | required | false | Turn on when something other than you starts calling this. |
The build, in order
A source interface, and the two sources you actually have
Captions enter the store through one interface, with the official API and a file importer behind it.
Two implementations written against one interface is the point of the exercise.
Files
src/sources/base.pysrc/models.pyIt has no credentials and no quota, so the schema gets settled cheaply.
Files
src/sources/captionfile.pytests/test_import.pyIt only works on videos you can edit, and the code should say so in its docstring.
Files
src/sources/youtube_api.pydocs/authorisation.md
done when · tick each as it passeswatch out- Calling a scraping library directly from your request handler feels like it saves a day, and it hard-wires both the legal exposure and the technical fragility into every code path you will later write, so when YouTube blocks the endpoint there is nothing to swap. Put every fetch behind the interface from the first commit, and let the stub carry the note about what a licensed provider would need to satisfy.
Segments are the truth, sentences are derived
Automatic caption fragments become searchable sentences without losing the timing.
It is the only record of what the source actually said.
Files
src/store/segments.pytests/test_immutable.pyAnd keep the segment range so a hit is a timestamp, not a video.
Files
src/sentences.pytests/test_sentences.pyFragment-level indexing cannot match a phrase a human would search for.
Files
src/search.pymigrations/002_fts.sql
done when · tick each as it passeswatch out- Automatic captions arrive as short unpunctuated fragments that split mid-sentence, so joining them with spaces and indexing the result gives you a body of text in which no phrase search ever matches what a person would type, and every hit resolves to the whole video. Keep segments untouched, derive sentences on top, and store the segment range on each sentence so a search result is a timestamp.
Quota you count, and errors that name the reason
A refusal is a distinct, documented outcome and never a bare five hundred.
The failure arrives too late to be a budget.
Files
src/quota.pytests/test_quota.py'Upstream error' is not a thing a caller can act on.
Files
src/errors.pysrc/api/routes.pyThe refusals are the ones you will want to read later.
Files
src/store/fetches.py
done when · tick each as it passeswatch out- The API bills quota units per call, and on 15 September 2026 Google's reference listed captions.list at fifty units and captions.download at two hundred, so a loop over a playlist burns a day's project budget in minutes and every later call then fails in a way that looks exactly like a network error. Check your project's current daily limit in the console, count units locally before the call, and return a distinct status when the budget is gone rather than a generic upstream failure.
A cache keyed on what actually varies
A repeat request costs nothing and a different language does not collide with the first one.
Any one of the three is enough to make the answer wrong.
Files
src/cache.pytests/test_cache_key.pyOld segments are how you explain an old answer.
Files
src/store/tracks.pySo a caller can see it got something other than what it asked for.
Files
src/api/schemas.py
done when · tick each as it passeswatch out- Keying the cache on the video alone means the first request decides the language and kind for every request that follows, so a caller asking for the manual German track quietly receives the automatic English one and nothing in the response contradicts it. Key on video, language and kind together, and put the track identity in the response body so a wrong answer is visible.
Serving it to something other than yourselfproduct builder
The service survives a second consumer, states its source policy, and exports everything.
One noisy caller should not be able to take the day from everyone.
Files
src/limits.pytests/test_limits.pyIncluding the fetch log, which is the history of the refusals.
Files
src/export.pytests/test_roundtrip.pyIt is the honest answer to 'why can it not fetch that video'.
Files
src/api/policy.pyREADME.md
done when · tick each as it passeswatch out- A personal service that quietly gets a second consumer will exhaust the shared daily quota on whichever caller loops hardest, and because the quota is global the other caller sees identical failures with no explanation. Give every key its own daily limit, spend from the global budget only after the per-key check passes, and say which limit was hit in the refusal.
That is the whole plan for TranscriptAPI. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- A proxy pool or any other blocking evasion.
- Channel, playlist and search over videos you do not control.
- Speech recognition over downloaded audio.
- Any availability promise.
- staying unblocked when YouTube rate-limits caption fetches
- search, channel and playlist endpoints
- reliability at bulk volume
- an SLA and support when YouTube changes something
- A diff view between two revisions of the same track, which is how you notice a caption edit
- Speaker labels imported from formats that carry them, kept out of the derived sentence text
- A small client library that fails loudly on the named outcomes rather than retrying them
Need the files? The project pack on the verdict page hands your agent the whole brief · more dev tools.