# Changelog of YouTube Transcript API & Scraper — YouTube Subtitles, Speech AI (`steadyfetch/youtube-transcript-scraper`) Actor

- **URL**: https://apify.com/steadyfetch/youtube-transcript-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/steadyfetch/youtube-transcript-scraper.md

## Changelog

### 1.0.69 — 2026-09-19

- **The whole "Maximum cost per run" you set is now spent on transcripts.** A capped run used to hold back about a seventh of your cap as a cushion against platform usage — but your cap pays for the transcripts and the speech minutes and nothing else, so that seventh was cap you had asked to spend and did not get: a cap worth twenty-three transcripts delivered twenty. A run now transcribes every video its cap can pay for, and still ends the way it always has — on its own line naming your cap and what was left, never on a run the platform cuts short. Videos with no captions are unaffected: the speech minutes they bill have always been held against the live room at the moment they are charged, and still are. Nothing about the price, the charged events, the input form or any output column changed.

### 1.0.68 — 2026-09-16

- **The one step left that could hold a run open past your timeout now ends inside it.** Before a run can use a proxied connection it asks Apify for the session, once per connection type, and that request had no time limit at all — not its own, and not your run's. On a slow minute it could sit there after your run timeout had passed, which is the one way a run could still be killed mid-step and lose its closing rows, its receipt and its status line. It is now given the smaller of a twenty-second ceiling and whatever the run has left, and a request that runs out is treated exactly as a session that could not be opened already was: an uncharged, re-runnable row, and the next video asks again. A run with time to spare is unchanged. No input field, output column, charged event or price changed.

### 1.0.67 — 2026-09-15

- **A run now stops at your run timeout instead of reading past it.** Every check in this actor asked whether a fetch had time to *start*; once one was under way, nothing could end it — so a fetch that stalled could still be open after your run timeout had passed, and the platform then killed the container mid-fetch. When that happened you lost the part of the run that matters most: the closing rows, the receipt row that reconciles what was delivered against what was charged, and the status line naming the limit that stopped things. Every fetch is now given the smaller of its own timeout and the time your run actually has left, with a reserve kept back to write all of that down — so a run that runs out of time ends by saying so, on the run page and on its own uncharged, re-runnable row, with the receipt intact. A run with time to spare is untouched: every fetch still gets its full timeout, and no video that used to be delivered is delivered any differently. No input field, output column, charged event or price changed.

### 1.0.66 — 2026-09-14

- **Two new ways to run this on a schedule: deliver only what is new, and chain it straight onto another scraper's run.** Turn on **New items only** (`newItemsOnly`) and a run leaves out every video your account already has — nothing fetched, nothing charged, no row — so a weekly Task hands your Sheet, webhook or database one row per new video and nothing to de-duplicate; the run's status line says how many were skipped, and a run whose whole list was already yours succeeds with that count. It changes only which videos are planned: a repeat was never charged and still is not. And **Dataset ID (chain a scraper run)** (`datasetId`, or `datasetItems` for rows you paste) points this actor at a finished run from any other scraper and transcribes every YouTube link in its rows, however deeply nested and whatever the field is called — with Apify's "Connect actor" integration the ID is filled in for you, so a search-or-feed scraper plus this one is one Task you can schedule. Rows holding no YouTube link are counted and said, never charged; a bare 11-character string in someone else's row is never read as a video ID; and a link already in **Video URLs or IDs** is still one video and one charge. Both fields are optional and default to off, so every existing run, Task and integration behaves exactly as before. No output column, charged event or price changed.

- **A busy run's status line no longer gets cut off at the end.** The run page truncates a status message at 500 characters, and a run that delivered a lot, hit the YouTube wall on some videos and reached more than one limit could write a longer line than that — so the part the platform cut was the reconciliation and the "open an issue" ask at the end, the half worth reading. The line now shortens itself whenever it would overflow, for any reason: the per-reason census gives way first (every count in it is already on its own row and in the run's OUTPUT), then the support ask. What never gives way, on any run: what you were charged, what was handed back uncharged, and the name of every limit that stopped something.

### 1.0.65 — 2026-09-14

- **A token that can read the memory but not write to it now says so — before it costs you a second run.** The account memory that hands back a transcript you have already paid for is a key-value store in your own Apify account, and permission to read it and permission to write to it are two different grants. A run started with a token that has Read but not Write — or Write but not Create, on an account that has never run this actor — opens the memory, recognises every video you already had and hands it back uncharged, and then silently fails to write down anything it delivers: the run looks perfect, and your NEXT run is charged all over again for every transcript this one just produced. It now says so the moment the first write is refused, in three places: the run page, the run log, and one uncharged row in the dataset carrying the whole instruction — give the token key-value store Write (and Create) permission under Settings → API & Integrations, or set Actor runs to Full access. Nothing about the run itself changed: the same videos are delivered and the same rows charged, and a run with a full-access token, or one started from the console, behaves exactly as before. No input field, output column, charged event or price changed.

- **A run started with a scoped API token now says why it could not skip the videos you already have — and exactly what to change.** The account memory that hands back a transcript you have already paid for lives in a key-value store in your own account. A run started over the API with a token limited to "Restricted access" and no key-value store permission cannot open that store at all, so every repeat in the list is fetched, transcribed and charged again — the one thing this page promises never happens. The run used to say only that "the check was unavailable", which reads like a passing glitch rather than a permission you can grant. It now names the cause and the fix on the run page, in the run log, and in one uncharged row at the top of the dataset: give the token key-value store Read, Write and Create permission under Settings → API & Integrations, or set Actor runs to Full access, or start the run from the console. Runs started from the console, or with a full-access token, behave exactly as before, and so does a run whose memory failed for any other reason. No input field, output column, charged event or price changed.

### 1.0.64 — 2026-09-13

- **The pointer to the channel actor now names it by its current title.** If you have a channel rather than a list of video links, this page sends you to that actor — and it is now called YouTube Channel Videos Transcript Scraper — Bulk Video to Text, renamed so the name says what a plain run there delivers (the newest 50 videos per channel, up to 5,000). The link points at the same actor as before; only the words a reader sees changed. Nothing here moved: no input field, output column, charged event or price changed.

### 1.0.63 — 2026-09-13

