Build TranscriptAPI

KINDAreplaces $5/mosaves $60/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort weekendthe files for this build are in the project pack

RuntimePython 3.12 with FastAPI and uvicorn, one process, no worker fleetStorageSQLite beside the app with a full text index over the sentence layerSourcesA source interface with the official captions API and a caption file importer behind itAuthorisationOAuth for the official API, local keys if you serve anyone but yourselfUnitsSegment times as integer milliseconds, never floating point seconds

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. free 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

  3. 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/transcripts.dbVideos, tracks, segments, sentences and the fetch log. Back this up; refetching is not always possible.
GOOGLE_CLIENT_IDoptional1234-abcd.apps.googleusercontent.comThe OAuth client for the official captions source. Omit and only the file importer runs.
GOOGLE_CLIENT_SECRETsecretoptionalGOCSPX-...redactedPaired with the client id. Never in the repository.
GOOGLE_TOKEN_PATHsecretoptional./data/oauth-token.jsonStored refresh token for the account that owns the videos.
DAILY_QUOTA_UNITSrequired10000Your project's published daily limit. Check it in the console rather than trusting this default.
TRACK_MAX_AGE_DAYSrequired30How stale a cached track may be before a refresh is allowed.
CLIENT_KEYS_ENABLEDrequiredfalseTurn on when something other than you starts calling this.

The build, in order

  1. 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.

    1. Two implementations written against one interface is the point of the exercise.

      Files src/sources/base.pysrc/models.py

    2. It has no credentials and no quota, so the schema gets settled cheaply.

      Files src/sources/captionfile.pytests/test_import.py

    3. It 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 passes
    watch 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.
  2. Segments are the truth, sentences are derived

    Automatic caption fragments become searchable sentences without losing the timing.

    1. It is the only record of what the source actually said.

      Files src/store/segments.pytests/test_immutable.py

    2. And keep the segment range so a hit is a timestamp, not a video.

      Files src/sentences.pytests/test_sentences.py

    3. Fragment-level indexing cannot match a phrase a human would search for.

      Files src/search.pymigrations/002_fts.sql

    done when · tick each as it passes
    watch 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.
  3. Quota you count, and errors that name the reason

    A refusal is a distinct, documented outcome and never a bare five hundred.

    1. The failure arrives too late to be a budget.

      Files src/quota.pytests/test_quota.py

    2. 'Upstream error' is not a thing a caller can act on.

      Files src/errors.pysrc/api/routes.py

    3. The refusals are the ones you will want to read later.

      Files src/store/fetches.py

    done when · tick each as it passes
    watch 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.
  4. A cache keyed on what actually varies

    A repeat request costs nothing and a different language does not collide with the first one.

    1. Any one of the three is enough to make the answer wrong.

      Files src/cache.pytests/test_cache_key.py

    2. Old segments are how you explain an old answer.

      Files src/store/tracks.py

    3. So a caller can see it got something other than what it asked for.

      Files src/api/schemas.py

    done when · tick each as it passes
    watch 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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more dev tools.