Bulk YouTube video uploads: Data API quotas and Studio automation

Bulk YouTube video uploads: Data API quotas and Studio automation
Anton Misiul
Anton Misiul

Customer Service Specialist

Bulk video uploads on YouTube quickly run into platform limitations. When you're dealing with hundreds of videos and multiple channels, manual work through YouTube Studio becomes inefficient, while switching to the YouTube Data API introduces its own limitations, from upload quotas to restrictions on video visibility.

In this article, we'll look at the main ways to perform bulk video uploads, YouTube's limitations, and ways to build such a system. We'll also recalculate the current Data API quotas separately: at the end of 2025, Google changed the cost of video uploads, and in June 2026 it began moving to a more granular quota model, so many older guides are already outdated. At the end, we'll put together a working Node.js script for uploading videos to multiple channels through isolated Octo Browser profiles.

Contents

Stay anonymous, take advantage of multi-accounting, and achieve your goals with the highest-quality anti-detect browser on the market.

Would you like to try Octo Browser at discount?
Use the promo code OCTOBLOG to get 30% off any subscription. This offer is valid only for new users.

Two tasks, two approaches

Before choosing an automation method, determine what you're actually trying to accomplish:

  • Bulk video uploads to one channel. Two hundred videos for a channel with educational lectures, a webinar archive, podcast clips. Here, the bottleneck is the channel's own daily limit, which, as we'll see below, cannot be bypassed.

  • Working with multiple channels. Localized versions of the same content for different countries, channels for different offers, client channels managed by an agency. Here, the main question is how to keep twenty Google accounts separate enough that YouTube does not link them together and ban them all at once.

The first task is solved through planning and scheduled publishing. The second requires profile isolation and automation. Let's look at both approaches.

Built-in YouTube capabilities for bulk uploads

Before writing scripts, check whether YouTube's built-in functionality is already enough for your needs. YouTube Studio accepts multiple files at once: in the upload dialog, you can select a batch of videos, all of them will be added as drafts, and you can then edit their metadata in bulk by selecting multiple checkboxes. Scheduled publishing can also be configured there, so twenty drafts can be scheduled for the month ahead.

For regular work with a single channel, this is often enough. Automation starts paying off when you have more than fifty videos, more than three channels, or when metadata is generated programmatically and stored in a CSV file or database.

YouTube Data API v3 and its two ceilings

The official way to upload videos is the videos.insert method. It supports resumable uploads, accepts files up to 256 GB, and works only through OAuth 2.0. Service accounts are not suitable for uploading on behalf of a channel, which breaks the very first attempt to build a server-side uploader without interactive authorization: you'll have to obtain a refresh token manually at least once per channel.

However, authorization is not the main problem.

Quota: almost every guide on the subject is outdated

If you've looked into bulk uploading before, you've almost certainly seen the following calculation: "10,000 units divided by 1,600 per upload equals six videos per day." This calculation is no longer valid, and this is the most important thing to understand in 2026.

Google changed the quota model twice. On December 4, 2025, the cost of a video upload was reduced from approximately 1,600 units to approximately 100. Then, on June 1, 2026, the API moved to a granular quota system: videos.insert and search.list calls are deducted from their own separate buckets rather than from a single shared pool.

The current default project quota looks like this:

Bucket

Default limit

videos.insert (video upload)

100 calls per day

search.list (search)

100 calls per day

All other methods

10,000 units per day combined

The buckets are independent: once you’ve used up your hundred uploads, you'll receive a 403 from videos.insert, even if 9,000 units remain untouched in the general pool.

The cost of methods within the general pool has not changed:

  • reads cost 1 unit;

  • writes such as videos.update and thumbnails.set cost 50;

  • captions.insert is significantly more expensive at 400.

Be careful with Google's documentation: at the time of writing, it contradicts itself. The "Quota calculator" page was updated in August 2025 and still shows the old 1,600-unit cost for videos.insert. The English overview and the June 1, 2026 entry in the Revision History are the up-to-date sources. You should always check your project's actual limits in the Google Cloud Console: quota values may differ between older and newer projects.

Conclusion: one hundred uploads per day per project is no longer the bottleneck that requires workarounds. The real limitations lie elsewhere.

Ceiling one: audit and locked private

Let's talk about the issue that most often makes custom uploaders useless, regardless of quota changes. All videos uploaded with videos.insert from an unverified API project created after July 28, 2020 are forcibly switched to private. The channel owner receives an email saying that the video has been locked as private because it was uploaded through an unverified client.

This cannot be appealed. The only way out is to re-upload the video through an official or audited client, or through the YouTube website. So until the audit is passed, the uploader formally works, returns 200 OK, and gives you a video ID, but nobody except you will be able to see the result of its work.

Projects created before July 2020 are not subject to this rule. If you have such a project, you're in luck. Everybody else will either have to pass the audit or give up on public API uploads.

Ceiling two: the channel's daily upload limit

This is the most annoying restriction because there is absolutely no workaround for it. YouTube limits how many videos a channel can upload within 24 hours and does not publish exact figures. We know that the limit depends on the channel's history, region, and whether it has strikes, and that it applies simultaneously to the web version, mobile apps, and API. Based on community observations, it is roughly 10–20 videos for new channels and up to a hundred for long-established ones, but these are observations, not documented values.

In the API, this limit appears as a separate uploadLimitExceeded error with code 400, and it is important to distinguish this from the exhausted project quota: the first applies to the channel, while the second applies to the project.

If you hit the channel limit, you'll have to wait 24 hours. Changing the upload method or contacting support won't help. The only effective way to increase throughput is not to upload more to one channel, but to distribute the workload across multiple channels.

Architectural conclusions

Under the new quota model, the main question is no longer the number of API requests. A hundred uploads per day per project is enough for most tasks. However, uploading through the API before passing the audit is useless for public content, and a single channel cannot be scaled vertically regardless of the upload method. That leaves only horizontal scaling: more channels, fewer uploads per channel.

And when you're managing twenty channels, you need a structured multi-accounting strategy. Google links accounts more aggressively than most platforms: through device fingerprints, shared cookie sessions, matching Client Hints, and IP addresses. Twenty channels from one Chrome instance under one IP will be tied into a single cluster, which can then be blocked as a whole.

So the architecture of the solution looks like this:

  1. Octo Browser, an anti-detect browser for multi-accounting: one isolated profile per channel, each with its own fingerprint and proxy. Profiles contain live Google sessions, so one-time profiles are not suitable here; you need persistent ones. They can be conveniently organized into folders so that channels belonging to the same project are grouped together.

  2. Rebrowser-puppeteer: a Puppeteer fork without the characteristic automation leaks.

  3. Octo Local API: launches profiles and connects to them via CDP. A full description of the methods is available in the documentation and the API reference.

  4. JSON job queue: a file containing the list of profiles, their associated videos, and metadata. This keeps the script universal while all the specifics are taken from the input data.

How to work with Studio selectors

The YouTube Studio interface is built with Polymer web components, and the selector strategy here differs from what we used for scraping Instagram.

The bad news: classes in Studio are generated and short-lived. The good news: key elements have stable IDs and attributes that are not tied to the interface language. The button for moving to the next step is always #next-button. Visibility radio buttons are tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. The switch for content made for kids is [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. None of these changes when the account locale changes.

The practical takeaway: rely on attributes and IDs rather than button text. A script that looks for a button containing the word "Next" will fall apart as soon as it runs on a profile with a different interface language. A script using #next-button will work in any language, and there is no need to forcibly set the locale through ?hl=en.

The second trick concerns the file upload itself. Clicking the drag-and-drop area is pointless: it opens the native operating system dialog, which cannot be closed from the browser. Instead, the file is passed directly to the hidden input[type="file"] through uploadFile(). The OS dialog does not appear at all in this case.

Ready-made script for uploading videos to multiple channels

