For a really long time I wanted to move my subscriptions from my old account to new account which has youtube premium. I was being very lazy and didn’t get to it for a long time plus my youtube time was really low thanks to doomscrolling reels on insta but once I get an idea stuck in my head I rarely forget it.
So when I initially planned to move my subscriptions I thought of automating it, in old account I click export and in new account I click Import. It had to be this simple, no searching channel name, clicking subscriptions and this lazziness kept me fom doing it for nearly 4 years.
I got annoyed at ads when I watched my favorite youtubers, but what am I if I don’t stick to only automated, no manual rule I set to make myself suffer. Mind you this was around the time ChatGPT came into being, and when it couldn’t spit out clean JSON even when you told your life depened on it. So I just waited for LLMs to get good at what I wanted, only automated, no manual.
One fine bored night I was wandering through youtube and that remined me of this todo. So I asked claude to
write a JS script to transfer my youtube subscriptions from one account to another
which it did and gave me a script which involded Google Cloud project, enabling the API, configure OAuth, create credentials, deal with quotas, etc.
#Garbage_In_Garbage_Out
I wasn’t gonna do all that work :D, So lazily I followed up with
i want to run it in browser console dude
and made it KISS YAGNI
now the plan was:
-
Open the console on the old account.
-
Ask YouTube for my subscriptions.
-
Save them somewhere.
-
Switch accounts.
-
Feed those saved subscritptions to YouTube.
-
Subscribe to everything again.
-
Pretend this was a carefully planned engineering project (Most important).
And honestly, I didn’t plan any of the above. It was inferred from the lazy prompts I gave.
Instead of setting up a proper application, I ended up with two browser-console scripts.
The Only Complicated Part: Pretending to Be Yourself
YouTube’s internal API expects an authorization header generated from your logged-in session.
In this case, the browser can generate the SAPISIDHASH value using the SAPISID cookie, the current timestamp, and YouTube’s origin.
It looks something like this:
SAPISIDHASH 1700000000_3f9a…
It looks complicated, but the browser already has everything needed to generate it.
After that, the rest is mostly just walking through YouTube’s JSON responses and finding channel IDs.
Step 1: Export (old account)
Go to youtube.com, open the console, and paste this.
If Chrome refuses to let you paste code into the console, type allow pasting first.
Chrome’s way of asking:
”Are you sure you know what you’re doing?”
The honest answer is no, but I trusted Claude to do the right thing with the script and I had reviewed it so… allow pasting it is.
The export script
(async () => {
const key = ytcfg.get('INNERTUBE_API_KEY'), context = ytcfg.get('INNERTUBE_CONTEXT');
const headers = async () => {
const sid = document.cookie.match(/(?:^|; )SAPISID=([^;]+)/)?.[1];
if (!sid) throw new Error('Not logged in?');
const ts = Math.floor(Date.now() / 1000), origin = 'https://www.youtube.com';
const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(`${ts} ${sid} ${origin}`));
const hex = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
return {
'Authorization': `SAPISIDHASH ${ts}_${hex}`,
'X-Origin': origin,
'X-Goog-AuthUser': String(ytcfg.get('SESSION_INDEX') || 0),
'Content-Type': 'application/json',
};
};
const walk = (o, f) => { if (o && typeof o === 'object') { f(o); for (const k in o) walk(o[k], f); } };
const subs = new Map();
let body = { browseId: 'FEchannels' };
while (body) {
const r = await fetch(`/youtubei/v1/browse?key=${key}`, {
method: 'POST', credentials: 'include', headers: await headers(),
body: JSON.stringify({ context, ...body }),
});
const data = await r.json();
let token = null;
walk(data, o => {
if (o.channelRenderer?.channelId)
subs.set(o.channelRenderer.channelId, o.channelRenderer.title?.simpleText || '');
const t = o.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
if (t) token = t;
});
body = token ? { continuation: token } : null;
console.log(`Found ${subs.size} so far…`);
}
const list = [...subs].map(([channelId, title]) => ({ channelId, title }));
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(list, null, 2)], { type: 'application/json' }));
a.download = 'subscriptions.json';
a.click();
console.log(`Done. ${list.length} subscriptions saved to subscriptions.json`);
})();
When it finished, I had a subscriptions.json file containing my entire YouTube history in miniature.
Step 2: Import (new account)
Then I switched to the new account, reloaded YouTube, opened the console again, and ran the second script with allow pasting.
This one opens a file picker, so you just give it the JSON file from the previous step.
The import script
(async () => {
const key = ytcfg.get('INNERTUBE_API_KEY'), context = ytcfg.get('INNERTUBE_CONTEXT');
const headers = async () => {
const sid = document.cookie.match(/(?:^|; )SAPISID=([^;]+)/)?.[1];
if (!sid) throw new Error('Not logged in?');
const ts = Math.floor(Date.now() / 1000), origin = 'https://www.youtube.com';
const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(`${ts} ${sid} ${origin}`));
const hex = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
return {
'Authorization': `SAPISIDHASH ${ts}_${hex}`,
'X-Origin': origin,
'X-Goog-AuthUser': String(ytcfg.get('SESSION_INDEX') || 0),
'Content-Type': 'application/json',
};
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
const input = document.createElement('input');
input.type = 'file'; input.accept = '.json';
input.onchange = async () => {
const list = JSON.parse(await input.files[0].text());
console.log(`Subscribing to ${list.length} channels…`);
let ok = 0;
for (const [i, s] of list.entries()) {
try {
const r = await fetch(`/youtubei/v1/subscription/subscribe?key=${key}`, {
method: 'POST', credentials: 'include', headers: await headers(),
body: JSON.stringify({ context, channelIds: [s.channelId] }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
ok++;
console.log(`✓ ${i + 1}/${list.length} ${s.title}`);
} catch (e) {
console.warn(`✗ ${s.title}: ${e.message}`);
if (/429/.test(e.message)) { console.warn('Rate limited, stopping. Wait a while and re-run.'); break; }
}
await sleep(1500 + Math.random() * 1000);
}
console.log(`Done. ${ok}/${list.length} subscribed.`);
};
input.click();
})();
That sleep(1500 + Math.random() * 1000) is intentional.
The script waits roughly 1.5–2.5 seconds between subscriptions instead of hammering the endpoint as fast as possible.
Is it fooling YouTube into thinking I’m a human?
Probably not.
But at least I’m being polite.
What Happened
Then I just watched the console fill up.
One subscription every couple of seconds.
✓ 1/300 Some Channel ✓ 2/300 Another Channel ✓ 3/300 Yet Another Channel
I sat back, watched a video in background while I waited and refreshed my mobile screen to see the subscriptions moving while laughing hysterically while failing to explain my tech-luddite wife what was happening and why it brought so much joy :D
Started questioning whether I really needed the rusty knife restoration channel.
Obviously I did.
Eventually:
Done. 145/145 subscribed.
I refreshed the new account.
There they were.
All of them.
My questionable YouTube history had successfully been transplanted to its new home.
Learnings
Trust yourself, you can be even lazier and describe the task closer to what you wanted to avoid 2 prompts in the LLM era.
Built with two prompts, a browser console, and a questionable amount of confidence.
If YouTube changes something tomorrow and this stops working, I accept no responsibility. I barely accepted responsibility when it worked.
tudulu ;]
Originally published on Medium.