- **An agent or a person who writes the singular field name now gets the transcripts, instead of an error row naming a field they never set.** This actor reads its list from `videoUrls`. A caller that sent `videoUrl`, `url`, `video`, `videos`, `videoId` or `videoIds` had that list passed straight through by the platform and ignored here, so the run answered the one-video sample or an error row that named `videoUrls` — a field the caller had not used. Those names are now read as the same list, whether they hold one link or many: the links are used exactly as sent, a list sent under two names is merged and de-duplicated so a video is still one transcript and one charge, and one uncharged row and one line in the run log say which name was read and that the field here is `videoUrls`. A channel name (`channel`, `channelUrl`, `channelUrls`) is read the same way and answered with the usual pointer to steadyfetch/youtube-channel-transcripts rather than "not an input field". The input form is unchanged — `videoUrls` is still the one field the schema declares and the one to use — and nothing charged or priced changes.
- **The store page now says how often a transcript actually changes, and what a second run of the same list gets you.** A published video's transcript does not change, so a new section says plainly that re-running a link you already have brings back nothing new, that the two rows worth running again are `live_no_transcript_yet` and `blocked_retry` (both uncharged), and that the account memory hands back everything you already had — `repeat: true`, `charged: false`, nothing fetched and no speech-to-text minute spent — for 90 days, after which those same videos are transcribed and charged again. It also says which schedule is worth setting up (one whose `videoUrls` list grows, saved as a Task on an Apify Schedule with an integration or a webhook on run succeeded) and names `repeat` as the field that tells you whether a run brought anything new. Nothing else moved: no input field, output column, event or price changed, and a run behaves exactly as before.
- **The store description now names the speech-to-text minute.** The card quoted the per-transcript price alone, while a video with no captions is also billed $0.008 per started minute of speech-to-text — on by default, and disclosed on the input form and on this page but not on the card, so a caption-less ten-minute video cost $0.08 more than the card led a buyer to expect. The card now says so, and says the minute is charged only when speech-to-text actually runs. No price, event, input field, default or output column changed.
- **This actor has a new title on the store: YouTube Transcript API & Scraper — YouTube Subtitles, Speech AI.** It now names the two ways people come looking for this: an API they can call from their own code, and YouTube subtitles. Both were already true — every actor here is callable over the Apify run API, and subtitles are what a run returns, with speech-to-text when a video has none. "SRT" leaves the title for room and is unchanged everywhere it matters: `format` still takes `srt`, and the page and the input form still say so. Same actor, same id, same URL, same input, same output, same price — nothing you have saved, scheduled or wired into an integration needs touching.

### 1.0.62 — 2026-09-12

- **An aborted or migrated run now leaves its receipt from the first moment of the run, instead of only after start-up finishes.** The record that says how a run ended was only put in place once the run had finished starting up — reading your input, opening its stores, working out what a previous attempt had already delivered and charged. A run stopped inside that window ended with nothing written about it at all, which mattered most on a re-started run, where the thing not written about was the earlier attempt's work. It is now in place from the run's first moment. Nothing else moved: no input field, output column, event or price changed, and a run that reaches its work behaves exactly as before.

### 1.0.61 — 2026-09-12

- **The run now holds its own minimum gap before every request, and stops opening a brand-new connection for each video.** We keep a small pause between calls to YouTube so a run never arrives as a burst. Two things meant it was barely being held: the pause was applied to the first of the three requests each video makes and not to the subtitle fetch or the audio download, and every video was fetched on a freshly opened connection, which starts its own clock owing nothing. A run now carries the connection that answered from one video to the next and waits the same minimum before every request on it. A video that is refused or challenged still gets a fresh connection straight away, exactly as before, and a refused connection is never offered to the next video. Nothing about your input, your rows, your columns or what you are charged changes — this is only about how often we knock.
- **This actor has a new title on the store: YouTube Transcript Scraper — YouTube Subtitles, Speech AI, SRT.** Same actor, same id, same URL, same input, same output, same price — only the words on the listing changed, so nothing you have saved, scheduled or wired into an integration needs touching. The title now carries the phrase buyers actually search for, which is the only reason it moved.
- **The repeat-memory section now says where to point a weekly schedule.** If what you want is a whole channel kept current every week rather than a fixed list of videos, the page now says so at the point where it explains that a video you already paid for comes back uncharged: the channel actor is the same engine with a channel as its input. Nothing here changes — same input, same output, same price.
- **Input schema: the description opens with the call that works, the per-video price with the speech-to-text minute, the arithmetic and the cap; the required field's first sentence is now the value shape, the console sample last.** Nothing about the form, the fields or the price changed — only the words that explain them, which is what an AI agent reads before it calls this actor.
- **An over-ask on a cap no longer refuses the run.** `maxItems` and `maxSpeechMinutes` carried a `maximum` in the input schema, which the platform checks before a container starts — so a caller asking for 10,000 videos got an error and no run at all, rather than the 5,000 the actor can serve. Both ceilings now live only in the code: the run starts, continues at the ceiling, and ships one uncharged row saying what you asked for and what bound it. Nothing about the form, the defaults, the output or the price changed.
- Nothing else moved: no input field, output column, event or price changed.

### 1.0.60 — 2026-09-11

- **A video left unread because transcribing it would pass your maximum cost per run is now counted against that cap, not against the video.** When a video has no captions we transcribe its audio, and before we start we check whether those minutes would take the run past your **Maximum cost per run**. If they would, the video is left alone and nothing is charged — that part is unchanged and always was. What was wrong is where it was counted: the run put those videos in with the ones that genuinely produced nothing, so your own spend cap read as a fault in the video, and the run could finish without ever naming the cap as the thing that stopped it. They are now counted where they belong — reported as "left at your maximum cost per run", with your cost cap named as the reason the run ended. Nothing about the money changes: those videos were never billed and still are not.
- **When a run comes back short, we can now see WHY, video by video.** Every uncharged row already told you what happened to that video; our own private run record folded almost all of them into a single number, so a video whose owner has captions switched off, one that has been taken down, an age-restricted one, one blocked where we fetched from, a live stream with no transcript yet and our own speech service failing all looked identical to us — and a problem worth fixing could sit unnoticed. Thirteen causes are recorded separately now. Your rows, their wording and what you are charged are exactly as before.
- **AI agents can now pin this actor from the top of the README** — the pin link, the actor id, the one input field you have to set and how to cap a run's bill are on the first screen of the page instead of far down it.
- **A maintenance detail:** the private run-report this actor writes for our own support — counts only, never anything you typed — now also records the per-event price your plan was billed at, and whether a run that stopped at the video limit you set had actually filled it. Nothing a run does, delivers or costs is different.
- The input form is unchanged: `videoUrls` is still the one field, still required, still the name on the Input tab. Nothing else moved — no price, no event, no charged column, and only delivered transcripts are charged.