The script sequentially goes through Octo profiles, opens Studio in each one, and uploads the videos assigned to that profile: it fills in the title, description, and tags, marks the content as not made for kids, sets visibility, and waits for processing to finish. Between uploads, it makes random pauses, counts uploads for each channel, and stops once it reaches the configured daily limit.

Profile preparation

  1. Create one Octo profile for each channel and assign each profile its own proxy. A residential or mobile proxy is preferable. Before using it, you should check its reliability.

  2. Open each profile manually and sign into the corresponding Google account. Go to Studio, accept all initial prompts, and confirm the phone number if necessary. The script should not encounter the onboarding flow.

  3. Upload one video manually from each profile. This confirms that the channel is ready to accept uploads and also resolves any verification issues.

  4. Copy the profile UUIDs from Octo. You will need them for the queue file.

Queue file

Create upload_queue.json next to the script with the following structure:

[
  {
    "uuid": "profile_UUID_1",
    "channel": "channel_name_1",
    "videos": [
      {
        "file": "file_path_1",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "PUBLIC"
      },
      {
        "file": "file_path_2",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "UNLISTED"
      }
    ]
  },
  {
    "uuid": "profile_UUID_2",
    "channel": "channel_name_2",
    "videos": [
      {
        "file": "file_path_3",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "PRIVATE"
      }
    ]
  }
]
[
  {
    "uuid": "profile_UUID_1",
    "channel": "channel_name_1",
    "videos": [
      {
        "file": "file_path_1",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "PUBLIC"
      },
      {
        "file": "file_path_2",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "UNLISTED"
      }
    ]
  },
  {
    "uuid": "profile_UUID_2",
    "channel": "channel_name_2",
    "videos": [
      {
        "file": "file_path_3",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "PRIVATE"
      }
    ]
  }
]

The visibility field accepts three values: PRIVATE, UNLISTED, and PUBLIC. If you generate the queue from a spreadsheet or database, simply export it in the same format.

Running the script

  1. Download and install VS Code.

  2. Download and install Node.js.

  3. Create a folder, for example octo_youtube_uploader, and open it in VS Code.

  4. Create octo_youtube_uploader.js and paste the script code into it.

  5. Put upload_queue.json with your profiles and videos next to it.

  6. Check the parameters in the config variable. The main ones are daily_limit_per_channel (how many videos to upload to one channel per run), delay_between_uploads (the pause between uploads in seconds), and upload_timeout_min (how many minutes to wait for a video to finish processing).

  7. Open the terminal and run npm i rebrowser-puppeteer axios.

  8. Launch Octo Browser.

  9. Run the script (Ctrl/Cmd + F5) and monitor the progress in the console.

If VS Code displays an error when installing dependencies, open PowerShell as administrator, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, confirm, and repeat the installation.

Keep in mind that uploading and processing a video takes minutes, not seconds. A run involving twenty videos can take hours, and that's normal. Do not shorten the pauses to speed things up: short, consistent intervals between actions are exactly what reveal automation patterns.

Script code

const axios = require('axios');
const puppeteer = require('rebrowser-puppeteer');
const fs = require('fs').promises;
const path = require('path');


const config = {
    octo_local_api_base_url: 'http://localhost:58888/api/profiles',
    headless_mode: false,
    queue_file: 'upload_queue.json',
    results_dir: 'youtube_results',
    daily_limit_per_channel: 8,
    upload_timeout_min: 40,
    // These two pauses account for roughly 90% of the total run time.
    // Shortening them speeds things up but makes the pattern easier to
    // spot, so treat it as a risk decision rather than an optimization.
    delay_between_uploads: { min: 180, max: 420 },
    delay_between_profiles: { min: 120, max: 300 }
};


const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control';
const SELECTORS = {
    file: 'input[type="file"]',
    title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'],
    description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'],
    tags_toggle: '#toggle-button',
    tags_input: '#tags-container input',
    not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]',
    next: '#next-button',
    done: '#done-button',
    progress: '.progress-label',
    dialogs: ['#close-button', 'ytcp-dialog #dismiss-button']
};


const random_range = (min, max) => min + Math.random() * (max - min);
const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000));
const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000);


async function check_limits(response) {
    for (const entry of (response.headers.ratelimit || '').split(',')) {
        const left = entry.match(/;r=(\d+)/);
        const reset = entry.match(/;t=(\d+)/);
        if (left && reset && +left[1] < 5) {
            const wait = +reset[1] + 1;
            console.log(`⏳ Octo rate limit reached, waiting ${wait}s`);
            await sleep(wait);
        }
    }
}


async function octo_call(action, body) {
    const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body,
        { headers: { 'Content-Type': 'application/json' } });
    await check_limits(res);
    return res.data;
}


// Resolves all selectors in a single round trip instead of one call per
// selector: on a 0.5s poll that adds up over a long run.
async function wait_for_any(page, selectors, timeout = 30000) {
    const list = Array.isArray(selectors) ? selectors : [selectors];
    const started = Date.now();
    while (Date.now() - started < timeout) {
        const hit = await page.evaluate(sels => sels.find(s => {
            const el = document.querySelector(s);
            if (!el) return false;
            const r = el.getBoundingClientRect();
            return r.width > 0 && r.height > 0;
        }), list);
        if (hit) return await page.$(hit);
        await sleep(0.5);
    }
    throw new Error(`None of the selectors matched: ${list.join(' | ')}`);
}


async function type_into_box(page, handle, text) {
    await handle.click();
    await human_delay(200, 500);
    await page.keyboard.down(MOD_KEY);
    await page.keyboard.press('KeyA');
    await page.keyboard.up(MOD_KEY);
    await page.keyboard.press('Backspace');
    await human_delay(150, 400);
    for (const chunk of text.split(/(?<=\s)/)) {
        await page.keyboard.type(chunk, { delay: random_range(15, 55) });
        if (Math.random() < 0.15) await human_delay(200, 700);
    }
}


async function dismiss_dialogs(page) {
    for (const selector of SELECTORS.dialogs) {
        const handle = await page.$(selector);
        if (!handle) continue;
        await handle.click().catch(() => { });
        await human_delay(500, 1200);
    }
}


// Closes tabs we do not need. Studio opens some on its own, and Octo
// persists the session, so leftovers reappear on the next launch.
// Without `keep` the last surviving tab is parked on about:blank.
async function prune_tabs(browser, keep = null) {
    try {
        const pages = await browser.pages();
        const survivor = keep || pages[0];
        for (const p of pages) {
            if (p !== survivor) await p.close().catch(() => { });
        }
        if (!keep && survivor) {
            await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { });
        }
    } catch (e) {
        console.log(`   ⚠️ Tab cleanup failed: ${e.message}`);
    }
}




async function wait_for_processing(page, timeout_min) {
    const deadline = Date.now() + timeout_min * 60 * 1000;
    let last_report = 0;
    while (Date.now() < deadline) {
        const percent = await page.evaluate(sel => {
            const el = document.querySelector(sel);
            const m = el && el.textContent.match(/(\d+)\s*%/);
            return m ? Number(m[1]) : null;
        }, SELECTORS.progress);
        // The percentage disappearing from the label means the upload is
        // done and Studio switched to its processing or checks message.
        if (percent === null || percent >= 100) return true;
        if (percent - last_report >= 10) {
            console.log(`   ⬆️ Uploaded ${percent}%`);
            last_report = percent;
        }
        await sleep(5);
    }
    return false;
}


