From c17c60c0491f24267ff85dad78c3bb5cc225b464 Mon Sep 17 00:00:00 2001 From: T Date: Wed, 10 Dec 2025 01:01:50 -0800 Subject: [PATCH] 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. --- src/js/index.js | 121 ++++++++++++++++- src/js/youtubeService.js | 287 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 394 insertions(+), 14 deletions(-) diff --git a/src/js/index.js b/src/js/index.js index 0b2a5e6..2417a53 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -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) { diff --git a/src/js/youtubeService.js b/src/js/youtubeService.js index 1ee70ef..95c76e0 100644 --- a/src/js/youtubeService.js +++ b/src/js/youtubeService.js @@ -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} 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; + } } /**