### 1.0.59 — 2026-09-10

- **A made-up closing line after the end of the audio no longer hides a real transcript — and is never delivered.** Speech recognition sometimes invents a sign-off, most often "Thank you.", and stamps it with a time that falls past the end of the video. When that stamp landed far enough out, the whole transcript was judged unreliable and the video came back marked as having no speech: nothing delivered and nothing charged, even when there were 25 seconds of clear speech on it. It was found on a real customer run of our Facebook ads transcriber — a 30-second property ad with 25 seconds of clear voiceover that came back with nothing at all — and the same speech step runs here. From this build the invented line is removed before anything else is decided, so the real speech is delivered and charged exactly as usual — and the invented line is taken out of the transcript and the timed cues too, so you are never handed, or billed for, a sentence the audio does not contain. The same invention was also slipping through quietly at the end of transcripts that DID deliver; it is gone from those as well. The length we measure for billing is unchanged.
- The input form is unchanged: `videoUrls` is still the one field, still required, still the name on the Input tab. Nothing else moved — no price, no event, no charged column, and only delivered transcripts are charged.

### 1.0.58 — 2026-09-10

- **A maintenance build: nothing about your runs changes.** The private run-report this actor writes for our own support — counts only, never anything you typed — gained room for four figures it does not fill in yet: how many targets a run was given, how long it waited on a blocked source, how many of those waits recovered, and a reason code for an item our own size limit refused. The build carries the shared contract so a later one can report them; on this actor every one of them is left blank, and nothing a run does or costs is different.
- The input form is unchanged: `videoUrls` is still the one field, still required, still the name on the Input tab. Nothing else moved — no price, no event, no charged column, and only delivered transcripts are charged.

### 1.0.57 — 2026-09-09

- **Two runs started at the same time no longer erase each other's memory.** The account's repeat memory is now merged on every write, so an item one run delivered stays remembered, and a later re-run hands it back instead of charging it again.
- **Patience is now priced.** A wall is waited out only for as long as the rows it could still recover are worth, and a wait is sized to the clock actually left instead of being refused when the full pause would not fit. The first pause is shorter, and a wall that answers the same way twice ends the wait for that video. Runs on a short time limit get patience they never got before; long runs stop paying for pauses that were never going to pay for themselves.
- The input form is unchanged: `videoUrls` is still the one field, still required, still the name on the Input tab. Nothing else moved — no price, no event, no charged column, and only delivered transcripts are charged.

### 1.0.56 — 2026-09-09

- **A list of videos sent under another actor's field name now runs, instead of coming back with nothing.** The transcript actors in this family do not all call their input list the same thing: it is `videoUrls` here, `channels` on the channel actor, `urls` on the media one. Send a body written for one of them to another and the platform quietly ignores the field it does not know, fills in the defaults, and the run answers a question you never asked. This actor now reads `urls` and `channels` as its own video list, uses every link exactly as sent, and adds one uncharged note row saying the field is called `videoUrls` here so the next run needs no note at all.
- **A channel link is still answered honestly, whatever field it arrives in.** Sending `channels` here does not turn this into the channel actor: a channel link still gets the same uncharged row pointing you at steadyfetch/youtube-channel-transcripts, which is the actor that walks a whole channel. Only the field NAME was ever the misunderstanding.
- The input form is unchanged: `videoUrls` is still the one field, still required, still the name on the Input tab. Nothing else moved — no price, no event, no charged column, and only delivered transcripts are charged.

### 1.0.55 — 2026-09-07

- **A music-only or silent video is no longer charged as a transcript.** When a video carried no speech, the transcriber sometimes invented a short caption for it — "Outro Music", "Música", "The End" — and that invented caption was delivered and billed as if it were the video's own words. Short videos were where it happened: below fifteen seconds the only thing standing between an invented caption and your bill was a list of exact phrases, and anything not on the list went straight through. A video whose transcript is nothing but a sound or a card is now recognised as such at any length and ships as an uncharged no-speech row — no result fee, exactly as the store page says.
- **A short video that really does speak is still delivered.** Speech is now weighed against the part of the video the words actually cover rather than the whole of its length, so a six-second video carrying a three-word line reads as speech and not as silence. Short spoken videos that used to come back as "no speech" are delivered.
- Nothing else moved: no price, event, input field or output column changed.

### 1.0.54 — 2026-09-07

- **"txt" and other everyday spellings of an output format now work instead of ending the run.** "Output format" takes json, text, srt or vtt. Any other spelling used to end the whole run before a single transcript was fetched — so "txt", "plain text", "webvtt" or "subrip" cost you the run even though each of them can only mean one thing. They are now understood, along with the option's own names on the form ("JSON with timestamps", "Plain text", "SRT subtitles", "VTT subtitles").
- **A value that genuinely names no format, or names two, is still refused before anything is charged.** "subtitles" and "captions" describe both srt and vtt, so this actor asks rather than guessing, and a format it does not produce is still named back to you on one uncharged row. Guessing would hand you a file you did not choose and bill you for it.
- Nothing else moved: no price, event, output column or charge changed, and only delivered results are charged.

### 1.0.53 — 2026-09-06

- **A host we cannot reach directly is no longer reported as a host that does not exist.** Some servers publish only one kind of internet address, and it is a kind our machines cannot dial straight out. The player, caption and audio lookups used to demand the other kind, get "no such address" back, and answer as though the video or the page were gone — a verdict that also stopped the run from retrying through a different route, because a thing that does not exist cannot be found on another route either. It now *prefers* the address it can dial rather than demanding it, so every host that publishes both is reached exactly as before, and one that does not is answered honestly and retried. Nothing was ever charged for either row.
- Nothing else moved: no price, event, output column or charge changed, and only delivered transcripts are charged.

### 1.0.52 — 2026-09-06

- **A block of links pasted into one row is now read as the list you meant, whatever separates them.** "Video URLs or IDs" takes one video per row. A whole block pasted into a single row was read as one very long address, so a paste of fifty links became one item — one video looked up at best, and the other forty-nine silently gone. A pasted block is now split back into its individual links whether they are separated by spaces, line breaks, tabs, commas, semicolons, pipes or nothing at all, so a column copied straight out of a spreadsheet works. Each one is then read, deduplicated and charged on its own, exactly as if you had pasted them one per row.
- **A link that carries another link inside it is still one link.** An address holding a second address in its query or its path is left whole, not broken in two, and a single link pasted on its own is never rewritten.
- **Text pasted around a link no longer breaks it.** A number, a bullet or a note sitting beside a link is ignored and the link itself is used. A row holding no link at all is still answered as one row, not one row per word.
- Nothing else moved: no price, event, output column or charge changed, and only delivered results are charged.

