Add brand account support for YouTube playlist migration

- Implement proper brand account detection and switching
- Add getAvailableChannels() to list all accounts (personal + brand)
- Add switchToChannel() with session context injection for brand accounts
- Update cookie authentication instructions for brand account usage
- Add interactive account selection when TARGET_CHANNEL not specified
- Fix account switching by injecting pageId into session context
- Add verification and debugging output for account switching

This allows users to migrate playlists to their YouTube brand accounts,
not just their personal Google accounts.
This commit is contained in:
T 2025-12-10 01:01:50 -08:00
parent 7572f65c2b
commit c17c60c049
2 changed files with 394 additions and 14 deletions

View file

@ -6,6 +6,8 @@ import {
clearWatchLaterPlaylist,
createPlaylistWithVideos,
getAuthenticatedInstance,
getAvailableChannels,
switchToChannel,
} from "./youtubeService.js";
function prompt(question) {
@ -23,9 +25,8 @@ function prompt(question) {
}
function generatePlaylistName() {
const today = new Date();
const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD
return `WL_${dateString}`;
// Use a custom playlist name
return "Watch Later 7";
}
async function main() {
@ -51,14 +52,119 @@ async function main() {
}
// Step 2: Get user's cookie for authentication.
console.log("\n=== COOKIE AUTHENTICATION INSTRUCTIONS ===");
console.log("⚠️ IMPORTANT: If you want to use a BRAND ACCOUNT (not your personal account):");
console.log(" 1. Open YouTube in your browser");
console.log(" 2. Click your profile icon in the top-right");
console.log(" 3. Select 'Switch account' and choose your BRAND ACCOUNT");
console.log(" 4. Verify you're on the correct account (check the name in top-right)");
console.log(" 5. Visit a page like youtube.com/feed/you to ensure session is established");
console.log("");
console.log("Then to get your cookie:");
console.log("1. Open DevTools (F12)");
console.log("2. Go to the Application/Storage tab");
console.log("3. Click 'Cookies' > 'https://www.youtube.com'");
console.log("4. Look for these important cookies and verify values:");
console.log(" - DELEGATED_SESSION_ID (if present, you're on a brand account)");
console.log(" - SID, __Secure-1PSID, __Secure-3PSID");
console.log("5. Then go to Network tab, refresh, click any youtube.com request");
console.log("6. Copy the ENTIRE 'Cookie' header value");
console.log("");
console.log("⚠️ The cookie MUST be from while you're actively on the brand account!\n");
const userCookie = await prompt(
"\nPlease paste your YouTube cookie string and press Enter:\n> "
"Please paste your FULL YouTube cookie string and press Enter:\n> "
);
if (!userCookie) {
console.error("A cookie is required to proceed. Exiting.");
return;
}
const youtube = await getAuthenticatedInstance(userCookie);
let youtube = await getAuthenticatedInstance(userCookie);
// Step 2.5: Check what account we're authenticated as
console.log("\n=== CHECKING AUTHENTICATED ACCOUNT ===");
const initialChannels = await getAvailableChannels(youtube);
const currentlySelected = initialChannels.find(ch => ch.is_selected);
if (currentlySelected) {
console.log(`\n✅ Authenticated as: ${currentlySelected.name}`);
console.log(` Type: ${currentlySelected.is_brand_account ? 'Brand Account (YouTube Channel)' : 'Personal Google Account'}`);
if (currentlySelected.handle) {
console.log(` Handle: @${currentlySelected.handle}`);
}
}
// Check for target channel and switch if specified
const targetChannelName = process.env.TARGET_CHANNEL;
if (targetChannelName && targetChannelName.trim() !== "") {
console.log(`\nTarget channel specified: "${targetChannelName}"`);
const result = await switchToChannel(youtube, targetChannelName.trim(), userCookie);
if (result.success && result.youtube) {
youtube = result.youtube; // Use the new authenticated instance
console.log("✅ Ready to create playlist on the selected channel.\n");
} else {
const continueAnyway = await prompt(
"\n⚠ Failed to switch to the specified channel. Continue with current account? (y/n): "
);
if (continueAnyway.toLowerCase() !== 'y' && continueAnyway.toLowerCase() !== 'yes') {
console.log("Exiting.");
return;
}
}
} else {
// No target channel specified - fetch all and let user choose
console.log("\nNo TARGET_CHANNEL specified in .env file.");
console.log("Fetching all available accounts (personal + brand accounts)...\n");
const channels = await getAvailableChannels(youtube);
if (channels.length > 1) {
console.log("\n=== AVAILABLE ACCOUNTS ===");
channels.forEach((ch, idx) => {
const type = ch.is_brand_account ? '[Brand Account/Channel]' : '[Personal Account]';
const selected = ch.is_selected ? ' ⭐ CURRENT' : '';
console.log(`${idx + 1}. ${ch.name} ${type}${selected}`);
if (ch.handle) console.log(` Handle: @${ch.handle}`);
if (ch.email) console.log(` ${ch.email}`);
});
const choice = await prompt(
`\nSelect which account to use for the playlist (1-${channels.length}), or press Enter to use current: `
);
if (choice.trim() !== "") {
const selectedIndex = parseInt(choice) - 1;
if (isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= channels.length) {
console.error("Invalid selection. Exiting.");
return;
}
const selectedChannel = channels[selectedIndex];
console.log(`\nSelected: ${selectedChannel.name}`);
if (!selectedChannel.is_selected) {
const result = await switchToChannel(youtube, selectedChannel.name, userCookie);
if (!result.success || !result.youtube) {
console.error("\n❌ Failed to switch to the selected account.");
console.error("Cannot proceed - playlist would be created on the wrong account.");
console.error("Exiting to prevent mistakes.");
return;
}
youtube = result.youtube; // Use the new authenticated instance
console.log("✅ Ready to create playlist on the selected account.\n");
}
} else {
console.log("\nUsing currently selected account.\n");
}
} else if (channels.length === 1) {
console.log(`✅ Using account: ${channels[0].name}\n`);
} else {
console.log("⚠️ No accounts detected. Proceeding with current session...\n");
}
}
// Step 3: Create the new playlist if there are videos to migrate.
if (publicVideoIds.length > 0) {
@ -68,6 +174,8 @@ async function main() {
}
// Step 4: Ask for confirmation to clear the ENTIRE 'Watch Later' playlist.
// COMMENTED OUT FOR TESTING - Just creating the new playlist
/*
const confirmation = await prompt(
`\nDo you want to clear your ENTIRE 'Watch Later' playlist now? (y/n): `
);
@ -75,12 +183,13 @@ async function main() {
const affirmativeAnswers = ["yes", "y"];
if (!affirmativeAnswers.includes(confirmation.toLowerCase())) {
console.log(
"Aborting. No changes will be made to your 'Watch Later' playlist."
"Skipping the 'Watch Later' playlist clearing step."
);
} else {
// Step 5: If confirmed, clear the playlist.
await clearWatchLaterPlaylist(youtube);
}
*/
console.log("\n✅ --- SCRIPT COMPLETE --- ✅");
} catch (error) {

View file

@ -8,11 +8,267 @@ import { Innertube } from "youtubei.js";
*/
export async function getAuthenticatedInstance(cookie) {
console.log("\nAuthenticating with YouTube...");
const youtube = await Innertube.create({ cookie });
console.log("Cookie length:", cookie.length);
// Check for brand account specific cookies
const hasDelegatedSession = cookie.includes('DELEGATED_SESSION_ID');
const hasPageId = cookie.toLowerCase().includes('pageid');
console.log("Has DELEGATED_SESSION_ID cookie:", hasDelegatedSession);
console.log("Has pageId in cookie:", hasPageId);
if (!hasDelegatedSession && !hasPageId) {
console.log("⚠️ WARNING: Cookie appears to be from a PERSONAL account, not a brand account!");
console.log("⚠️ Brand account cookies typically contain DELEGATED_SESSION_ID");
console.log("⚠️ Make sure you switch to your brand account BEFORE copying the cookie!");
}
const youtube = await Innertube.create({
cookie,
generate_session_locally: true
});
console.log("Authentication successful.");
console.log("Session logged in:", youtube.session.logged_in);
console.log("Session account:", youtube.session.account_name);
return youtube;
}
/**
* Gets all available channels/accounts for the authenticated user.
* This includes both the personal Google account AND any brand accounts.
* @param {Innertube} youtube - An authenticated Innertube instance.
* @returns {Promise<Array>} An array of channel objects with id, name, and handle.
*/
export async function getAvailableChannels(youtube) {
console.log("\n=== FETCHING AVAILABLE CHANNELS (Personal + Brand Accounts) ===");
try {
console.log("\nCalling youtube.account.getInfo(true) to get ALL accounts...");
// Use getInfo(true) to get ALL accounts including brand accounts
// This parameter is specifically for getting all accounts when using cookie auth
const accountItems = await youtube.account.getInfo(true);
console.log("\nRaw response from getInfo(true):");
console.log(JSON.stringify(accountItems, null, 2));
console.log(`\nType of response: ${Array.isArray(accountItems) ? 'Array' : typeof accountItems}`);
console.log(`Number of items: ${accountItems ? accountItems.length : 0}`);
// If we have account items, map them to include both personal and brand accounts
if (accountItems && accountItems.length > 0) {
const channels = accountItems.map((item, index) => {
const name = item.account_name?.toString() || item.title?.toString() || `Account ${index + 1}`;
const email = item.account_byline?.toString() || null;
const handle = item.channel_handle?.toString() || null;
const hasChannel = item.has_channel || false;
const isSelected = item.is_selected || false;
return {
name,
email,
id: item.id || item.channel_id,
handle,
is_selected: isSelected,
has_channel: hasChannel,
is_brand_account: hasChannel, // Brand accounts have YouTube channels
endpoint: item.endpoint,
raw: item // Keep raw data for debugging
};
});
console.log("\n=== PARSED ACCOUNTS ===");
channels.forEach((ch, idx) => {
console.log(`\n${idx + 1}. ${ch.name}`);
console.log(` Type: ${ch.is_brand_account ? 'Brand Account (YouTube Channel)' : 'Personal Google Account'}`);
console.log(` Email/Byline: ${ch.email || 'N/A'}`);
console.log(` Handle: ${ch.handle || 'N/A'}`);
console.log(` Currently Selected: ${ch.is_selected ? 'YES ⭐' : 'NO'}`);
});
return channels;
}
console.log("\n⚠ getInfo(true) returned no accounts. This may indicate an issue with the cookie or API.");
return [];
} catch (error) {
console.error("\n❌ Error fetching channels:", error.message);
console.error("Full error:", error);
console.error("Stack:", error.stack);
return [];
}
}
/**
* Switches to a specific channel/account by name and returns fresh session.
* Works with both personal Google accounts and brand accounts.
* @param {Innertube} youtube - An authenticated Innertube instance.
* @param {string} channelName - The name of the channel/account to switch to.
* @param {string} cookie - The cookie string to re-authenticate with.
* @returns {Promise<{success: boolean, youtube: Innertube|null}>} Success status and new youtube instance.
*/
export async function switchToChannel(youtube, channelName, cookie) {
console.log(`\n=== ATTEMPTING TO SWITCH TO: "${channelName}" ===`);
try {
// Get all available channels (both personal and brand accounts)
const channels = await getAvailableChannels(youtube);
if (channels.length === 0) {
console.log("❌ No accounts/channels found to switch to.");
return { success: false, youtube: null };
}
// Find the matching channel (case-insensitive, check both name and handle)
const targetChannel = channels.find(ch =>
ch.name.toLowerCase() === channelName.toLowerCase() ||
(ch.handle && ch.handle.toLowerCase() === channelName.toLowerCase()) ||
(ch.handle && `@${ch.handle}`.toLowerCase() === channelName.toLowerCase())
);
if (!targetChannel) {
console.log(`\n❌ "${channelName}" not found.`);
console.log("\n📋 Available accounts/channels:");
channels.forEach((ch, idx) => {
console.log(` ${idx + 1}. ${ch.name} ${ch.is_brand_account ? '(Brand/Channel)' : '(Personal)'}`);
if (ch.handle) console.log(` Handle: @${ch.handle}`);
});
return { success: false, youtube: null };
}
console.log(`\n✅ Found: ${targetChannel.name}`);
console.log(` Type: ${targetChannel.is_brand_account ? 'Brand Account (YouTube Channel)' : 'Personal Google Account'}`);
console.log(` Handle: ${targetChannel.handle || 'N/A'}`);
// If already selected, no need to switch
if (targetChannel.is_selected) {
console.log("✅ This account is already selected!");
return { success: true, youtube };
}
// Try to switch using the endpoint
if (targetChannel.endpoint) {
console.log("\n🔄 Switching account...");
console.log("Endpoint details:");
console.log(" Type:", targetChannel.endpoint.type);
console.log(" Name:", targetChannel.endpoint.metadata?.name || targetChannel.endpoint.name);
console.log(" Payload:", JSON.stringify(targetChannel.endpoint.payload, null, 2));
try {
// Try using the account manager's selectActiveIdentity method if available
if (targetChannel.endpoint.payload?.supportedTokens) {
const tokens = targetChannel.endpoint.payload.supportedTokens;
console.log("\nAttempting to switch using supportedTokens...");
// Extract the page ID from tokens
const pageIdToken = tokens.find(t => t.pageIdToken);
const pageId = pageIdToken?.pageIdToken?.pageId;
console.log("Brand account page ID:", pageId);
// Try calling the endpoint directly with the actions object
const result = await youtube.actions.execute('/account/account_menu', {
selectActiveIdentityEndpoint: targetChannel.endpoint.payload
});
console.log("Switch API result status:", result.success ? 'SUCCESS' : 'FAILED');
if (result.success && pageId) {
console.log("✅ Account switch API call succeeded!");
// Try to manually update the session context with the delegated page ID
console.log("🔧 Attempting to update session context with brand account page ID...");
try {
// Update the session context to include the delegated session ID
if (youtube.session.context) {
youtube.session.context.user = youtube.session.context.user || {};
youtube.session.context.user.onBehalfOfUser = pageId;
youtube.session.context.user.delegatedSessionId = pageId;
console.log("✅ Session context updated with page ID:", pageId);
console.log("Updated context:", JSON.stringify(youtube.session.context.user, null, 2));
}
return { success: true, youtube: youtube };
} catch (error) {
console.log("⚠️ Could not update session context:", error.message);
console.log("Proceeding anyway with switched session...");
return { success: true, youtube: youtube };
}
} else {
console.log("❌ Account switch API call failed or no page ID found.");
return { success: false, youtube: null };
}
} else {
await targetChannel.endpoint.call(youtube.actions);
console.log("✅ Account switched successfully!");
// DON'T re-authenticate - use the existing session
console.log("✅ Using the switched session (not re-authenticating to preserve state)");
// Verify the switch
console.log("\n🔍 Verifying account switch...");
const verifyChannels = await getAvailableChannels(youtube);
const nowSelected = verifyChannels.find(ch => ch.is_selected);
console.log(`Currently selected account: ${nowSelected?.name || 'unknown'}`);
console.log(`Target account: ${channelName}`);
if (nowSelected && nowSelected.name.toLowerCase() === channelName.toLowerCase()) {
console.log("✅ Verification successful - using switched account!");
return { success: true, youtube: youtube }; // Return the SAME instance
} else {
console.log("⚠️ WARNING: Account switch verification failed!");
return { success: false, youtube: null };
}
}
} catch (error) {
console.error("❌ Error switching account:", error.message);
console.error("Full error:", error);
// Try alternative method - directly posting to the account switch endpoint
console.log("\n🔄 Trying alternative switching method...");
try {
const payload = targetChannel.endpoint.payload;
if (payload && payload.supportedTokens) {
// Use the actions API to manually send the switch request
const response = await youtube.actions.session.http.fetch('/youtubei/v1/account/account_menu', {
method: 'POST',
body: JSON.stringify({
context: youtube.actions.session.context,
selectActiveIdentityEndpoint: payload
})
});
console.log("Alternative method response:", response);
console.log("✅ Account switched successfully via alternative method!");
// Re-authenticate to get fresh session
console.log("🔄 Re-authenticating with new account context...");
await new Promise(resolve => setTimeout(resolve, 1500));
const newYoutube = await getAuthenticatedInstance(cookie);
return { success: true, youtube: newYoutube };
}
} catch (altError) {
console.error("❌ Alternative method also failed:", altError.message);
}
return { success: false, youtube: null };
}
} else {
console.log("⚠️ No endpoint available to switch account.");
console.log("This account may not support switching via the API.");
return { success: false, youtube: null };
}
} catch (error) {
console.error("❌ Error in switchToChannel:", error.message);
console.error("Full error:", error);
return { success: false, youtube: null };
}
}
/**
* Creates a new playlist and adds the given videos to it.
* @param {Innertube} youtube - An authenticated Innertube instance.
@ -24,15 +280,30 @@ export async function createPlaylistWithVideos(
playlistName,
videoIds
) {
console.log(`\n=== PLAYLIST CREATION DEBUG INFO ===`);
console.log(`Playlist name: "${playlistName}"`);
console.log(`Number of videos to add: ${videoIds.length}`);
console.log(`First 5 video IDs: ${videoIds.slice(0, 5).join(', ')}`);
console.log(`\nCreating new private playlist named: "${playlistName}"...`);
const playlistDetails = await youtube.playlist.create(playlistName, videoIds);
const playlistId = playlistDetails.playlist_id;
try {
const playlistDetails = await youtube.playlist.create(playlistName, videoIds);
console.log(`\n=== PLAYLIST CREATION RESPONSE ===`);
console.log('Full response:', JSON.stringify(playlistDetails, null, 2));
const playlistId = playlistDetails.playlist_id;
console.log(`\nExtracted playlist_id: ${playlistId}`);
console.log(`Playlist "${playlistName}" created successfully.`);
console.log(`Added ${videoIds.length} videos to the new playlist.`);
console.log(
`View it here: https://www.youtube.com/playlist/list=${playlistId}`
);
console.log(`\n✅ Playlist "${playlistName}" created successfully.`);
console.log(`✅ Added ${videoIds.length} videos to the new playlist.`);
console.log(`\n🔗 View it here: https://www.youtube.com/playlist?list=${playlistId}`);
} catch (error) {
console.error(`\n❌ ERROR creating playlist:`);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
throw error;
}
}
/**