async function fill_metadata(page, video) {
    const title_box = await wait_for_any(page, SELECTORS.title, 90000);
    await human_delay(1500, 3000);
    await type_into_box(page, title_box, video.title);
    console.log(`   ✏️ Title: ${video.title}`);


    if (video.description?.trim()) {
        const box = await wait_for_any(page, SELECTORS.description);
        await type_into_box(page, box, video.description);
        console.log('   📝 Description filled in');
    }


    if (video.tags?.length) {
        const toggle = await page.$(SELECTORS.tags_toggle);
        if (toggle) {
            await toggle.click();
            await human_delay(1000, 2000);
            const input = await page.$(SELECTORS.tags_input);
            if (input) {
                await input.click();
                for (const tag of video.tags) {
                    await page.keyboard.type(tag, { delay: random_range(20, 60) });
                    await page.keyboard.press('Comma');
                    await human_delay(300, 800);
                }
                console.log(`   🏷 Tags: ${video.tags.join(', ')}`);
            }
        }
    }


    const not_for_kids = await page.$(SELECTORS.not_for_kids);
    if (not_for_kids) {
        await not_for_kids.click();
        await human_delay(600, 1400);
    }
}


async function upload_one(page, video) {
    console.log(`\n🎬 ${path.basename(video.file)}`);


    await page.goto('https://www.youtube.com/upload', {
        waitUntil: 'domcontentloaded',
        timeout: 60000
    });
    await human_delay(2000, 4000);
    await dismiss_dialogs(page);


    const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 });
    await file_input.uploadFile(video.file);
    console.log('   📤 File handed to the input, waiting for the metadata dialog');


    await fill_metadata(page, video);


    // Three wizard steps: details, video elements, checks.
    for (let step = 0; step < 3; step++) {
        const next = await wait_for_any(page, SELECTORS.next);
        await next.click();
        await human_delay(1500, 3000);
    }


    const visibility = video.visibility || 'PRIVATE';
    const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`);
    await radio.click();
    console.log(`   👁 Visibility: ${visibility}`);
    await human_delay(1000, 2000);


    if (!await wait_for_processing(page, config.upload_timeout_min)) {
        console.log('   ⚠️ Processing timed out, the video stays a draft');
        return { file: video.file, success: false, reason: 'processing_timeout' };
    }


    const done = await wait_for_any(page, SELECTORS.done, 60000);
    await done.click();
    await human_delay(3000, 6000);


    await dismiss_dialogs(page);
    await prune_tabs(page.browser(), page);


    console.log('   ✅ Published');
    return { file: video.file, success: true, visibility };
}


async function process_profile(entry, index, total) {
    const line = '='.repeat(70);
    console.log(`\n${line}\n📋 Profile ${index + 1}/${total}${entry.channel} (${entry.uuid})`);
    console.log(`   Videos queued: ${entry.videos.length}\n${line}`);


    const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] };


    await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
    await sleep(3);


    let start_data;
    try {
        start_data = await octo_call('start', {
            uuid: entry.uuid,
            headless: config.headless_mode,
            debug_port: true,
            timeout: 120
        });
    } catch (e) {
        const body = e.response?.data ? JSON.stringify(e.response.data) : e.message;
        console.error(`❌ Profile failed to start: ${body}`);
        return { ...stats, status: 'start_failed' };
    }


    if (!start_data?.ws_endpoint) {
        console.error('❌ Octo returned no ws_endpoint');
        await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
        return { ...stats, status: 'no_ws' };
    }


    let browser;
    try {
        browser = await puppeteer.connect({
            browserWSEndpoint: start_data.ws_endpoint,
            defaultViewport: null,
            protocolTimeout: 3600000
        });


        // The profile starts with a tab already open. Reuse it, otherwise
        // a second, empty one shows up next to it.
        const pages = await browser.pages();
        const page = pages[0] || await browser.newPage();
        await page.setViewport({ width: 1440, height: 900 });


        await page.goto('https://studio.youtube.com/', {
            waitUntil: 'domcontentloaded',
            timeout: 60000
        });
        await human_delay(1500, 2500);


        if (page.url().includes('accounts.google.com')) {
            console.error('❌ Profile is not signed in to Google');
            return { ...stats, status: 'not_logged_in' };
        }


        const limit = Math.min(entry.videos.length, config.daily_limit_per_channel);
        for (let i = 0; i < limit; i++) {
            try {
                stats.uploads.push(await upload_one(page, entry.videos[i]));
            } catch (e) {
                console.error(`❌ Upload error: ${e.message}`);
                stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message });
            }
            if (i < limit - 1) {
                const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max);
                console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`);
                await sleep(pause);
            }
        }


        if (entry.videos.length > limit) {
            console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`);
        }
    } catch (e) {
        console.error(`❌ Profile processing error: ${e.message}`);
        stats.status = 'error';
        stats.error = e.message;
    } finally {
        if (browser) {
            await prune_tabs(browser);
            browser.disconnect();
        }
        await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
        await sleep(2);
    }


    return stats;
}


// Validate the queue before launching a browser. An empty title or a wrong
// file path would otherwise pass silently: there is nothing to type, no
// error is raised, and the video lands on YouTube named after its file.
async function validate_queue(queue) {
    const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC'];
    if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array'];


    const problems = [];
    for (const [i, entry] of queue.entries()) {
        const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`;
        if (!entry.uuid) problems.push(`${where}: no profile uuid`);
        if (!Array.isArray(entry.videos) || !entry.videos.length) {
            problems.push(`${where}: no videos at all`);
            continue;
        }


        for (const [j, video] of entry.videos.entries()) {
            const at = `${where}, video ${j + 1}`;
            const readable = video.file && await fs.access(video.file).then(() => true, () => false);


            if (!video.file) problems.push(`${at}: no file path`);
            else if (!readable) problems.push(`${at}: file not found — ${video.file}`);


            if (!video.title?.trim()) problems.push(`${at}: empty title`);
            else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`);


            if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`);
            if (video.visibility && !VISIBILITY.includes(video.visibility)) {
                problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`);
            }
        }
    }
    return problems;
}


(async () => {
    console.log('🚀 Octo YouTube Bulk Uploader');


    const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8'));
    const problems = await validate_queue(queue);
    if (problems.length) {
        console.error(`\n❌ Queue validation failed (${problems.length}):`);
        for (const p of problems) console.error(`   • ${p}`);
        console.error('\nFix the queue and run again. No browser was launched.');
        process.exit(1);
    }


    const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0);
    console.log(`   ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`);
    console.log(`   Per-channel limit for this run: ${config.daily_limit_per_channel}\n`);


    const results_dir = path.join(__dirname, config.results_dir);
    await fs.mkdir(results_dir, { recursive: true });


    const all_stats = [];
    for (let i = 0; i < queue.length; i++) {
        all_stats.push(await process_profile(queue[i], i, queue.length));
        if (i < queue.length - 1) {
            const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max);
            console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`);
            await sleep(pause);
        }
    }


    console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`);
    for (const s of all_stats) {
        const ok = s.uploads.filter(u => u.success).length;
        console.log(`\n${s.channel}${s.status}, succeeded ${ok}/${s.uploads.length}`);
        for (const u of s.uploads) {
            console.log(u.success
                ? `   ✅ ${path.basename(u.file)}${u.visibility}`
                : `   ❌ ${path.basename(u.file)}: ${u.reason}`);
        }
    }


    const report_path = path.join(results_dir, `report_${Date.now()}.json`);
    await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8');
    console.log(`\n📄 Report: ${report_path}\n🎉 Done.`);
})();

const axios = require('axios');
const puppeteer = require('rebrowser-puppeteer');
const fs = require('fs').promises;
const path = require('path');


const config = {
    octo_local_api_base_url: 'http://localhost:58888/api/profiles',
    headless_mode: false,
    queue_file: 'upload_queue.json',
    results_dir: 'youtube_results',
    daily_limit_per_channel: 8,
    upload_timeout_min: 40,
    // These two pauses account for roughly 90% of the total run time.
    // Shortening them speeds things up but makes the pattern easier to
    // spot, so treat it as a risk decision rather than an optimization.
    delay_between_uploads: { min: 180, max: 420 },
    delay_between_profiles: { min: 120, max: 300 }
};


const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control';
const SELECTORS = {
    file: 'input[type="file"]',
    title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'],
    description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'],
    tags_toggle: '#toggle-button',
    tags_input: '#tags-container input',
    not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]',
    next: '#next-button',
    done: '#done-button',
    progress: '.progress-label',
    dialogs: ['#close-button', 'ytcp-dialog #dismiss-button']
};


const random_range = (min, max) => min + Math.random() * (max - min);
const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000));
const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000);


async function check_limits(response) {
    for (const entry of (response.headers.ratelimit || '').split(',')) {
        const left = entry.match(/;r=(\d+)/);
        const reset = entry.match(/;t=(\d+)/);
        if (left && reset && +left[1] < 5) {
            const wait = +reset[1] + 1;
            console.log(`⏳ Octo rate limit reached, waiting ${wait}s`);
            await sleep(wait);
        }
    }
}


async function octo_call(action, body) {
    const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body,
        { headers: { 'Content-Type': 'application/json' } });
    await check_limits(res);
    return res.data;
}


// Resolves all selectors in a single round trip instead of one call per
// selector: on a 0.5s poll that adds up over a long run.
async function wait_for_any(page, selectors, timeout = 30000) {
    const list = Array.isArray(selectors) ? selectors : [selectors];
    const started = Date.now();
    while (Date.now() - started < timeout) {
        const hit = await page.evaluate(sels => sels.find(s => {
            const el = document.querySelector(s);
            if (!el) return false;
            const r = el.getBoundingClientRect();
            return r.width > 0 && r.height > 0;
        }), list);
        if (hit) return await page.$(hit);
        await sleep(0.5);
    }
    throw new Error(`None of the selectors matched: ${list.join(' | ')}`);
}


async function type_into_box(page, handle, text) {
    await handle.click();
    await human_delay(200, 500);
    await page.keyboard.down(MOD_KEY);
    await page.keyboard.press('KeyA');
    await page.keyboard.up(MOD_KEY);
    await page.keyboard.press('Backspace');
    await human_delay(150, 400);
    for (const chunk of text.split(/(?<=\s)/)) {
        await page.keyboard.type(chunk, { delay: random_range(15, 55) });
        if (Math.random() < 0.15) await human_delay(200, 700);
    }
}


async function dismiss_dialogs(page) {
    for (const selector of SELECTORS.dialogs) {
        const handle = await page.$(selector);
        if (!handle) continue;
        await handle.click().catch(() => { });
        await human_delay(500, 1200);
    }
}


// Closes tabs we do not need. Studio opens some on its own, and Octo
// persists the session, so leftovers reappear on the next launch.
// Without `keep` the last surviving tab is parked on about:blank.
async function prune_tabs(browser, keep = null) {
    try {
        const pages = await browser.pages();
        const survivor = keep || pages[0];
        for (const p of pages) {
            if (p !== survivor) await p.close().catch(() => { });
        }
        if (!keep && survivor) {
            await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { });
        }
    } catch (e) {
        console.log(`   ⚠️ Tab cleanup failed: ${e.message}`);
    }
}




async function wait_for_processing(page, timeout_min) {
    const deadline = Date.now() + timeout_min * 60 * 1000;
    let last_report = 0;
    while (Date.now() < deadline) {
        const percent = await page.evaluate(sel => {
            const el = document.querySelector(sel);
            const m = el && el.textContent.match(/(\d+)\s*%/);
            return m ? Number(m[1]) : null;
        }, SELECTORS.progress);
        // The percentage disappearing from the label means the upload is
        // done and Studio switched to its processing or checks message.
        if (percent === null || percent >= 100) return true;
        if (percent - last_report >= 10) {
            console.log(`   ⬆️ Uploaded ${percent}%`);
            last_report = percent;
        }
        await sleep(5);
    }
    return false;
}


async function fill_metadata(page, video) {
    const title_box = await wait_for_any(page, SELECTORS.title, 90000);
    await human_delay(1500, 3000);
    await type_into_box(page, title_box, video.title);
    console.log(`   ✏️ Title: ${video.title}`);


    if (video.description?.trim()) {
        const box = await wait_for_any(page, SELECTORS.description);
        await type_into_box(page, box, video.description);
        console.log('   📝 Description filled in');
    }


    if (video.tags?.length) {
        const toggle = await page.$(SELECTORS.tags_toggle);
        if (toggle) {
            await toggle.click();
            await human_delay(1000, 2000);
            const input = await page.$(SELECTORS.tags_input);
            if (input) {
                await input.click();
                for (const tag of video.tags) {
                    await page.keyboard.type(tag, { delay: random_range(20, 60) });
                    await page.keyboard.press('Comma');
                    await human_delay(300, 800);
                }
                console.log(`   🏷 Tags: ${video.tags.join(', ')}`);
            }
        }
    }


    const not_for_kids = await page.$(SELECTORS.not_for_kids);
    if (not_for_kids) {
        await not_for_kids.click();
        await human_delay(600, 1400);
    }
}


async function upload_one(page, video) {
    console.log(`\n🎬 ${path.basename(video.file)}`);


    await page.goto('https://www.youtube.com/upload', {
        waitUntil: 'domcontentloaded',
        timeout: 60000
    });
    await human_delay(2000, 4000);
    await dismiss_dialogs(page);


    const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 });
    await file_input.uploadFile(video.file);
    console.log('   📤 File handed to the input, waiting for the metadata dialog');


    await fill_metadata(page, video);


    // Three wizard steps: details, video elements, checks.
    for (let step = 0; step < 3; step++) {
        const next = await wait_for_any(page, SELECTORS.next);
        await next.click();
        await human_delay(1500, 3000);
    }


    const visibility = video.visibility || 'PRIVATE';
    const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`);
    await radio.click();
    console.log(`   👁 Visibility: ${visibility}`);
    await human_delay(1000, 2000);


    if (!await wait_for_processing(page, config.upload_timeout_min)) {
        console.log('   ⚠️ Processing timed out, the video stays a draft');
        return { file: video.file, success: false, reason: 'processing_timeout' };
    }


    const done = await wait_for_any(page, SELECTORS.done, 60000);
    await done.click();
    await human_delay(3000, 6000);


    await dismiss_dialogs(page);
    await prune_tabs(page.browser(), page);


    console.log('   ✅ Published');
    return { file: video.file, success: true, visibility };
}