### 1.0.51 — 2026-09-06

- **Every video your maximum cost per run left behind now arrives as its own row.** When a run reached its **Maximum cost per run** before a video's turn, that video was simply absent from your dataset: the run page counted how many were left, but nothing named them, so a list of ten links whose cap covered three gave you three rows and no way to tell which seven were missing. Each one now ships as an uncharged `skipped_budget` row carrying its video id and the link you sent, with a reason saying the run reached its maximum cost before that video was started.
- **The row says how to get it:** raise the maximum cost per run, or split the batch, and re-run. The rows are flagged `retryable`, so you can filter exactly what a re-run would bring back.
- **Nothing was fetched and nothing is charged for these rows** — the video was never started, so there is no caption lookup, no speech-to-text and no result fee.
- **A re-run still fetches them.** These rows are deliberately not recorded as delivered, so resuming or re-running the same list picks them up again.
- **Nothing about pricing, events, input or output columns changed.**

### 1.0.50 — 2026-09-06

- **Every video the run timeout left behind now arrives as its own row.** When a run reached its time limit before a video's turn, that video was simply absent from your dataset: the run page said how many were left, but nothing named them, so a list of ten links that ran out of clock after three gave you three rows and no way to tell which seven were missing. Each one now ships as an uncharged `skipped_deadline` row carrying its video id and the link you sent, with a reason saying the run reached its time limit before it was started.
- **The row says how to get it:** raise the run timeout, or split the batch, and re-run. The rows are flagged `retryable`, so you can filter exactly what a re-run would bring back.
- **Nothing was fetched and nothing is charged for these rows** — the video was never started, so there is no caption lookup, no speech-to-text and no result fee.
- **A re-run still fetches them.** These rows are deliberately not recorded as delivered, so resuming or re-running the same list picks them up again.
- **Nothing about pricing, events, input or output columns changed.**

### 1.0.49 — 2026-09-06

- **A video whose transcript was already in hand when your cost cap was reached now reaches you.** If a run hit its **Maximum cost per run** in the moment a video's transcript came back — a caption mint, or a speech-to-text pass already made — that transcript was thrown away and you got an empty "cost cap reached" row in its place. You paid nothing for it, but the answer you had waited for was gone, and only a re-run at a higher cap could bring it back. That video now arrives complete, with `charged: false` and a row saying the cap was reached and nothing was charged for it.
- **Your maximum cost is still never exceeded** — the cap takes the charge, not the result.
- **Videos the cap stopped before any work was done are unchanged:** they are still reported as `skipped_budget` naming the cap.
- **Nothing else moved:** no price, event or output column changed, and you are still charged only for transcripts that actually deliver.

### 1.0.48 — 2026-09-05

- **The run's record now says what stopped the run, and counts the videos it did not deliver.** A run reported nothing about how it ended — your max-items cap, your max charge, the run timeout or a move to another server all read the same — so a run that was cut short was indistinguishable from one that simply finished. Every run now records which of those ended it.
- **A resumed run counts what it already delivered.** When the platform moves a run to another server, the new one never transcribes or charges again for a video already in your results — but it also did not count those videos, so a resumed run's own record read as if it had delivered fewer than it did. They now count as delivered, which is what they are.
- **An entry stopped by a setting is counted once, not twice.** A link the actor could not read was tallied both in the run's total and again under the field it was about, so a run with one such entry accounted for more entries than it was ever given.
- **Nothing about your results or your bill changed** — no row, price, event, column or charge, and the run page's wording is unchanged. Delivered transcripts are charged; misses, skips and guidance rows stay uncharged.

### 1.0.47 — 2026-09-05

- **A run that waits now always keeps room to finish and report.** When YouTube walls every connection for a video, the run can pause and climb the whole connection ladder again — but the check that decided whether a pause was affordable kept back only half a minute for that second climb, while a climb here is four connections, each with its own thirty-second limit. On a short run — the five-minute limit the daily health check uses among them — the pause was allowed and the climb after it then ran into the time the run keeps back for writing your rows and the summary. The check now keeps back a whole climb, measured from the ladder's own connection count and timeout, so a pause is only taken when the run can finish the climb the pause was for. A run with room to wait waits exactly as it did before.
- **Charges are unchanged.** Waiting is uncharged, a walled video is uncharged, and only delivered transcripts are charged.

### 1.0.46 — 2026-09-05

- **A run started with no input at all now has room to wait out a block.** Press Start on the untouched form and this actor transcribes one short real video, charged like any run. YouTube sometimes turns a video's connections away for a few minutes at a time, and one round of attempts could use up most of the sample's own three-minute limit — so the pause-and-try-again this actor gained in the last build could never happen on the most common first run of all: it reported "please re-run" while the block was still lifting. The sample's time limit is now six minutes, sized to hold one full round of attempts, one pause, one more round, and enough time left over to stop cleanly and report. A sample YouTube answers still finishes in seconds; only a blocked one uses the extra time, and nothing is charged unless a transcript is delivered.
- **A no-input run was also measuring its pause against the wrong clock.** It checked the pause against the run's overall time limit instead of the sample's own shorter one, so a blocked sample could start a pause its own limit had no room for. The pause is now measured against the limit the run is actually working under. Nothing changes for a run with your own videos in it.
- **How much of its remaining time a blocked run may spend waiting now follows the size of what you asked for.** A run with more videos still to transcribe keeps exactly the limit it had — no more than half the time left — so a wide block still leaves the later videos their share. A run with a single video to fetch may spend what is left on its one pause, because nothing else is waiting for that time.
- **The no-input sample now always transcribes for real.** It used to be handed back from this account's own history of past runs, so a second Start returned the earlier copy uncharged and nothing was actually fetched. The sample now ignores that history in both directions: it fetches and transcribes every time, and it does not add its own video to the history. A run with your own videos in it is unchanged — a video this account already has is still handed back to you uncharged.

### 1.0.45 — 2026-09-05

