← yayster.com

YouTube Data API · October 2026

Why Your YouTube Upload Script Stops Working After a Week

Bulk-upload and schedule a whole slate of videos from one command — Python, the free YouTube Data API, no paid tool. The hard part isn't the upload call. It's the two things nobody warns you about.

* * *

It worked. That's what makes this one hurt.

We wrote a script that takes a slate of videos, uploads each one, sets its title, description, tags, thumbnail and playlist, and schedules it to publish on its own day. One command. We ran it, watched a week of content land exactly where it was supposed to, and stopped thinking about it.

Eight days later it was dead. Not loudly — there was no alert, because from the script's point of view nothing had changed. The token it had been handed was simply no longer a token.

That failure has a cause, it has a fix, and the fix is one setting in a console you have already been in. But you won't find it in the tutorial that got you this far, because the tutorial ends at the first successful upload. So this page does the whole thing: the method, and then the four things that cost us real time.

* * *

Why you end up here at all

Uploading one video by hand is fine. Nobody writes a script for one video.

The problem is a schedule. A slate going out weekly, every entry with its title, description, thumbnail and playlist already decided. By hand that's roughly twenty minutes a video of clicking through upload → edit settings → schedule publish time, and it's one mistyped date away from publishing something early. Do it for eight videos and you've burned most of a day on data entry that a file could have held.

The tools that automate it are subscriptions. The capability itself is free: the YouTube Data API v3, a Google Cloud project, and about a hundred lines of Python.

The reason people bounce off it isn't the upload call. The upload call is six lines. It's OAuth.

Step 1 — Enable the API and make an OAuth client

Two operations in the Google Cloud console, both free:

That JSON file is the thing that identifies your script to Google. It isn't a credential for your channel — it's a credential for your script. The channel part comes next.

One choice here matters more than it looks: the scope. If all you ever do is upload, the narrow youtube.upload scope is enough. If you want the script to also set thumbnails and add videos to playlists — and on a slate, you do — you need the broader youtube scope. Pick the narrow one, build the whole pipeline, and discover the gap at the thumbnail step, and you get to redo consent from the start. Decide once, up front.

Step 2 — Consent, without running a web server

This is where everyone gets stuck, and it's worth understanding rather than copying.

The standard “installed app” OAuth flow wants to run a tiny web server on your machine. It opens a browser, you approve, and Google redirects your browser back to http://localhost:8080/?code=…, where that little server is waiting to catch the code. It works beautifully on a laptop.

On a headless box — a server, a container, a VM you reach over SSH — there isn't a browser to open, and often nothing you want listening on a port. The flow has nowhere to land.

So point the redirect at a port where nothing is listening, on purpose. Register http://localhost:1 as the redirect URI on your OAuth client, and have the script print the authorization URL instead of opening it:

from google_auth_oauthlib.flow import Flow

SCOPES = ["https://www.googleapis.com/auth/youtube"]

flow = Flow.from_client_secrets_file(
    "client_secret.json",
    scopes=SCOPES,
    redirect_uri="http://localhost:1",
)

auth_url, _ = flow.authorization_url(
    access_type="offline",      # we want a refresh token, not just an access token
    prompt="consent",
)
print(auth_url)

Copy that URL into the browser you're already signed in to — your laptop, your phone, anywhere. Approve it.

Your browser then lands on “This site can't be reached.”

That screen looks like total failure. It's the success path.

Nothing is listening on port 1, so nothing can render a page — but Google has already done the only thing that mattered: it appended the authorization code to the redirect, and that code is sitting in your address bar. Copy the whole URL, paste it back into the script, and exchange it:

pasted = input("Paste the full URL from the address bar: ").strip()
flow.fetch_token(authorization_response=pasted)

creds = flow.credentials          # has .refresh_token — store this, see below

One detail from actually doing this rather than reading about it: in current Chromium the error on that page reads ERR_UNSAFE_PORT, not ERR_CONNECTION_REFUSED. Port 1 is on the browser's blocked list, so it refuses to even try the connection. It makes no difference to the trick — the headline is still “This site can't be reached” and the URL is still intact in the bar — but if you're googling the error code, that's the one you'll see.

Step 3 — Describe the slate once, in a manifest

Don't put video metadata in your code. Put it in a file, one entry per video:

{
  "videos": [
    {
      "file": "renders/ep01.mp4",
      "title": "Bulk Upload & Schedule YouTube Videos With the API (Python, Free)",
      "description": "descriptions/ep01.txt",
      "tags": ["youtube api upload python", "bulk upload youtube videos"],
      "thumbnail": "thumbs/ep01.png",
      "playlist": "How-To: Run It Yourself",
      "publishAt": "2026-10-07T14:00:00Z"
    }
  ]
}

The scheduling itself is one field on the upload body, and it has a precondition people miss: a video can only carry a publishAt while its privacyStatus is private. You aren't uploading it public and asking YouTube to hide it — you're uploading it private and telling YouTube when to flip it.