async function process_profile(entry, index, total) {
    const line = '='.repeat(70);
    console.log(`\n${line}\n📋 Profile ${index + 1}/${total}${entry.channel} (${entry.uuid})`);
    console.log(`   Videos queued: ${entry.videos.length}\n${line}`);


    const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] };


    await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
    await sleep(3);


    let start_data;
    try {
        start_data = await octo_call('start', {
            uuid: entry.uuid,
            headless: config.headless_mode,
            debug_port: true,
            timeout: 120
        });
    } catch (e) {
        const body = e.response?.data ? JSON.stringify(e.response.data) : e.message;
        console.error(`❌ Profile failed to start: ${body}`);
        return { ...stats, status: 'start_failed' };
    }


    if (!start_data?.ws_endpoint) {
        console.error('❌ Octo returned no ws_endpoint');
        await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
        return { ...stats, status: 'no_ws' };
    }


    let browser;
    try {
        browser = await puppeteer.connect({
            browserWSEndpoint: start_data.ws_endpoint,
            defaultViewport: null,
            protocolTimeout: 3600000
        });


        // The profile starts with a tab already open. Reuse it, otherwise
        // a second, empty one shows up next to it.
        const pages = await browser.pages();
        const page = pages[0] || await browser.newPage();
        await page.setViewport({ width: 1440, height: 900 });


        await page.goto('https://studio.youtube.com/', {
            waitUntil: 'domcontentloaded',
            timeout: 60000
        });
        await human_delay(1500, 2500);


        if (page.url().includes('accounts.google.com')) {
            console.error('❌ Profile is not signed in to Google');
            return { ...stats, status: 'not_logged_in' };
        }


        const limit = Math.min(entry.videos.length, config.daily_limit_per_channel);
        for (let i = 0; i < limit; i++) {
            try {
                stats.uploads.push(await upload_one(page, entry.videos[i]));
            } catch (e) {
                console.error(`❌ Upload error: ${e.message}`);
                stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message });
            }
            if (i < limit - 1) {
                const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max);
                console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`);
                await sleep(pause);
            }
        }


        if (entry.videos.length > limit) {
            console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`);
        }
    } catch (e) {
        console.error(`❌ Profile processing error: ${e.message}`);
        stats.status = 'error';
        stats.error = e.message;
    } finally {
        if (browser) {
            await prune_tabs(browser);
            browser.disconnect();
        }
        await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
        await sleep(2);
    }


    return stats;
}