- **A video YouTube walls is now waited out inside your own time budget, instead of being handed straight back for a re-run.** Until now the run tried a walled video on four connections in a row — with no pause between them, so the whole ladder burned in well under a minute — and then shipped an uncharged "please re-run" row while most of a long run's time went unused. That wall is a per-connection bot check that usually lifts within minutes. Now, while the run still holds real time and the videos still to collect can pay for the idle minutes, it waits a minute and a half to three and walks the same four connections again. A video that answers after the wait is delivered and charged exactly as if the first try had answered.
- **What never waits:** a video that is removed, private, members-only, age-restricted, region-blocked, a live stream still being processed, and the run's own row, time and cost limits — those are answers, and a wait cannot change them. A run already at its time limit waits for nothing.
- **Short runs are unchanged.** A wait plus a fresh walk has to fit inside what the run has left, with time kept back to finish and report, so a 60-second run makes its single walk exactly as before, and the no-input sample keeps its own three-minute window.
- **One run's waiting is capped in total, not per video.** A wall storm across many videos shares one budget for patience — a few minutes at most — so a long list can never sit waiting video after video. Videos are worked in the order you sent them, and because the wall is per connection, the minutes the first walled video waits are minutes every video after it also gains.
- **A video still walled after the waits keeps its usual uncharged row**, which now says what was tried: how long the run waited and how many passes it made.
- **Charges are unchanged:** waiting is uncharged, a walled video is uncharged, and only delivered transcripts and the speech minutes they use are charged. A video's own data limit is untouched by a wait, so a patient run can never buy more data for one video than an impatient one.

### 1.0.44 — 2026-09-05

- **The default sample can fall back to speech-to-text again.** Since 1.0.43 a video's audio is only fetched and transcribed when the run has enough time left for that step's worst case. The bare-Start sample (nothing set, or only a setting changed) runs under a short three-minute window, and that window was shorter than that worst case — so whenever the sample video's captions were unavailable, the sample came back as an uncharged "the run reached its time limit" row within seconds instead of a speech-to-text transcript. The worst-case check now reads the run's real time limit; the three-minute window still bounds how long the sample keeps starting new work. Runs that name their own videos were never affected, and a sample answered from captions never was.

### 1.0.43 — 2026-09-05

- **A video near the run's time limit now stops cleanly instead of being cut off mid-transcription.** Fetching a video can take several attempts, and transcribing its audio takes longer still. Those steps now check the run's own clock and never start work they cannot finish in time, so a run that reaches its limit ends with the transcripts it already has. Any video it did not reach comes back as an uncharged "please re-run" row (you can also raise the run timeout) rather than a run cut short.

### 1.0.42 — 2026-09-04

- **Settings with no videos now run the sample under your settings, instead of returning one guidance row.** Choosing a different **Output format**, a preferred caption language, turning speech-to-text off, or setting a limit and clicking **Start** with the video list empty used to end the run with a single uncharged "no videos were provided" row — so changing one dropdown was answered worse than changing nothing at all. That run now transcribes the sample video with your settings applied, charged like any run.
- **A limit is a ceiling, never a request.** On one of these runs "Max videos" and "Max speech-to-text minutes" are honoured as *at most* — the sample's own one video and one minute are used when they are smaller, so narrowing a setting can never widen the bill.
- **One uncharged row explains it**, naming every setting you did set, that everything else used the sample's own values, and the field to fill in for your own run. It is never counted as a miss. When you set a preferred caption language it says plainly that the sample is one fixed video, so that language is used only if the video carries a track for it — the transcript row always names the language it actually carries.
- **A field this actor does not recognise, or a video list you actually emptied, still gets the guidance row** — answering a typo with a sample row would read as if it had worked. An output format this actor does not support is still refused by name.

### 1.0.41 — 2026-09-04

- **An output format this actor does not recognise no longer swallows your list.** Setting "Output format" to a value it cannot produce refuses the run — that part is unchanged, because delivering json to someone who asked for subrip bills them for data they did not ask for. What changed is the answer: you used to get one row for the whole run, however many videos you had pasted. The row that names the value comes first, and then every video you sent gets its own row saying it was not read and which field stopped it. Nothing is fetched and nothing is charged, exactly as before.
- **Every refusal now names the input field it is about** — "Video URLs or IDs" or "Output format" — in the run's own record as well as in the row you read. A field that is turning people away then gets noticed and fixed quickly, instead of looking like a scatter of unrelated typos.
- **A YouTube wall is now told apart from a video that simply had nothing to give.** When YouTube answers with "confirm you're not a bot", asks for a signed-in account, or says the video is still processing, that row is counted as the wall it is rather than as an ordinary uncharged miss. It was always uncharged and always retryable; now a spell of walls is visible to us as a problem on our side, which is what gets it fixed. The closing line names them separately too.
- **The run record now reports what you asked for, not what survived.** A run where a setting stopped every video, or where the run clock or your cost cap left them all, used to report an ask of zero and could pass unnoticed. It now reports the number of videos you sent.
- A few row and page outcomes this listing called "free" are named "uncharged", which is what they are — running any actor uses platform time you pay for. No facts, prices or charges changed.

### 1.0.40 — 2026-09-04

**A video this account has already been charged for is never charged for again.**

Re-run the same list, poll a video weekly, or paste a link you had months ago: the transcript comes back from the run that produced it, with nothing charged for it a second time. Nothing is fetched and no speech-to-text minute is spent — the check happens before any of that — and those rows do not count against your **Max videos** cap, so the cap buys new videos.

Those rows say so: `repeat: true`, `charged: false`, `firstSeenAt` and `firstSeenRunId`, plus a note naming the day and the run. The run's status line says how many were handed back.

The memory lives in **your own account**, in a key-value store called **`yt-transcripts-account`** on your Storage tab — delete it to start over and be charged again. Transcripts drop out of it after 90 days on their own. On a run where that store cannot be read, the run still delivers and charges exactly as it did before, and the status line says the check was unavailable so you know a repeat could have been billed.

Also: run status lines are shorter, so the counts and the support ask survive on the run page.

### 1.0.39 — 2026-09-04

- **A problem on the transcription service's side is no longer reported as a problem with your video.** When that service refuses for a reason of its own, the video stays alive and its row says plainly to re-run it. A file this actor genuinely cannot transcribe still ends as an honest, uncharged miss that says so.

### 1.0.38 — 2026-09-04

- **A run that stops before it starts now reports its outcome too** — a run refused for its memory setting, and a run stopped because a key on our side is missing, now reach us the same way every other run does: counts and reason codes only, never your input or your rows.

### 1.0.37 — 2026-09-04

- **Every run now reports its own outcome to us** — counts and reason codes only, never your input or your rows — so a run that goes wrong reaches us even when nobody shares it.
- **Row notes state what happened and what was charged; the support ask lives on the run page.**

### 1.0.36 — 2026-09-04

- **Run status lines fit the run page again; sample-run wording shortened.**
- **Rows that carry no result fee are now described as uncharged, not free** — running any actor still uses platform time.

