I made 3 promo videos for my apps. Uploading them by hand is a 10-minute job. I tried to automate it and burned half a day.

Here’s the conclusion up front: YouTube uploads via browser automation don’t work. The API is the only option. Below are the reasons, and every trap I hit even after moving to the API.

Google always blocks sign-ins from automated browsers

My first plan was to create a new profile in Playwright, sign in to YouTube Studio, and upload from there.

On the sign-in screen, this appears:

このブラウザまたはアプリは安全でない可能性があります

(This is Google’s “This browser or app may not be secure” message.)

This isn’t the kind of wall you look for a workaround to. The right move was to redesign around the assumption that a fresh sign-in from an automated browser will not go through. I reached the same conclusion when I built automated posting for X (Twitter), and there I got around it by reusing a real profile that was already signed in. The moment you try to make a script do a fresh sign-in, you’ve lost.

So I thought, “Why not just use an existing signed-in profile?” That’s where I hit the second trap.

A URL check mistook “no permission” for success

I had a Chrome profile that was already signed in. But it was the one I used to run a different channel, and it didn’t have permissions for the YK Studio channel.

The script checked it like this:

await page.goto(`https://studio.youtube.com/channel/${CHANNEL_ID}`);
const ok = page.url().includes(CHANNEL_ID);   // ← this is always true

When you open this URL with an account that doesn’t have permission, the URL stays the same and only the page content is swapped out for “You don’t have permission to view this page.” There’s no redirect, so the URL check sails right through.

Because of this, I once wrongly reported that “access worked.” Always check based on the page content. You can’t use the URL, because there’s no guarantee it changes on failure.

(As an aside, I hit this exact same “judge success by the URL and get a false positive” pattern with the X auto-posting too. I wrote about it in detail in that post. I’ve done it twice now, so I’ve learned my lesson.)

Switching to the API

I rewrote it to use the YouTube Data API v3. Here’s the overall flow:

npx tsx src/yt_api_upload.ts --auth    # first time only: consent and save the token
npx tsx src/yt_api_upload.ts           # after that, it can run unattended

The upload targets are managed in a manifest:

[
  { "file": "promo_narabu.mp4", "title": "...", "description": "...", "visibility": "public" }
]

Completed uploads are recorded in a separate file to prevent double posting.

The guard that stopped an upload to the wrong channel

This is the piece I’m really glad I put in. Before uploading, it fetches the channel ID of the account that’s currently authenticated.

const me = await youtube.channels.list({ part: ['id'], mine: true });
if (me.data.items?.[0]?.id !== EXPECTED_CHANNEL_ID) {
  throw new Error(`想定外のチャンネル: ${me.data.items?.[0]?.id}`);
}

(想定外のチャンネル means “unexpected channel.”)

This actually fired and stopped an upload to the wrong channel.

The cause was the account I’d authenticated with. The channel is owned by an account I use for operations, but I’d given consent with my everyday account. Call mine: true in that state and you get back a different, personal channel under an individual’s name. If it had gone ahead, a promo video for my app would have been published on a family member’s personal channel.

It’s worth always having a machine confirm “which account did I authenticate with” before running. Human memory can’t be trusted.

With OAuth in “Testing,” you get a 403, and tokens expire in 7 days

If the consent screen is still in “Testing,” accounts that aren’t registered as test users get rejected.

Error 403: access_denied

In the new console UI, the publishing status is under “Google Auth Platform → Audience.” Pressing “Publish app” there solves it, but the button is grayed out if the app name, support email, homepage URL, and privacy policy URL aren’t filled in.

If you’re in a hurry, adding yourself as a test user is faster. But refresh tokens in Testing mode expire after 7 days. A week later, your unattended runs silently stop. If I intended to run this permanently, I should have filled in those 4 branding fields from the start and published it to production.

Quota

One upload costs 1600 units, and the daily limit is 10,000. That’s 6 uploads a day, max.

It’s a number you should calculate in advance if you plan to upload in batches. Line up 10 without knowing this and everything from the 4th one onward quietly fails.

A silly bug I hit with the vertical videos

Separately from the 3 landscape promos, I made 3 vertical 1080x1920 videos for Shorts. They weren’t simple crops of the landscape versions; I re-laid them out for portrait.

While doing that, I reused a shell function from the landscape version that generates a rounded-corner mask and shadow, and its output filename was hardcoded.

roundrect 1920 1080 out.png    # 1st call (landscape)
roundrect 1080 1920 out.png    # 2nd call (portrait) overwrites the 1st

The second call clobbered the first, and the later alphamerge step failed on a size mismatch. The error message pointed at a size violation in the compositing step, so it took me a bit of a detour to realize the cause was where the function wrote its output.

Adding a suffix per call fixed it. A function with a hardcoded intermediate filename breaks the moment it’s called twice.

Summary

  • Signing in to Google through browser automation is impossible. It’s either reuse an existing profile or use the API
  • Don’t use the URL to judge success. Look at the page content or the API’s return value
  • Before posting or sending anything, have a machine confirm “which account is this.” That alone stops the worst accidents
  • If your OAuth app is in Testing, tokens expire in 7 days. Not suitable for unattended operation

The fundamental risk of automation is that nobody’s watching when it fails. That’s why you have a machine check only the irreversible conditions right before posting. What helped most this time wasn’t fancy retries or error handling; it was this 4-line guard.


The story of making the same mistake with X auto-posting is here. I wrote about the setup where all the operations work is assigned to AI agents here.