// Validate the queue before launching a browser. An empty title or a wrong
// file path would otherwise pass silently: there is nothing to type, no
// error is raised, and the video lands on YouTube named after its file.
async function validate_queue(queue) {
    const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC'];
    if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array'];


    const problems = [];
    for (const [i, entry] of queue.entries()) {
        const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`;
        if (!entry.uuid) problems.push(`${where}: no profile uuid`);
        if (!Array.isArray(entry.videos) || !entry.videos.length) {
            problems.push(`${where}: no videos at all`);
            continue;
        }


        for (const [j, video] of entry.videos.entries()) {
            const at = `${where}, video ${j + 1}`;
            const readable = video.file && await fs.access(video.file).then(() => true, () => false);


            if (!video.file) problems.push(`${at}: no file path`);
            else if (!readable) problems.push(`${at}: file not found — ${video.file}`);


            if (!video.title?.trim()) problems.push(`${at}: empty title`);
            else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`);


            if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`);
            if (video.visibility && !VISIBILITY.includes(video.visibility)) {
                problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`);
            }
        }
    }
    return problems;
}


(async () => {
    console.log('🚀 Octo YouTube Bulk Uploader');


    const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8'));
    const problems = await validate_queue(queue);
    if (problems.length) {
        console.error(`\n❌ Queue validation failed (${problems.length}):`);
        for (const p of problems) console.error(`   • ${p}`);
        console.error('\nFix the queue and run again. No browser was launched.');
        process.exit(1);
    }


    const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0);
    console.log(`   ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`);
    console.log(`   Per-channel limit for this run: ${config.daily_limit_per_channel}\n`);


    const results_dir = path.join(__dirname, config.results_dir);
    await fs.mkdir(results_dir, { recursive: true });


    const all_stats = [];
    for (let i = 0; i < queue.length; i++) {
        all_stats.push(await process_profile(queue[i], i, queue.length));
        if (i < queue.length - 1) {
            const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max);
            console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`);
            await sleep(pause);
        }
    }


    console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`);
    for (const s of all_stats) {
        const ok = s.uploads.filter(u => u.success).length;
        console.log(`\n${s.channel}${s.status}, succeeded ${ok}/${s.uploads.length}`);
        for (const u of s.uploads) {
            console.log(u.success
                ? `   ✅ ${path.basename(u.file)}${u.visibility}`
                : `   ❌ ${path.basename(u.file)}: ${u.reason}`);
        }
    }


    const report_path = path.join(results_dir, `report_${Date.now()}.json`);
    await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8');
    console.log(`\n📄 Report: ${report_path}\n🎉 Done.`);
})();

Checklist before running the entire network

  1. Channels have been properly prepared. A new channel that gets twenty videos on its first day looks suspicious regardless of how it is populated. Start with two or three uploads per day and grow the channel gradually.

  2. Proxies are different and not datacenter-based. One IP address for twenty channels defeats profile isolation.

  3. Metadata is not templated. Identical descriptions with identical sets of links across the entire network are one of the most obvious signs that the channels are connected. Generate variations.

  4. Selectors have been tested. Studio changes over time, so before a large run, execute the script on one profile with headless_mode: false and visually inspect where it might fail.

  5. One video at a time per profile. Parallel uploads of several videos from the same profile are limited by the channel's bandwidth and increase the chance of timeouts. Parallelize profiles, not videos within a profile.

  6. Content is not duplicated. Uploading the same file to ten channels may fall under YouTube's reused-content policy. Localization, editing, and voice-over should differ.

A hybrid approach: browser uploads, API management

This approach used to be justified by its cost: uploading cost 1,600 units, while everything else cost between 1 and 50. After the December price reduction, the cost argument disappeared, but the approach itself remains useful, simply for a different reason.

Uploads through the API from an unverified project result in locked private videos, while the videos.insert bucket is capped at one hundred calls per day per project and can only be expanded through an audit. All other operations are subject to neither restriction: they are deducted from the general 10,000-unit pool, where editing metadata costs 50 units and reading costs one.

This leads to a division of responsibilities. Files are uploaded through browser automation because that avoids the unverified client rule and does not consume the scarce upload bucket. After that, the API takes over: it edits titles and descriptions via videos.update, sets thumbnails via thumbnails.set, organizes videos into playlists, and collects statistics. No audit is required for these operations, and there is no risk of videos being locked because the upload itself was performed by the browser.

The browser does what is risky through the API; the API does what is slow and fragile through the browser. Bulk-editing descriptions on two hundred videos through the API takes a minute, while doing it through Studio automation takes half a day.

Conclusion

Bulk YouTube uploads run into more than just quota limits. Since the end of 2025, the Data API quota has largely stopped being a problem: one hundred uploads per day per project covers most use cases, and the limit can be expanded through an audit. The locked-private issue for videos uploaded from unverified projects is solved either through that same audit or by uploading videos in the browser. The channel's daily upload limit simply cannot be bypassed, so the workload needs to be distributed across multiple channels.

The script in this article handles the last case: it turns a network of channels into a single manageable pipeline, where the queue lives in JSON and each channel operates from its own isolated profile with its own proxy. It can be extended in obvious ways: assign thumbnails, add subtitles, schedule publication instead of publishing immediately, or add persistent queue state so that a run resumes from where it stopped the previous day.

The resilience of such a system is not determined by the script, which is its simplest part. The system works as long as the channels appear independent: different fingerprints, different IP addresses, different content, and a plausible publishing pace.

Stay anonymous, take advantage of multi-accounting, and achieve your goals with the highest-quality anti-detect browser on the market.

Would you like to try Octo Browser at discount?
Use the promo code OCTOBLOG to get 30% off any subscription. This offer is valid only for new users.

Two tasks, two approaches

Before choosing an automation method, determine what you're actually trying to accomplish:

  • Bulk video uploads to one channel. Two hundred videos for a channel with educational lectures, a webinar archive, podcast clips. Here, the bottleneck is the channel's own daily limit, which, as we'll see below, cannot be bypassed.

  • Working with multiple channels. Localized versions of the same content for different countries, channels for different offers, client channels managed by an agency. Here, the main question is how to keep twenty Google accounts separate enough that YouTube does not link them together and ban them all at once.

The first task is solved through planning and scheduled publishing. The second requires profile isolation and automation. Let's look at both approaches.

Built-in YouTube capabilities for bulk uploads

Before writing scripts, check whether YouTube's built-in functionality is already enough for your needs. YouTube Studio accepts multiple files at once: in the upload dialog, you can select a batch of videos, all of them will be added as drafts, and you can then edit their metadata in bulk by selecting multiple checkboxes. Scheduled publishing can also be configured there, so twenty drafts can be scheduled for the month ahead.

For regular work with a single channel, this is often enough. Automation starts paying off when you have more than fifty videos, more than three channels, or when metadata is generated programmatically and stored in a CSV file or database.

YouTube Data API v3 and its two ceilings