### 1.0.35 — 2026-09-04

- **A run that ends with a problem now says where to reach us.** A miss, an input error, an early stop or a failed run closes by pointing at the Issues tab and naming the reply time; a run that delivered everything, including one that filled the row cap you set, is left alone.
- **One support promise across this page** — issues are answered in a couple of hours, always within a day.

### 1.0.34 — 2026-09-04

**A run that resumes after a platform restart now recognises every row it already delivered, so nothing is delivered or charged twice.**

Apify occasionally moves a running actor to another server. When that happens, the run re-reads its own dataset to remember what it already delivered. Until now it trusted the dataset's row count, which can lag for a moment after a restart; a lagging count could make the run start over and charge again for rows you already had, or stop reading before the end. The run now checks for real rows instead of trusting the count, and reads to the end whatever the count says. Rows, prices, charges and the status line on a normal run are exactly as before.

### 1.0.33 — 2026-09-04

**Internal bookkeeping only — nothing changes in your rows, prices, charges or status line.**

The record this actor keeps of its own running costs now reports itself: if it cannot be saved, the run log says so plainly instead of staying quiet, and every run's OUTPUT record carries whether it was saved. Your dataset, your charges, your prices and the status line are exactly as before.

### 1.0.32 — 2026-09-03

**Output format: an unrecognised value is now refused instead of quietly delivered in json.**

- **A format we do not recognise no longer costs you a run.** Sending something that is not `json`, `text`, `srt` or `vtt` in the Output format field used to transcribe your videos in `json` anyway and mention it on a row — so you paid for transcripts in a format you had not asked for. Now nothing is transcribed and nothing is charged: you get one row naming the value you sent and the four formats that exist, and you re-run with one of them.
- **Nothing else moved.** Leaving Output format blank, or sending `null`, still means `json` and still runs normally, a bare Start still transcribes the real sample video, and every valid format behaves exactly as before.

### 1.0.31 — 2026-09-03

**Listing text only — nothing changes in your rows, prices, charges or status line.**

- The input-form and dataset screenshots now load from Apify storage instead of an outside host, so they render on the listing itself.
- Every link that pointed outside Apify has been removed from the listing text.

### 1.0.30 — 2026-09-02

**Internal accounting fix — nothing changes in your rows, prices, charges or status line.**

A small correction to how our own cost records count very short clips, and our cost records are now complete for every run. Your dataset, your charges and the status line are unchanged.

### 1.0.29 — 2026-09-02

**Internal cost accounting only — nothing changes in your rows, prices, charges or status line.**

Runs started from our own account now record the processing they used, so our cost reports are measured instead of estimated. Nothing is added to your run or its storage, and your dataset, your charges and the status line are unchanged.

### 1.0.28 — 2026-09-02

Documentation build: nothing about what runs, what is delivered or what is charged changed.

- **Real console screenshots on this page** — the input form exactly as it arrives, and the dataset table a real run delivers.
- **One-click MCP link** — pin this actor in Claude, Cursor or any MCP client straight from this page.
- **Suite links refreshed** to the current names of the other steadyfetch actors.
- **Input-form copy corrected.** The Video URLs or IDs field still said a bare Start returns one uncharged sample row. It transcribes a real video, charged like any run — the field now says so, as this page already did.

### 1.0.27 — 2026-09-02

Start with the default form and get a real transcript instead of a placeholder row.

- **A bare Start now transcribes a real video.** Click Start with nothing set (or send an empty input from the API) and the run transcribes one short real video — `youtube.com/watch?v=jNQXAC9IVRw`, "Me at the zoo" — and returns its full row, charged like any other transcript. The stored placeholder row is gone; what you see is what a real run returns.
- **The sample stays small and quick by design:** one video, at most one speech-to-text minute, and a three-minute window. On a bad minute you get one uncharged row saying the video could not be transcribed this time (re-run in a minute), never an empty dataset.
- **The status line says when a run was the sample** and how to run your own videos.
- **Nothing else changed:** any input with video links in it behaves exactly as before, and a mistyped field still gets a row naming the field instead of a sample.

### 1.0.26 — 2026-08-31

**Runs sent from a template or an agent no longer fail before they start.** Tools that build the input for you — n8n, MCP clients, agent frameworks — send `null` for every option you left unset. The platform used to refuse those runs outright, before this actor ever saw them. Now every optional field accepts `null` and reads it as "use the default", including a request where every optional field is `null`. `videoUrls` is still required and still needs a real value.

"Output format" keeps its dropdown, and if you send a format we do not recognise the run now delivers on the default and adds one uncharged row naming the formats we support — instead of the run being refused, or quietly handing you a different format than you asked for. Nothing about what is delivered or charged changed.

### 1.0.23 — 2026-08-30

**A video with no real speech is never charged.** On the spoken-audio route, a transcript that is only the speech engine's own filler — including the subtitle-credit lines it sometimes invents on music-only videos — or one whose timing does not fit the audio, now ships as an uncharged no-speech row. Genuinely spoken videos, in any language, are delivered and charged exactly as before.

### 1.0.22 — 2026-08-30

- **Transcripts now come back in the video's own language.** A plain paste picks the video's original-language caption track instead of whichever translation YouTube happened to list first — an English video no longer comes back in Arabic. An explicit language choice still wins exactly as before, and the row still tells you which language you got.
- **Cleaner transcript text.** Repeated rolling-caption lines (the same line printed again as YouTube scrolls its caption window) and invisible zero-width characters are now removed from the text and its segments.
- Nothing about what this actor charges changed.

### 1.0.21 — 2026-08-30

- **Videos that YouTube challenges on busy shared networks now deliver far more often on the first run.** When YouTube answers a lookup with a "confirm you're not a bot" wall, this actor climbs to its cleanest network route — and that route now gets two full attempts instead of one, caption-fetch trouble now climbs to a better route instead of using up the run's tries where it was stuck, and a rare setup hiccup that could quietly leave those retries on the wrong network is fixed. Videos that used to come back as an uncharged "please re-run" row now come back as transcripts.
- Caption reading is marginally faster on videos with very long transcripts.
- Nothing about what this actor delivers or charges changed otherwise.

### 1.0.20 — 2026-08-30

- **A busy speech-to-text service no longer costs you the video.** The speech-to-text route now tries the audio it already holds three times, with a growing pause between attempts — and when the service says how long to wait, the run waits exactly that long (never more than a minute). Videos that used to come back as an uncharged "please re-run" row during a busy spell now deliver on the first run. Captioned videos never touch this route and are unaffected. Nothing about what this actor charges changed.