body = {
    "snippet": {"title": v["title"], "description": desc, "tags": v["tags"]},
    "status": {
        "privacyStatus": "private",        # required for publishAt to be honoured
        "publishAt": v["publishAt"],       # RFC 3339, UTC
        "selfDeclaredMadeForKids": False,
    },
}

Then run it dry, first, every time. A dry run that validates every file and thumbnail exists, resolves the schedule dates and prints exactly what it's about to do costs nothing. A bad batch costs you a published video with the wrong title on a real channel. Read the dry-run output before you let the thing touch the API — not as a ritual, as the actual check.

Step 4 — Run it, and make re-runs safe

The upload itself is a resumable upload, which matters on a slate of large files: a dropped connection continues instead of starting the file over.

from googleapiclient.http import MediaFileUpload

media = MediaFileUpload(v["file"], chunksize=-1, resumable=True)
request = youtube.videos().insert(part="snippet,status", body=body, media_body=media)

response = None
while response is None:
    status, response = request.next_chunk()

The part actually worth copying isn't that. It's what happens before the upload call.

A batch job that can publish on your behalf will eventually die halfway through — a network blip, a quota wall, a bad file in position six. When it does, you want to re-run the same command and have it finish the job rather than post everything twice. So before uploading anything, ask the channel for its uploads playlist and look for that exact title, including private and unlisted videos — a scheduled video from the dead run is private, so a check that only sees public videos sees nothing and re-uploads all of it:

# resolve the channel's own "uploads" playlist, then page through it
ch = youtube.channels().list(part="contentDetails", mine=True).execute()
uploads = ch["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
# → playlistItems().list(part="snippet", playlistId=uploads, maxResults=50)
#   and match on title

If it finds a match, skip the upload and reconcile the metadata instead. That's what idempotent means in practice here, and it's the difference between a script you trust with a slate and one you babysit.

Two smaller decisions in the same spirit: if a playlist insert or a thumbnail set fails, log it and keep going. A thumbnail problem shouldn't cost you the upload — you can fix a thumbnail in thirty seconds afterwards, and re-running the whole batch to fix one is how you hit the quota wall.

Whatever you write, read that duplicate check before you trust it. That goes for any script that can publish on your behalf.

The four that cost us real time

All four of these are ours, from this month.

1. In “Testing” status, refresh tokens expire in about 7 days

This is the one from the top of the page. While your OAuth app's publishing status is Testing, Google expires refresh tokens after roughly a week. Your automation works, you stop watching it, and a week later it's silently dead — and because the failure is an invalid refresh token rather than a crash, nothing about it reads as “the thing you built is broken.”

The fix is to publish the OAuth app to Production. What is not the fix is minting a fresh token whenever it breaks, which is exactly what we did first; it feels like a fix because it works for another seven days.

2. Store the token outside your project directory

Ours sat next to the code. The workspace got re-created, the token went with it, and uploads that had been working stopped — for a completely different reason than the first time. A credential's lifetime shouldn't be tied to a checkout's lifetime. It lives outside the project now, and the script writes it back to that durable location every run, so the next re-checkout can't take it.

3. A virtualenv can outlive its own Python

Move a box or change interpreters and you get ModuleNotFoundError on a package you can plainly see installed in site-packages. The venv is pointing at an interpreter that isn't there any more. Rebuild the venv rather than debugging the import — we lost an hour to this reading the wrong half of the problem.

4. Quota is smaller than you think

An upload costs a large slice of the daily API allowance. A dozen videos is fine; re-running a big batch because something failed in the middle is how you find the ceiling. Which is one more argument for the duplicate check in Step 4: the re-run should be cheap.

None of that is a reason to pay a subscription. It's just the part the tutorials skip, because the tutorials stop at the first successful upload and these all happen after it.

What we are not showing you

The consent screen in Step 2 has a real account on it, and the redirect that carries the authorization code carries a real credential. Those are ours, so they're not in the screenshots and they're not in the video — the description of the flow is exact, and the frame that would prove it would also leak it.

Likewise, the code in Steps 3 and 4 is the minimal version you'd write yourself, not a paste-out of our uploader: that script does more than this — durable token store, playlist reconciliation, per-video failure isolation — and it doesn't have a public home yet, so we're not going to point you at a link that doesn't exist.

Everything described here is something we ran. Where a claim is about our own experience rather than about the API — the seven days, the re-created workspace, the hour on the venv — that's us, not documentation.

We build these: publishing pipelines that run on a schedule and keep running. If you'd rather have this working than spend a weekend on OAuth, message us — a human reads every message.

And if you'd rather build it yourself: genuinely fine. That's why the steps are in here.

This is Yayster. We build local-AI and automation pipelines, and we document them while we do it — every guide here is something we ran.

Reach us on Telegram → t.me/yaysterllc_official

YouTube · X/Twitter