The official way to upload videos is the videos.insert method. It supports resumable uploads, accepts files up to 256 GB, and works only through OAuth 2.0. Service accounts are not suitable for uploading on behalf of a channel, which breaks the very first attempt to build a server-side uploader without interactive authorization: you'll have to obtain a refresh token manually at least once per channel.

However, authorization is not the main problem.

Quota: almost every guide on the subject is outdated

If you've looked into bulk uploading before, you've almost certainly seen the following calculation: "10,000 units divided by 1,600 per upload equals six videos per day." This calculation is no longer valid, and this is the most important thing to understand in 2026.

Google changed the quota model twice. On December 4, 2025, the cost of a video upload was reduced from approximately 1,600 units to approximately 100. Then, on June 1, 2026, the API moved to a granular quota system: videos.insert and search.list calls are deducted from their own separate buckets rather than from a single shared pool.

The current default project quota looks like this:

Bucket

Default limit

videos.insert (video upload)

100 calls per day

search.list (search)

100 calls per day

All other methods

10,000 units per day combined

The buckets are independent: once you’ve used up your hundred uploads, you'll receive a 403 from videos.insert, even if 9,000 units remain untouched in the general pool.

The cost of methods within the general pool has not changed:

  • reads cost 1 unit;

  • writes such as videos.update and thumbnails.set cost 50;

  • captions.insert is significantly more expensive at 400.

Be careful with Google's documentation: at the time of writing, it contradicts itself. The "Quota calculator" page was updated in August 2025 and still shows the old 1,600-unit cost for videos.insert. The English overview and the June 1, 2026 entry in the Revision History are the up-to-date sources. You should always check your project's actual limits in the Google Cloud Console: quota values may differ between older and newer projects.

Conclusion: one hundred uploads per day per project is no longer the bottleneck that requires workarounds. The real limitations lie elsewhere.

Ceiling one: audit and locked private

Let's talk about the issue that most often makes custom uploaders useless, regardless of quota changes. All videos uploaded with videos.insert from an unverified API project created after July 28, 2020 are forcibly switched to private. The channel owner receives an email saying that the video has been locked as private because it was uploaded through an unverified client.

This cannot be appealed. The only way out is to re-upload the video through an official or audited client, or through the YouTube website. So until the audit is passed, the uploader formally works, returns 200 OK, and gives you a video ID, but nobody except you will be able to see the result of its work.

Projects created before July 2020 are not subject to this rule. If you have such a project, you're in luck. Everybody else will either have to pass the audit or give up on public API uploads.

Ceiling two: the channel's daily upload limit

This is the most annoying restriction because there is absolutely no workaround for it. YouTube limits how many videos a channel can upload within 24 hours and does not publish exact figures. We know that the limit depends on the channel's history, region, and whether it has strikes, and that it applies simultaneously to the web version, mobile apps, and API. Based on community observations, it is roughly 10–20 videos for new channels and up to a hundred for long-established ones, but these are observations, not documented values.

In the API, this limit appears as a separate uploadLimitExceeded error with code 400, and it is important to distinguish this from the exhausted project quota: the first applies to the channel, while the second applies to the project.

If you hit the channel limit, you'll have to wait 24 hours. Changing the upload method or contacting support won't help. The only effective way to increase throughput is not to upload more to one channel, but to distribute the workload across multiple channels.

Architectural conclusions

Under the new quota model, the main question is no longer the number of API requests. A hundred uploads per day per project is enough for most tasks. However, uploading through the API before passing the audit is useless for public content, and a single channel cannot be scaled vertically regardless of the upload method. That leaves only horizontal scaling: more channels, fewer uploads per channel.

And when you're managing twenty channels, you need a structured multi-accounting strategy. Google links accounts more aggressively than most platforms: through device fingerprints, shared cookie sessions, matching Client Hints, and IP addresses. Twenty channels from one Chrome instance under one IP will be tied into a single cluster, which can then be blocked as a whole.

So the architecture of the solution looks like this:

  1. Octo Browser, an anti-detect browser for multi-accounting: one isolated profile per channel, each with its own fingerprint and proxy. Profiles contain live Google sessions, so one-time profiles are not suitable here; you need persistent ones. They can be conveniently organized into folders so that channels belonging to the same project are grouped together.

  2. Rebrowser-puppeteer: a Puppeteer fork without the characteristic automation leaks.

  3. Octo Local API: launches profiles and connects to them via CDP. A full description of the methods is available in the documentation and the API reference.

  4. JSON job queue: a file containing the list of profiles, their associated videos, and metadata. This keeps the script universal while all the specifics are taken from the input data.

How to work with Studio selectors

The YouTube Studio interface is built with Polymer web components, and the selector strategy here differs from what we used for scraping Instagram.

The bad news: classes in Studio are generated and short-lived. The good news: key elements have stable IDs and attributes that are not tied to the interface language. The button for moving to the next step is always #next-button. Visibility radio buttons are tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. The switch for content made for kids is [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. None of these changes when the account locale changes.

The practical takeaway: rely on attributes and IDs rather than button text. A script that looks for a button containing the word "Next" will fall apart as soon as it runs on a profile with a different interface language. A script using #next-button will work in any language, and there is no need to forcibly set the locale through ?hl=en.

The second trick concerns the file upload itself. Clicking the drag-and-drop area is pointless: it opens the native operating system dialog, which cannot be closed from the browser. Instead, the file is passed directly to the hidden input[type="file"] through uploadFile(). The OS dialog does not appear at all in this case.

Ready-made script for uploading videos to multiple channels

The script sequentially goes through Octo profiles, opens Studio in each one, and uploads the videos assigned to that profile: it fills in the title, description, and tags, marks the content as not made for kids, sets visibility, and waits for processing to finish. Between uploads, it makes random pauses, counts uploads for each channel, and stops once it reaches the configured daily limit.

Profile preparation

  1. Create one Octo profile for each channel and assign each profile its own proxy. A residential or mobile proxy is preferable. Before using it, you should check its reliability.

  2. Open each profile manually and sign into the corresponding Google account. Go to Studio, accept all initial prompts, and confirm the phone number if necessary. The script should not encounter the onboarding flow.

  3. Upload one video manually from each profile. This confirms that the channel is ready to accept uploads and also resolves any verification issues.

  4. Copy the profile UUIDs from Octo. You will need them for the queue file.

Queue file

Create upload_queue.json next to the script with the following structure:

[
  {
    "uuid": "profile_UUID_1",
    "channel": "channel_name_1",
    "videos": [
      {
        "file": "file_path_1",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "PUBLIC"
      },
      {
        "file": "file_path_2",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "UNLISTED"
      }
    ]
  },
  {
    "uuid": "profile_UUID_2",
    "channel": "channel_name_2",
    "videos": [
      {
        "file": "file_path_3",
        "title": "YouTube_video_title",
        "description": "YouTube_video_description",
        "tags": ["tag1", "tag2"],
        "visibility": "PRIVATE"
      }
    ]
  }
]

The visibility field accepts three values: PRIVATE, UNLISTED, and PUBLIC. If you generate the queue from a spreadsheet or database, simply export it in the same format.

Running the script

  1. Download and install VS Code.

  2. Download and install Node.js.

  3. Create a folder, for example octo_youtube_uploader, and open it in VS Code.

  4. Create octo_youtube_uploader.js and paste the script code into it.

  5. Put upload_queue.json with your profiles and videos next to it.

  6. Check the parameters in the config variable. The main ones are daily_limit_per_channel (how many videos to upload to one channel per run), delay_between_uploads (the pause between uploads in seconds), and upload_timeout_min (how many minutes to wait for a video to finish processing).

  7. Open the terminal and run npm i rebrowser-puppeteer axios.

  8. Launch Octo Browser.

  9. Run the script (Ctrl/Cmd + F5) and monitor the progress in the console.

If VS Code displays an error when installing dependencies, open PowerShell as administrator, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, confirm, and repeat the installation.