### 1.0.19 — 2026-08-30

Clearer release notes — this page's notes now read more plainly. Nothing about what this actor delivers or charges changed.

### 1.0.18 — 2026-08-30

Suite links now point at the full live shelf — every actor named in this README is a live
store link. Nothing about what this actor delivers or charges changed.

### 1.0.17 — 2026-08-30

Interrupted runs and the cost cap: three edge cases where the books could go wrong are closed, and `null` in a numeric option no longer means zero.

- **A resumed run can no longer count a delivered transcript twice.** When an interrupted run picks up, it reads back what it already delivered. If that read-back itself hit a snag partway and had to start over, rows read before the snag could be counted a second time — and settling the books against an inflated count could take a charge for a transcript you had already paid for. Each read-back attempt now starts from a clean slate and every row counts exactly once, so a resume can only ever settle what is genuinely owed.
- **An interrupted charge that still cannot be taken on resume is left for later — never guessed at.** When a resumed run owes a charge from before the interruption and taking it fails right now, the run says so, adds nothing further to that settlement, and carries on with its work; the amount is settled on a later resume against the platform's own record. A charge that may have landed despite the error is never issued a second time.
- **Two videos finishing in the same moment can no longer both claim the last room under `Maximum cost per run`.** Near the cap, the room a just-finished video's charge is about to take is now counted before the next video is allowed to bill. The cap holds exactly: the video that no longer fits ships as the usual uncharged `skipped_budget` row telling you to raise the cap and re-run.
- **`null` no longer means zero in the numeric options.** Sending `Max videos` or `Max speech-to-text minutes` as `null` or an empty string — the shape many API templates produce for "not set" — was read as the number zero and clamped to the smallest value the field allows, quietly shrinking the run. Those values now mean what leaving the field out means: the default.

### 1.0.16 — 2026-08-30

A row can no longer claim it was charged when the charge did not land.

- **`charged: true` now always means the charge actually happened.** Near the run's maximum cost, a delivered transcript could be marked `charged: true` even though the charge itself no longer went through — a row in your dataset overstating what you paid. Whether a transcript can still be billed is now confirmed before its row is written: once the run's maximum cost is reached, that video returns an uncharged `skipped_budget` row telling you to raise "Maximum cost per run" and re-run, the remaining videos are skipped with the usual count in the status line, and no row ever states a charge that was not taken. Runs comfortably under their cost limit are unchanged.

### 1.0.15 — 2026-08-29

- Clearer wording on uncharged no-speech rows: a video whose audio turns out to be music-only or silent now says exactly that in `statusReason`, in plain language. The same videos are recognised as before, and they are still never charged.

### 1.0.14 — 2026-08-29

A new `retryable` column, and two "this video is gone" answers that were not true.

- **Every row now says whether re-running it is worth your time.** The new `retryable` column is `true` when YouTube refused us this time and the same input is worth another run, and `false` when the answer will not change. It always agrees with the sentence in `statusReason`, so your code can branch on the column instead of parsing the note.
- **A video is no longer reported as removed because YouTube's service did not answer.** When the video service itself returned "not found" — which happens when YouTube moves or gates that endpoint, not because of anything about your video — the run marked the video permanently gone and moved on. YouTube reports a genuinely removed video a different way, and that answer is unchanged. The endpoint one is now a temporary problem you can re-run, still with nothing charged.
- **A caption track that exists but is empty now goes to speech-to-text.** Videos whose captions came back as a valid but empty track were being answered "please re-run" forever, when the honest next step was to transcribe the audio. They deliver again. A caption fetch that was actually blocked is still a re-run, exactly as in 1.0.13.
- **A resumed run cannot bill you twice for the same transcript.** When a run is interrupted and picks up where it left off, it reconciles what was already charged. If it could not confirm those numbers, it now charges nothing and leaves the settlement for later, instead of guessing from a count that may be stale — and if the run's own delivery record comes back missing rows it has already charged for, the run stops cleanly rather than re-delivering and re-charging them.
- Store links to the rest of the steadyfetch shelf now point at the actors that are live.

### 1.0.13 — 2026-08-29

Captions YouTube refused to hand over are no longer reported as captions that do not exist.

- **A blocked caption fetch is now honest, and uncharged.** When YouTube listed a video's captions but then refused to serve them, the run reported "this video has no captions". It now recognises the refusal, retries on a fresh connection, and if the captions still cannot be served returns an uncharged row asking you to re-run.
- **That same refusal can no longer bill you for speech-to-text.** With speech-to-text on — the default — a blocked caption fetch used to fall through to transcribing the audio, and the speech minutes were charged. A blocked caption fetch never becomes a paid speech-to-text transcript now.
- Unchanged: a video that genuinely has no caption track still goes to speech-to-text exactly as before, or returns "no captions" when you have speech-to-text switched off.

### 1.0.12 — 2026-08-29

A typo in an input field name now gets a helpful pointer instead of sample rows.

- **A misspelled field name is answered with the field you meant.** Sending your videos under a name
  this actor does not have — `videoIds` instead of `videoUrls`, say — or filling in only the options
  and no videos, used to come back as the uncharged sample row, which reads as though the run had
  worked. Those runs now return one uncharged row that names the field the actor did not recognise,
  names the one it does, and shows the shape to send. Runs that do carry videos are unchanged, and a
  run with nothing set at all still returns the sample row.

### 1.0.11 — 2026-08-29

- **A speech-to-text outage on our side no longer makes the rest of the run pay for it.** If the transcription service refuses this actor's access mid-run, caption-less videos now come back immediately as uncharged `asr_unavailable` rows that say plainly the problem is ours — instead of each one downloading its audio first and then failing one by one. Videos with captions keep delivering normally, and ordinary rate-limit blips keep their usual retries.
- **A "not found" answer from YouTube's video service is now final for that video.** It ships as an uncharged `not_found` row instead of being retried as if it were a temporary block — which also kept those retries from slowing down the rest of your list.

### 1.0.10 — 2026-08-28

- **The "still processing" answer now shows whose claim it is.** When YouTube declines a video with "We're processing this video. Check back later.", the row relays it as a quote — `YouTube says: "We're processing this video. Check back later."` — the same attribution rows for removed videos already carry. YouTube gives that answer even for some long-unavailable videos, so the row now makes clear the claim is YouTube's; the verdict stays temporary, uncharged, and worth a re-run.

### 1.0.9 — 2026-08-27

Review-fix build: a row's `charged` column now always matches what the run could actually bill.