Keep in mind that uploading and processing a video takes minutes, not seconds. A run involving twenty videos can take hours, and that's normal. Do not shorten the pauses to speed things up: short, consistent intervals between actions are exactly what reveal automation patterns.

Script code

const axios = require('axios');
const puppeteer = require('rebrowser-puppeteer');
const fs = require('fs').promises;
const path = require('path');


const config = {
    octo_local_api_base_url: 'http://localhost:58888/api/profiles',
    headless_mode: false,
    queue_file: 'upload_queue.json',
    results_dir: 'youtube_results',
    daily_limit_per_channel: 8,
    upload_timeout_min: 40,
    // These two pauses account for roughly 90% of the total run time.
    // Shortening them speeds things up but makes the pattern easier to
    // spot, so treat it as a risk decision rather than an optimization.
    delay_between_uploads: { min: 180, max: 420 },
    delay_between_profiles: { min: 120, max: 300 }
};


const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control';
const SELECTORS = {
    file: 'input[type="file"]',
    title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'],
    description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'],
    tags_toggle: '#toggle-button',
    tags_input: '#tags-container input',
    not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]',
    next: '#next-button',
    done: '#done-button',
    progress: '.progress-label',
    dialogs: ['#close-button', 'ytcp-dialog #dismiss-button']
};


const random_range = (min, max) => min + Math.random() * (max - min);
const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000));
const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000);


async function check_limits(response) {
    for (const entry of (response.headers.ratelimit || '').split(',')) {
        const left = entry.match(/;r=(\d+)/);
        const reset = entry.match(/;t=(\d+)/);
        if (left && reset && +left[1] < 5) {
            const wait = +reset[1] + 1;
            console.log(`⏳ Octo rate limit reached, waiting ${wait}s`);
            await sleep(wait);
        }
    }
}


async function octo_call(action, body) {
    const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body,
        { headers: { 'Content-Type': 'application/json' } });
    await check_limits(res);
    return res.data;
}


// Resolves all selectors in a single round trip instead of one call per
// selector: on a 0.5s poll that adds up over a long run.
async function wait_for_any(page, selectors, timeout = 30000) {
    const list = Array.isArray(selectors) ? selectors : [selectors];
    const started = Date.now();
    while (Date.now() - started < timeout) {
        const hit = await page.evaluate(sels => sels.find(s => {
            const el = document.querySelector(s);
            if (!el) return false;
            const r = el.getBoundingClientRect();
            return r.width > 0 && r.height > 0;
        }), list);
        if (hit) return await page.$(hit);
        await sleep(0.5);
    }
    throw new Error(`None of the selectors matched: ${list.join(' | ')}`);
}


async function type_into_box(page, handle, text) {
    await handle.click();
    await human_delay(200, 500);
    await page.keyboard.down(MOD_KEY);
    await page.keyboard.press('KeyA');
    await page.keyboard.up(MOD_KEY);
    await page.keyboard.press('Backspace');
    await human_delay(150, 400);
    for (const chunk of text.split(/(?<=\s)/)) {
        await page.keyboard.type(chunk, { delay: random_range(15, 55) });
        if (Math.random() < 0.15) await human_delay(200, 700);
    }
}


async function dismiss_dialogs(page) {
    for (const selector of SELECTORS.dialogs) {
        const handle = await page.$(selector);
        if (!handle) continue;
        await handle.click().catch(() => { });
        await human_delay(500, 1200);
    }
}


// Closes tabs we do not need. Studio opens some on its own, and Octo
// persists the session, so leftovers reappear on the next launch.
// Without `keep` the last surviving tab is parked on about:blank.
async function prune_tabs(browser, keep = null) {
    try {
        const pages = await browser.pages();
        const survivor = keep || pages[0];
        for (const p of pages) {
            if (p !== survivor) await p.close().catch(() => { });
        }
        if (!keep && survivor) {
            await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { });
        }
    } catch (e) {
        console.log(`   ⚠️ Tab cleanup failed: ${e.message}`);
    }
}




async function wait_for_processing(page, timeout_min) {
    const deadline = Date.now() + timeout_min * 60 * 1000;
    let last_report = 0;
    while (Date.now() < deadline) {
        const percent = await page.evaluate(sel => {
            const el = document.querySelector(sel);
            const m = el && el.textContent.match(/(\d+)\s*%/);
            return m ? Number(m[1]) : null;
        }, SELECTORS.progress);
        // The percentage disappearing from the label means the upload is
        // done and Studio switched to its processing or checks message.
        if (percent === null || percent >= 100) return true;
        if (percent - last_report >= 10) {
            console.log(`   ⬆️ Uploaded ${percent}%`);
            last_report = percent;
        }
        await sleep(5);
    }
    return false;
}


async function fill_metadata(page, video) {
    const title_box = await wait_for_any(page, SELECTORS.title, 90000);
    await human_delay(1500, 3000);
    await type_into_box(page, title_box, video.title);
    console.log(`   ✏️ Title: ${video.title}`);


    if (video.description?.trim()) {
        const box = await wait_for_any(page, SELECTORS.description);
        await type_into_box(page, box, video.description);
        console.log('   📝 Description filled in');
    }


    if (video.tags?.length) {
        const toggle = await page.$(SELECTORS.tags_toggle);
        if (toggle) {
            await toggle.click();
            await human_delay(1000, 2000);
            const input = await page.$(SELECTORS.tags_input);
            if (input) {
                await input.click();
                for (const tag of video.tags) {
                    await page.keyboard.type(tag, { delay: random_range(20, 60) });
                    await page.keyboard.press('Comma');
                    await human_delay(300, 800);
                }
                console.log(`   🏷 Tags: ${video.tags.join(', ')}`);
            }
        }
    }


    const not_for_kids = await page.$(SELECTORS.not_for_kids);
    if (not_for_kids) {
        await not_for_kids.click();
        await human_delay(600, 1400);
    }
}


async function upload_one(page, video) {
    console.log(`\n🎬 ${path.basename(video.file)}`);


    await page.goto('https://www.youtube.com/upload', {
        waitUntil: 'domcontentloaded',
        timeout: 60000
    });
    await human_delay(2000, 4000);
    await dismiss_dialogs(page);


    const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 });
    await file_input.uploadFile(video.file);
    console.log('   📤 File handed to the input, waiting for the metadata dialog');


    await fill_metadata(page, video);


    // Three wizard steps: details, video elements, checks.
    for (let step = 0; step < 3; step++) {
        const next = await wait_for_any(page, SELECTORS.next);
        await next.click();
        await human_delay(1500, 3000);
    }


    const visibility = video.visibility || 'PRIVATE';
    const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`);
    await radio.click();
    console.log(`   👁 Visibility: ${visibility}`);
    await human_delay(1000, 2000);


    if (!await wait_for_processing(page, config.upload_timeout_min)) {
        console.log('   ⚠️ Processing timed out, the video stays a draft');
        return { file: video.file, success: false, reason: 'processing_timeout' };
    }


    const done = await wait_for_any(page, SELECTORS.done, 60000);
    await done.click();
    await human_delay(3000, 6000);


    await dismiss_dialogs(page);
    await prune_tabs(page.browser(), page);


    console.log('   ✅ Published');
    return { file: video.file, success: true, visibility };
}


async function process_profile(entry, index, total) {
    const line = '='.repeat(70);
    console.log(`\n${line}\n📋 Profile ${index + 1}/${total}${entry.channel} (${entry.uuid})`);
    console.log(`   Videos queued: ${entry.videos.length}\n${line}`);


    const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] };


    await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
    await sleep(3);


    let start_data;
    try {
        start_data = await octo_call('start', {
            uuid: entry.uuid,
            headless: config.headless_mode,
            debug_port: true,
            timeout: 120
        });
    } catch (e) {
        const body = e.response?.data ? JSON.stringify(e.response.data) : e.message;
        console.error(`❌ Profile failed to start: ${body}`);
        return { ...stats, status: 'start_failed' };
    }


    if (!start_data?.ws_endpoint) {
        console.error('❌ Octo returned no ws_endpoint');
        await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
        return { ...stats, status: 'no_ws' };
    }


    let browser;
    try {
        browser = await puppeteer.connect({
            browserWSEndpoint: start_data.ws_endpoint,
            defaultViewport: null,
            protocolTimeout: 3600000
        });


        // The profile starts with a tab already open. Reuse it, otherwise
        // a second, empty one shows up next to it.
        const pages = await browser.pages();
        const page = pages[0] || await browser.newPage();
        await page.setViewport({ width: 1440, height: 900 });


        await page.goto('https://studio.youtube.com/', {
            waitUntil: 'domcontentloaded',
            timeout: 60000
        });
        await human_delay(1500, 2500);


        if (page.url().includes('accounts.google.com')) {
            console.error('❌ Profile is not signed in to Google');
            return { ...stats, status: 'not_logged_in' };
        }


        const limit = Math.min(entry.videos.length, config.daily_limit_per_channel);
        for (let i = 0; i < limit; i++) {
            try {
                stats.uploads.push(await upload_one(page, entry.videos[i]));
            } catch (e) {
                console.error(`❌ Upload error: ${e.message}`);
                stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message });
            }
            if (i < limit - 1) {
                const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max);
                console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`);
                await sleep(pause);
            }
        }


        if (entry.videos.length > limit) {
            console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`);
        }
    } catch (e) {
        console.error(`❌ Profile processing error: ${e.message}`);
        stats.status = 'error';
        stats.error = e.message;
    } finally {
        if (browser) {
            await prune_tabs(browser);
            browser.disconnect();
        }
        await octo_call('stop', { uuid: entry.uuid }).catch(() => { });
        await sleep(2);
    }


    return stats;
}


// Validate the queue before launching a browser. An empty title or a wrong
// file path would otherwise pass silently: there is nothing to type, no
// error is raised, and the video lands on YouTube named after its file.
async function validate_queue(queue) {
    const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC'];
    if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array'];


    const problems = [];
    for (const [i, entry] of queue.entries()) {
        const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`;
        if (!entry.uuid) problems.push(`${where}: no profile uuid`);
        if (!Array.isArray(entry.videos) || !entry.videos.length) {
            problems.push(`${where}: no videos at all`);
            continue;
        }


        for (const [j, video] of entry.videos.entries()) {
            const at = `${where}, video ${j + 1}`;
            const readable = video.file && await fs.access(video.file).then(() => true, () => false);


            if (!video.file) problems.push(`${at}: no file path`);
            else if (!readable) problems.push(`${at}: file not found — ${video.file}`);


            if (!video.title?.trim()) problems.push(`${at}: empty title`);
            else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`);


            if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`);
            if (video.visibility && !VISIBILITY.includes(video.visibility)) {
                problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`);
            }
        }
    }
    return problems;
}


(async () => {
    console.log('🚀 Octo YouTube Bulk Uploader');


    const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8'));
    const problems = await validate_queue(queue);
    if (problems.length) {
        console.error(`\n❌ Queue validation failed (${problems.length}):`);
        for (const p of problems) console.error(`   • ${p}`);
        console.error('\nFix the queue and run again. No browser was launched.');
        process.exit(1);
    }


    const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0);
    console.log(`   ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`);
    console.log(`   Per-channel limit for this run: ${config.daily_limit_per_channel}\n`);


    const results_dir = path.join(__dirname, config.results_dir);
    await fs.mkdir(results_dir, { recursive: true });


    const all_stats = [];
    for (let i = 0; i < queue.length; i++) {
        all_stats.push(await process_profile(queue[i], i, queue.length));
        if (i < queue.length - 1) {
            const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max);
            console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`);
            await sleep(pause);
        }
    }


    console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`);
    for (const s of all_stats) {
        const ok = s.uploads.filter(u => u.success).length;
        console.log(`\n${s.channel}${s.status}, succeeded ${ok}/${s.uploads.length}`);
        for (const u of s.uploads) {
            console.log(u.success
                ? `   ✅ ${path.basename(u.file)}${u.visibility}`
                : `   ❌ ${path.basename(u.file)}: ${u.reason}`);
        }
    }


    const report_path = path.join(results_dir, `report_${Date.now()}.json`);
    await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8');
    console.log(`\n📄 Report: ${report_path}\n🎉 Done.`);
})();

Checklist before running the entire network

  1. Channels have been properly prepared. A new channel that gets twenty videos on its first day looks suspicious regardless of how it is populated. Start with two or three uploads per day and grow the channel gradually.

  2. Proxies are different and not datacenter-based. One IP address for twenty channels defeats profile isolation.

  3. Metadata is not templated. Identical descriptions with identical sets of links across the entire network are one of the most obvious signs that the channels are connected. Generate variations.

  4. Selectors have been tested. Studio changes over time, so before a large run, execute the script on one profile with headless_mode: false and visually inspect where it might fail.

  5. One video at a time per profile. Parallel uploads of several videos from the same profile are limited by the channel's bandwidth and increase the chance of timeouts. Parallelize profiles, not videos within a profile.

  6. Content is not duplicated. Uploading the same file to ten channels may fall under YouTube's reused-content policy. Localization, editing, and voice-over should differ.

A hybrid approach: browser uploads, API management

This approach used to be justified by its cost: uploading cost 1,600 units, while everything else cost between 1 and 50. After the December price reduction, the cost argument disappeared, but the approach itself remains useful, simply for a different reason.

Uploads through the API from an unverified project result in locked private videos, while the videos.insert bucket is capped at one hundred calls per day per project and can only be expanded through an audit. All other operations are subject to neither restriction: they are deducted from the general 10,000-unit pool, where editing metadata costs 50 units and reading costs one.

This leads to a division of responsibilities. Files are uploaded through browser automation because that avoids the unverified client rule and does not consume the scarce upload bucket. After that, the API takes over: it edits titles and descriptions via videos.update, sets thumbnails via thumbnails.set, organizes videos into playlists, and collects statistics. No audit is required for these operations, and there is no risk of videos being locked because the upload itself was performed by the browser.

The browser does what is risky through the API; the API does what is slow and fragile through the browser. Bulk-editing descriptions on two hundred videos through the API takes a minute, while doing it through Studio automation takes half a day.

Conclusion

Bulk YouTube uploads run into more than just quota limits. Since the end of 2025, the Data API quota has largely stopped being a problem: one hundred uploads per day per project covers most use cases, and the limit can be expanded through an audit. The locked-private issue for videos uploaded from unverified projects is solved either through that same audit or by uploading videos in the browser. The channel's daily upload limit simply cannot be bypassed, so the workload needs to be distributed across multiple channels.

The script in this article handles the last case: it turns a network of channels into a single manageable pipeline, where the queue lives in JSON and each channel operates from its own isolated profile with its own proxy. It can be extended in obvious ways: assign thumbnails, add subtitles, schedule publication instead of publishing immediately, or add persistent queue state so that a run resumes from where it stopped the previous day.

The resilience of such a system is not determined by the script, which is its simplest part. The system works as long as the channels appear independent: different fingerprints, different IP addresses, different content, and a plausible publishing pace.

Stay up to date with the latest Octo Browser news

By clicking the button you agree to our Privacy Policy.

Stay up to date with the latest Octo Browser news

By clicking the button you agree to our Privacy Policy.

Stay up to date with the latest Octo Browser news

By clicking the button you agree to our Privacy Policy.

Join Octo Browser now

Or contact Customer Service at any time with any questions you might have.

Join Octo Browser now

Or contact Customer Service at any time with any questions you might have.

Join Octo Browser now

Or contact Customer Service at any time with any questions you might have.

©

2026

Octo Browser

©

2026

Octo Browser

©

2026

Octo Browser