- Fixed: a run that could not bill — an unmonetized copy of the actor, or a price that had not
  taken effect yet — still marked delivered transcripts as `charged: true` and counted those
  charges in the run summary, even though nothing reached your invoice. Such a run now delivers the
  transcripts uncharged and marks every row `charged: false`. The dataset reconciles against your invoice
  in every case, not just the normal one.
- Fixed: the same run also budgeted itself as though it had unlimited billable room, so the
  speech-to-text minute limit and the cost cap you set were not applied to it. Both caps now hold on
  every run.
- If this actor's billing is ever mis-set in a way that would charge you for the rows this listing
  promises carry no result fee — videos we could not transcribe, the sample — the run now
  fetches nothing, delivers nothing and charges nothing, and says plainly that the fault is ours. A
  pricing mistake on our side can no longer put a charge on an uncharged row.

### 1.0.8 — 2026-08-27

Housekeeping only — no change to how anything runs.

- Removed an internal working note that had been swept into the published source of the
  previous build. It affected nothing at runtime; it simply did not belong in the source you
  can browse.
- Corrected one line of the 1.0.7 notes below: it described a download change that was
  measured, rejected and not shipped. What did ship is the line above it.

### 1.0.7 — 2026-08-27

A long video with no captions now gets an honest, permanent answer instead of being told to
try again.

- Fixed: a caption-less video longer than a few minutes came back as a temporary problem that
  a re-run would clear. It will not clear. Past roughly a few minutes YouTube stops releasing
  a video's audio to anything but its own player, so speech-to-text cannot reach enough of it
  for a complete transcript — and a partial transcript is never sold as a whole one. Those
  videos now come back as `audio_too_long_for_speech`: permanent, uncharged, and saying so
  plainly rather than sending you to re-run something that can never succeed. Videos that have
  captions are unaffected at any length, and short caption-less videos transcribe as before.
- Changed: when YouTube refuses a video's audio, the run now works out whether it is refusing
  that particular video or simply throttling this run, and reports the two differently. A
  throttle is still reported as temporary and still worth re-running.

### 1.0.6 — 2026-08-27

Interrupted runs now resume instead of repeating themselves, and the two per-run limits
became whole-run limits.

- Fixed: when Apify moves a running job to another server — which it does routinely on
  long runs — the run used to start its work over. Videos you had already been given came
  back a second time as duplicate rows, and were charged a second time. A run now reads
  back what it has already delivered and skips exactly those videos: no duplicate row, no
  second charge, and the finished dataset looks the same as if it had never been
  interrupted. The same protection covers resurrecting a finished run.
- Fixed: if the move landed in the moment between a row being delivered and its charge
  being recorded, that row was delivered uncharged and its `charged: true` was not true. The
  run now settles that one charge on restart, so every row's `charged` field matches the
  invoice.
- Fixed: `Max videos` and `Max speech-to-text minutes` were counted per server, not per
  run — so a job that got moved could deliver up to twice what you asked for. Both are
  now whole-run limits, counted across the move.
- Fixed: a moved run was also given a fresh copy of its own internal time ceiling, so it
  could run far longer than it was meant to. It now stops when the original run was due
  to stop.
- Corrected: the input form said to use the channel actor for "a whole channel or
  playlist". It does not do playlists. It now says to paste the video links here for a
  playlist, matching what the README and the guidance rows already said.
- The YouTube channel actor, Trends Now, Social Trends and Speech to Text are now
  published, so they are real links in the actor list at the bottom of the README.

### 1.0.5 — 2026-08-27

Review-fix build: speech-minute accounting, long-video speech detection, the oversized-audio
limit, subtitle timestamps and the run deadline.

- Fixed: a video that had to be retried reserved its speech-to-text minutes again on every
  attempt. That ate the run's minute budget, so later videos — and sometimes the retry itself —
  were skipped for a limit that had not really been reached. A video now holds its minutes once,
  and gives all of them back if it never delivers.
- Fixed: a long video with real but thinly spread speech (a four-hour stream carrying twenty
  minutes of talking) was discarded as "no speech" after the speech-to-text had already run.
  Sparse-but-real speech is now recognised and delivered.
  Music-only and silent uploads are still caught, and still cost nothing.
- Fixed: a video whose audio stream does not declare its size (live replays, some still-processing
  uploads) was downloaded whole before the per-video data limit could refuse it. The download now
  stops at the limit and the video is reported as skipped. Nothing is charged either way.
- Fixed: an SRT or VTT timestamp landing a hair under a whole second rendered a four-digit
  millisecond field (`00:00:59,1000`) that no subtitle player accepts. It now carries into the
  next second.
- Fixed: an unreadable platform timeout could switch off both the platform deadline and our own
  one-hour cap. The run's own cap now always applies, so a run still stops cleanly.

### 1.0.4 — 2026-08-27

- Fixed: on a run with no pricing attached, rows said they had been charged when nothing had been.
  Rows now report `charged: false` and an empty `chargeEvents` ledger unless a charge was really made.
- Corrected: a playlist link used to suggest the channel actor could transcribe the whole playlist.
  It cannot — it transcribes whole channels. The guidance row now says to paste the video links here.
- README: output shown first, pricing stated up front, and a plain list of what can fail and why
  none of it is charged.

### 1.0.3 — initial release

Paste YouTube video links or IDs and get each video's transcript.

- Captions first: manual captions are preferred over auto-generated ones, and you can ask
  for a specific language (regional variants like `pt-BR` match `pt`).
- Built-in speech-to-text for videos that have no captions, so caption-less videos still
  return real text. Each row says which route produced it.
- Accepts watch links, `youtu.be` links, Shorts, `/live`, `/embed`, mobile and music
  hosts, and bare 11-character video IDs, in any casing.
- Output as JSON with timestamped segments, plain text, SRT or VTT.
- Charged once per delivered transcript, plus speech-to-text minutes only when
  speech-to-text actually ran. A captioned video never charges a minute.
- Nothing is charged for a video that is private, removed, age-restricted, region-blocked,
  still live, silent, music-only, or that YouTube refused to serve. A temporary refusal is
  reported as temporary and says to re-run; only a clean answer from YouTube is reported
  as permanent.
- `Max speech-to-text minutes` and `Max videos` are hard limits: the run stops cleanly and
  every skipped row names the limit that stopped it.
- Every row carries `charged`, `statusReason` and `chargeEvents`, so the invoice reconciles
  from the dataset itself.
- A run with no videos returns one uncharged sample row showing the exact output shape.
