AutomationFlowsSocial Media › Multi Yt to Tt

Multi Yt to Tt

Multi YT To TT. Uses googleSheets, httpRequest, youTube. Scheduled trigger; 30 nodes.

Cron / scheduled trigger★★★★★ complexity30 nodesGoogle SheetsHTTP RequestYouTube
Social Media Trigger: Cron / scheduled Nodes: 30 Complexity: ★★★★★ Added:

This workflow follows the Google Sheets → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Multi YT To TT",
  "nodes": [
    {
      "parameters": {
        "triggerTimes": {
          "item": [
            {
              "mode": "everyX",
              "unit": "minutes"
            }
          ]
        }
      },
      "id": "5f1ccd07-1c13-48e1-8d94-e174a0d335c6",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.cron",
      "typeVersion": 1,
      "position": [
        0,
        160
      ]
    },
    {
      "parameters": {
        "jsCode": "// Get channels from HTTP Request node\nconst channels = $input.first().json.body;\n\n// Filter enabled channels\nconst enabledChannels = channels.filter(ch => ch.enabled === true);\n\nif (enabledChannels.length === 0) {\n  throw new Error('No enabled channels found');\n}\n\n// Get current index from items (persistent storage)\nconst items = $input.all();\nlet currentIndex = 0;\n\n// Try to get stored index from previous execution\nif (items[0].json._channelIndex !== undefined) {\n  currentIndex = items[0].json._channelIndex;\n}\n\n// Reset if we've gone through all channels\nif (currentIndex >= enabledChannels.length) {\n  currentIndex = 0;\n}\n\n// Select channel\nconst selectedChannel = enabledChannels[currentIndex];\n\n// Prepare next index for next run\nconst nextIndex = currentIndex + 1;\n\n// Return channel with next index stored\nreturn {\n  json: {\n    ...selectedChannel,\n    _channelIndex: nextIndex\n  }\n};"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        384,
        160
      ],
      "id": "69625797-00ec-42bc-b79a-b2d5f7139f1a",
      "name": "Channel Configuration"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json._stopExecution }}",
              "value2": true
            }
          ]
        }
      },
      "id": "4f8f7cb4-2871-464e-993b-2e7a68cc353b",
      "name": "IF \u2022 Stop Execution?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        592,
        160
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "={{ $json.sheet_id }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $json.sheet_tab_id }}",
          "mode": "id"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        224,
        384
      ],
      "id": "0915adee-e728-4655-8d81-384cd831bca0",
      "name": "Read Channel Sheet",
      "retryOnFail": true,
      "waitBetweenTries": 5000,
      "alwaysOutputData": true,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Merge channel config with sheet data\n// Get channel config DIRECTLY from Channel Configuration (paired through IF node)\n\n// Try to get from Channel Configuration - use all() to get all executions\nlet channelConfig = {};\ntry {\n  const channelItems = $('Channel Configuration').all();\n  if (channelItems && channelItems.length > 0) {\n    // Get the LAST (most recent) execution\n    channelConfig = channelItems[channelItems.length - 1].json;\n  }\n} catch (e) {\n  // Fallback: try getting from static data\n  const staticData = $getWorkflowStaticData('global');\n  channelConfig = staticData.currentRunChannelConfig || {};\n}\n\n// Get all sheet rows\nconst sheetRows = $input.all();\n\n// If sheet is empty, return just the channel config\nif (!sheetRows || sheetRows.length === 0) {\n  return [{\n    json: {\n      ...channelConfig,\n      _sheetEmpty: true\n    }\n  }];\n}\n\n// Merge channel config into each sheet row\nreturn sheetRows.map(row => ({\n  json: {\n    ...row.json,\n    ...channelConfig  // Add all channel config fields to preserve context\n  }\n}));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        400,
        368
      ],
      "id": "59f7a4ac-c96d-4a14-b118-74824bcb9752",
      "name": "Merge Channel Context"
    },
    {
      "parameters": {
        "jsCode": "// Check for pending videos in current channel's sheet\n// Priority: Process pending videos before fetching new ones\n\nconst sheetRows = items || [];\n\n// Get channel config from first item (merged in previous node)\nlet channelConfig = {};\nif (sheetRows.length > 0 && sheetRows[0].json) {\n  const firstRow = sheetRows[0].json;\n  channelConfig = {\n    channel_id: firstRow.channel_id,\n    gmail: firstRow.gmail,\n    channel_name: firstRow.channel_name,\n    youtube_channel_id: firstRow.youtube_channel_id,\n    tiktok_username: firstRow.tiktok_username,\n    youtube_credential_id: firstRow.youtube_credential_id,\n    youtube_credential_name: firstRow.youtube_credential_name,\n    google_credential_id: firstRow.google_credential_id,\n    google_credential_name: firstRow.google_credential_name,\n    sheet_id: firstRow.sheet_id,\n    sheet_tab_id: firstRow.sheet_tab_id,\n    sheet_tab_name: firstRow.sheet_tab_name\n  };\n}\n\n// Find videos with status='processing' (uploaded but not yet processed)\nconst pendingVideos = sheetRows.filter(row => {\n  const status = (row.json.status || '').toLowerCase().trim();\n  return status === 'processing' && row.json.video_id && row.json.mp4_url;\n});\n\nif (pendingVideos.length > 0) {\n  // Return ONLY the first pending video with channel context\n  return [{\n    json: {\n      ...pendingVideos[0].json,\n      has_pending: true,\n      pending_count: pendingVideos.length,\n      flow_type: 'pending'\n    }\n  }];\n}\n\n// No pending videos - signal to fetch new videos from TikTok\nreturn [{\n  json: {\n    ...channelConfig,\n    has_pending: false,\n    fetch_new_videos: true,\n    flow_type: 'new'\n  }\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        576,
        368
      ],
      "id": "44f6a207-e4fa-4a0d-b1bc-d58fb9417240",
      "name": "Check Pending Videos"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.has_pending }}",
              "value2": true
            }
          ]
        }
      },
      "id": "163395f2-4b00-4358-85f6-fa66e94a0062",
      "name": "IF \u2022 Has Pending?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        784,
        368
      ]
    },
    {
      "parameters": {
        "url": "https://www.tikwm.com/api/user/posts",
        "options": {},
        "queryParametersUi": {
          "parameter": [
            {
              "name": "unique_id",
              "value": "={{ $json.tiktok_username }}"
            },
            {
              "name": "count",
              "value": "10"
            }
          ]
        }
      },
      "id": "894a57be-09e3-4e4c-b10c-edcb4764931b",
      "name": "Fetch TikTok Videos",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 1,
      "position": [
        992,
        528
      ],
      "retryOnFail": true,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "functionCode": "// Normalize TikWM response - Process videos from last 72 hours\nconst response = items[0].json || {};\nconst data = response.data || {};\nconst videos = data.videos || [];\nconst user = data.user || {};\n\n// Get channel config from workflow static data\n// Use all() to get ALL executions and take the most recent\nlet channelConfig = {};\ntry {\n  // Use this.getWorkflowStaticData for Function nodes\n  const staticData = this.getWorkflowStaticData('global');\n  channelConfig = staticData.currentRunChannelConfig || {};\n} catch (e) {\n  channelConfig = {};\n}\n\nif (videos.length === 0) {\n  return [{\n    json: {\n      ...channelConfig,\n      no_new_video: true,\n      message: \"No videos found from TikTok user\"\n    }\n  }];\n}\n\n// Calculate timestamp for 72 hours ago\nconst now = Math.floor(Date.now() / 1000);\nconst hours72Ago = now - (72 * 60 * 60);\n\n// Process ALL videos and filter by last 72 hours\nconst processedVideos = videos\n  .map(video => {\n    const createTime = video.create_time || 0;\n    \n    // Skip videos older than 72 hours\n    if (createTime < hours72Ago) {\n      return null;\n    }\n\n    const videoId = video.video_id || video.id || `vid_${Date.now()}_${Math.random()}`;\n    const title = video.title || video.desc || \"Untitled Video\";\n    const caption = video.desc || video.description || video.caption || video.title || \"No caption\";\n    \n    // Username: Try multiple sources (video author object, then user object, then channel config, then fallback)\n    let username = \"unknown_user\";\n    if (video.author && video.author.unique_id) {\n      username = video.author.unique_id;\n    } else if (video.author && video.author.username) {\n      username = video.author.username;\n    } else if (video.author && video.author.nickname) {\n      username = video.author.nickname;\n    } else if (user.unique_id) {\n      username = user.unique_id;\n    } else if (user.username) {\n      username = user.username;\n    } else if (user.nickname) {\n      username = user.nickname;\n    } else if (channelConfig.tiktok_username) {\n      username = channelConfig.tiktok_username;\n    }\n    \n    const mp4Url = video.play || video.download_addr || \"\";\n    const wmMp4Url = video.wmplay || video.play || \"\";\n    const cover = video.cover || video.origin_cover || \"\";\n    const shareUrl = `https://www.tiktok.com/@${username}/video/${videoId}`;\n\n    return {\n      json: {\n        ...channelConfig,  // Include channel config in each video\n        video_id: videoId,\n        title: title,\n        username: username,\n        caption: caption,\n        mp4_url: mp4Url,\n        wm_mp4_url: wmMp4Url,\n        cover: cover,\n        share_url: shareUrl,\n        create_time: createTime,\n        tiktok_upload_time: new Date(createTime * 1000).toLocaleString(\"en-US\", {\n          day: \"2-digit\", month: \"short\", year: \"numeric\",\n          hour: \"2-digit\", minute: \"2-digit\", hour12: true\n        }).replace(\",\", \"\"),\n        no_new_video: false\n      }\n    };\n  })\n  .filter(item => item !== null);\n\nif (processedVideos.length === 0) {\n  return [{\n    json: {\n      ...channelConfig,\n      no_new_video: true,\n      message: \"No videos found from the last 72 hours\"\n    }\n  }];\n}\n\nreturn processedVideos;"
      },
      "id": "8d7e8a00-bbe5-48a9-ac18-08e42fc6ab87",
      "name": "Normalize TikTok Data",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        1200,
        512
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.no_new_video }}"
            }
          ]
        }
      },
      "id": "10af78b2-86ce-49ff-92fa-04f2398252be",
      "name": "IF \u2022 Video Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1392,
        512
      ]
    },
    {
      "parameters": {
        "functionCode": "// Check for duplicates against current channel's sheet\nconst allVideos = $('Normalize TikTok Data').all();\nconst sheetRows = $('Read Channel Sheet').all();\n\n// Create a Set of existing video IDs\nconst existingVideoIds = new Set();\nif (sheetRows.length > 0) {\n  sheetRows.forEach(row => {\n    const videoId = String((row.json.video_id || '').trim());\n    if (videoId) {\n      existingVideoIds.add(videoId);\n    }\n  });\n}\n\n// Process each video and mark as duplicate or new\nconst results = allVideos.map(videoItem => {\n  const videoData = videoItem.json || {};\n  const videoId = String((videoData.video_id || '').trim());\n  \n  // Check if video exists in sheet\n  if (videoId && existingVideoIds.has(videoId)) {\n    const existing = sheetRows.find(r => \n      String((r.json.video_id || '').trim()) === videoId\n    );\n    const existingData = existing ? existing.json : {};\n    \n    return {\n      json: {\n        ...videoData,\n        isDuplicate: true,\n        skipped_reason: (existingData.status === 'done' && existingData.youtube_id) \n          ? 'already uploaded' \n          : 'duplicate video',\n        existing_youtube_id: existingData.youtube_id || '',\n        existing_upload_time: existingData.upload_time || ''\n      }\n    };\n  }\n  \n  // New video\n  return {\n    json: {\n      ...videoData,\n      isDuplicate: false,\n      skipped_reason: null\n    }\n  };\n});\n\n// Return FIRST NEW VIDEO only (process one at a time, will loop back for more)\nconst firstNewVideo = results.find(v => !v.json.isDuplicate);\nif (firstNewVideo) {\n  firstNewVideo.json._hasMoreVideos = results.filter(v => !v.json.isDuplicate).length > 1;\n  return [firstNewVideo];\n}\n\n// All videos are duplicates - mark to move to next channel\nif (results.length > 0) {\n  results[0].json._moveToNextChannel = true;\n  return [results[0]];\n}\n\n// No videos at all\nreturn [{\n  json: {\n    _moveToNextChannel: true,\n    isDuplicate: false,\n    message: \"No videos to check\"\n  }\n}];"
      },
      "id": "0e0ee2e5-431f-40af-a978-241b9dec7f44",
      "name": "Check for Duplicates",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        1600,
        496
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ !$json.isDuplicate }}",
              "value2": "={{ true }}"
            }
          ]
        }
      },
      "id": "114a2b4e-0cdb-484e-812e-fb971658b862",
      "name": "IF \u2022 Not Duplicate?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1792,
        496
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "={{ $json.sheet_id }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $json.sheet_tab_id }}",
          "mode": "id"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "video_id": "={{ $json.video_id }}",
            "title": "={{ $json.title }}",
            "username": "={{ $json.username }}",
            "caption": "={{ $json.caption }}",
            "url": "={{ $json.share_url }}",
            "mp4_url": "={{ $json.mp4_url }}",
            "wm_mp4_url": "={{ $json.wm_mp4_url }}",
            "cover": "={{ $json.cover }}",
            "share_url": "={{ $json.share_url }}",
            "tiktok_upload_time": "={{ $json.tiktok_upload_time }}",
            "status": "processing"
          },
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "82e85849-fdf6-4637-ad61-60942f439235",
      "name": "Append New Video Row",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        2048,
        400
      ],
      "retryOnFail": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Preserve the original video data from IF node\nconst videoData = $('IF \u2022 Not Duplicate?').item.json;\nreturn {json: videoData};"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2240,
        384
      ],
      "id": "ce459bb6-9705-419b-a093-5fc9ea3b96e5",
      "name": "Get Video Data"
    },
    {
      "parameters": {
        "url": "={{ $json.mp4_url || $json.wm_mp4_url }}",
        "options": {
          "timeout": 60000
        }
      },
      "id": "0451af18-6b1e-441a-8501-279a866bbaf5",
      "name": "Download Video",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        976,
        192
      ],
      "retryOnFail": true,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "binaryToPropery",
        "destinationKey": "base64",
        "options": {}
      },
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        1184,
        176
      ],
      "id": "d0cecea7-7d74-4b91-9d81-16412ff77f39",
      "name": "Extract from File"
    },
    {
      "parameters": {
        "operation": "toBinary",
        "sourceProperty": "base64",
        "options": {
          "fileName": "video.mp4",
          "mimeType": "video/mp4"
        }
      },
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [
        1376,
        176
      ],
      "id": "81b5169a-8177-4b1c-94ef-6120fafe7f90",
      "name": "Convert to File"
    },
    {
      "parameters": {
        "jsCode": "// PREPARE YOUTUBE METADATA\n// Retrieve video data from earlier nodes (before binary processing)\n\nfunction removeEmojis(str) {\n  if (!str) return '';\n  return str.replace(\n    /([\\u2700-\\u27BF]|[\\uE000-\\uF8FF]|[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]|\\u24C2|\\uD83D[\\uDC00-\\uDE4F])/g,\n    ''\n  );\n}\n\nfunction removeTikTok(str) {\n  if (!str) return '';\n  return str.replace(/tiktok/gi, '');\n}\n\nfunction sanitize(str, fallback = 'TikTok Video') {\n  if (!str) return fallback;\n  if (str.includes('Log Skipped Video') || str.includes('Update Sheet')) {\n    return fallback;\n  }\n  return str;\n}\n\n// Get video data from earlier nodes\nlet videoData = {};\n\ntry {\n  const getVideoDataItems = $('Get Video Data').all();\n  if (getVideoDataItems && getVideoDataItems.length > 0) {\n    videoData = getVideoDataItems[0].json;\n  }\n} catch (e) {\n  try {\n    const downloadVideoItems = $('Download Video').all();\n    if (downloadVideoItems && downloadVideoItems.length > 0) {\n      videoData = downloadVideoItems[0].json;\n    }\n  } catch (e2) {\n    try {\n      const pendingItems = $('Check Pending Videos').all();\n      if (pendingItems && pendingItems.length > 0) {\n        videoData = pendingItems[0].json;\n      }\n    } catch (e3) {\n      videoData = $json || {};\n    }\n  }\n}\n\n// Title Logic\nlet rawTitle =\n  sanitize(videoData.title) ||\n  sanitize(videoData.caption) ||\n  sanitize(videoData.desc) ||\n  'TikTok Video';\n\nrawTitle = removeTikTok(removeEmojis(rawTitle)).trim();\n\nlet cleanTitle = rawTitle.replace(/#\\w+/g, '').trim();\nif (cleanTitle.length > 90) cleanTitle = cleanTitle.substring(0, 90);\nif (!cleanTitle) cleanTitle = 'TikTok Video Content';\n\nconst yt_title = cleanTitle;\n\n// Description Logic\nlet yt_description = cleanTitle;\nconst caption = videoData.caption;\nif (caption && caption.trim()) {\n  yt_description = removeTikTok(removeEmojis(sanitize(caption))).trim();\n}\nyt_description += `\\n\\nFor more videos like this, don't forget to like, share, and subscribe to our channel!`;\nyt_description += `\\n\\nVideo courtesy of @${videoData.username || 'creator'}`;\nif (videoData.share_url) {\n  yt_description += ` - ${videoData.share_url}`;\n}\nyt_description += `\\nShow some love and follow them!`;\n\n// Binary: ensure video is under \"data\"\nconst bin = $input.first().binary || {};\nconst binKey = Object.keys(bin)[0];\nconst binaryOut = {};\nif (binKey) {\n  binaryOut['data'] = bin[binKey];\n}\n\n// Output with all video data\nreturn [\n  {\n    json: {\n      ...videoData,\n      yt_title,\n      yt_description,\n    },\n    binary: binaryOut,\n  },\n];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1584,
        176
      ],
      "id": "d98b6b80-da1c-4845-a30e-3e2dd2d2baad",
      "name": "Prepare YouTube Metadata"
    },
    {
      "parameters": {
        "jsCode": "// CALCULATE SCHEDULED PUBLISH TIME\n// Per-channel scheduling with 2-3 hours \u00b1 30 min random variation\n\nconst INTERVAL_HOURS = 2;          // Base: 2 hours\nconst INTERVAL_HOURS_MAX = 3;      // Max: 3 hours\nconst RANDOM_MINUTES = 30;         // Random wiggle: \u00b130 minutes\nconst MIN_BUFFER_MINUTES = 30;     // Minimum future buffer\n\nconst staticData = $getWorkflowStaticData(\"global\");\nconst currentChannel = $json.channel_id;\n\n// Helper: Parse various date formats\nfunction tryParseDate(s) {\n  if (!s) return null;\n  let d = new Date(s);\n  if (!isNaN(d.getTime())) return d;\n  let t = String(s).replace(/,/g, ' ').replace(/-/g, ' ').trim();\n  d = new Date(t);\n  if (!isNaN(d.getTime())) return d;\n  return null;\n}\n\n// 1) Get seed date for THIS channel\nlet seedDate = null;\n\n// Try to get last scheduled time from current channel's sheet\ntry {\n  const sheetItems = $items(\"Read Channel Sheet\") || [];\n  let maxDt = null;\n  for (const r of sheetItems) {\n    const ts = r.json && r.json.scheduled_publish_time;\n    if (ts) {\n      const parsed = tryParseDate(ts);\n      if (parsed && !isNaN(parsed.getTime())) {\n        if (!maxDt || parsed > maxDt) maxDt = parsed;\n      }\n    }\n  }\n  if (maxDt) seedDate = maxDt;\n} catch (e) {}\n\n// Fallback to channel-specific staticData\nif (!seedDate && staticData[currentChannel + '_lastScheduled']) {\n  const s = new Date(staticData[currentChannel + '_lastScheduled']);\n  if (!isNaN(s.getTime())) seedDate = s;\n}\n\n// Last resort: now\nif (!seedDate) seedDate = new Date();\n\n// 2) Calculate interval with randomness\nconst randomHours = INTERVAL_HOURS + Math.random() * (INTERVAL_HOURS_MAX - INTERVAL_HOURS);\nlet intervalMs = randomHours * 60 * 60 * 1000;\n\n// Add random minutes wiggle\nif (RANDOM_MINUTES > 0) {\n  const wiggle = Math.floor(Math.random() * (RANDOM_MINUTES * 2 + 1)) - RANDOM_MINUTES;\n  intervalMs += wiggle * 60 * 1000;\n}\n\n// 3) Apply interval\nlet scheduled = new Date(seedDate.getTime() + intervalMs);\n\n// 4) Ensure minimum buffer\nconst minFuture = new Date(Date.now() + MIN_BUFFER_MINUTES * 60 * 1000);\nif (scheduled < minFuture) {\n  scheduled = new Date(minFuture.getTime());\n}\n\n// 5) Format outputs\nconst publishAtIso = scheduled.toISOString();\nconst friendly = scheduled.toLocaleString(\"en-US\", {\n  day: \"2-digit\", month: \"short\", year: \"numeric\",\n  hour: \"2-digit\", minute: \"2-digit\", hour12: true\n}).replace(\",\", \"\");\n\nconst uploadTimeFriendly = new Date().toLocaleString(\"en-US\", {\n  day: \"2-digit\", month: \"short\", year: \"numeric\",\n  hour: \"2-digit\", minute: \"2-digit\", hour12: true\n}).replace(\",\", \"\");\n\n// 6) Store for next run (channel-specific)\ntry {\n  staticData[currentChannel + '_lastScheduled'] = scheduled.toISOString();\n} catch (e) {}\n\n// Output with all video data + scheduling\nreturn [{\n  json: {\n    ...$json,\n    publishAt: publishAtIso,\n    scheduled_publish_time: friendly,\n    upload_time: uploadTimeFriendly\n  },\n  binary: $input.first().binary\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1776,
        176
      ],
      "id": "d37e5f95-c4ec-4ded-9a57-58bdb9b8f02e",
      "name": "Calculate Scheduled Time"
    },
    {
      "parameters": {
        "resource": "video",
        "operation": "upload",
        "title": "={{ $json.yt_title }}",
        "regionCode": "US",
        "categoryId": "22",
        "options": {
          "description": "={{ $json.yt_description }}",
          "privacyStatus": "private",
          "publishAt": "={{ $json.publishAt }}",
          "selfDeclaredMadeForKids": "={{ false }}"
        }
      },
      "type": "n8n-nodes-base.youTube",
      "typeVersion": 1,
      "position": [
        2000,
        160
      ],
      "id": "e0befcaa-441a-40cb-b174-85c3e223bb64",
      "name": "Upload to YouTube",
      "retryOnFail": true,
      "waitBetweenTries": 3000,
      "alwaysOutputData": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "video-id-assignment",
              "name": "video_id",
              "value": "={{ $('Prepare YouTube Metadata').item.json.video_id }}",
              "type": "string"
            },
            {
              "id": "upload-id-assignment",
              "name": "uploadId",
              "value": "={{$json.uploadId}}",
              "type": "string"
            },
            {
              "id": "channel-id-assignment",
              "name": "channel_id",
              "value": "={{ $('Prepare YouTube Metadata').item.json.channel_id }}",
              "type": "string"
            },
            {
              "id": "scheduled-time-assignment",
              "name": "scheduled_publish_time",
              "value": "={{ $('Calculate Scheduled Time').item.json.scheduled_publish_time }}",
              "type": "string"
            },
            {
              "id": "upload-time-assignment",
              "name": "upload_time",
              "value": "={{ $('Calculate Scheduled Time').item.json.upload_time }}",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2320,
        224
      ],
      "id": "8a04a5ae-6c86-43ab-b68b-43db73859706",
      "name": "Edit Fields"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Prepare YouTube Metadata').item.json.sheet_id }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $('Prepare YouTube Metadata').item.json.sheet_tab_id }}",
          "mode": "id"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "youtube_id": "={{ $json.uploadId }}",
            "upload_time": "={{ $json.upload_time }}",
            "scheduled_publish_time": "={{ $json.scheduled_publish_time }}",
            "video_id": "={{ $json.video_id }}",
            "tiktok_upload_time": "={{ $('Prepare YouTube Metadata').item.json.tiktok_upload_time }}",
            "status": "scheduled"
          },
          "matchingColumns": [
            "video_id"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "d9728eb4-d261-43a1-923c-82a6b73047db",
      "name": "Update Sheet (Success)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        2480,
        224
      ],
      "alwaysOutputData": true,
      "retryOnFail": true,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "={{ $json.sheet_id }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $json.sheet_tab_id }}",
          "mode": "id"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "video_id": "={{ $json.video_id }}",
            "skipped_reason": "={{ $json.skipped_reason }}",
            "row_number": 0
          },
          "matchingColumns": [
            "video_id"
          ],
          "schema": [
            {
              "id": "video_id",
              "displayName": "video_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "username",
              "displayName": "username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "caption",
              "displayName": "caption",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "url",
              "displayName": "url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "mp4_url",
              "displayName": "mp4_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "wm_mp4_url",
              "displayName": "wm_mp4_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "cover",
              "displayName": "cover",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "share_url",
              "displayName": "share_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "youtube_id",
              "displayName": "youtube_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "upload_time",
              "displayName": "upload_time",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "skipped_reason",
              "displayName": "skipped_reason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": " tiktok_upload_time",
              "displayName": " tiktok_upload_time",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "scheduled_publish_time",
              "displayName": "scheduled_publish_time",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": " last_error",
              "displayName": " last_error",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "error_time",
              "displayName": "error_time",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true,
              "readOnly": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "436f42c0-5bfc-401b-a31e-15ad31e1672a",
      "name": "Log Skipped Video",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        2048,
        592
      ],
      "retryOnFail": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "={{ $json.sheet_id }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $json.sheet_tab_id }}",
          "mode": "id"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "video_id": "={{ $json.video_id }}",
            "last_error": "={{ $json.error.message || 'Upload failed' }}",
            "error_time": "={{ $now.toLocaleString('en-US', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: true }).replace(',', '') }}",
            "status": "error"
          },
          "matchingColumns": [
            "video_id"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "f58eb598-498e-4318-82f8-6c6b5de0c35f",
      "name": "Log Error to Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        2320,
        64
      ],
      "retryOnFail": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Signal to move to next channel - just pass the channel_id\nconst inputData = $input.item.json;\n\nreturn {\n  json: {\n    channel_id: inputData.channel_id,\n    _moveToNextChannel: true\n  }\n};"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2256,
        592
      ],
      "id": "29ed19ad-2917-4641-98ab-d65d905bcbb6",
      "name": "Signal Next Channel"
    },
    {
      "parameters": {
        "jsCode": "// Signal to move to next channel after error - just pass channel_id\nconst inputData = $input.item.json;\n\nreturn {\n  json: {\n    channel_id: inputData.channel_id,\n    _moveToNextChannel: true\n  }\n};"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2528,
        64
      ],
      "id": "ed9efe6c-793c-4e47-97da-208a628ad6d2",
      "name": "Signal After Error"
    },
    {
      "parameters": {
        "jsCode": "// Signal to move to next channel when no videos - just pass channel_id\nconst inputData = $input.item.json;\n\nreturn {\n  json: {\n    channel_id: inputData.channel_id,\n    _moveToNextChannel: true\n  }\n};"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1616,
        608
      ],
      "id": "f9beec1a-0c43-43f3-aaf1-8f377c79d3cc",
      "name": "Signal No Videos"
    },
    {
      "parameters": {
        "jsCode": "// Signal to move to next channel after successful upload - just pass channel_id\nconst inputData = $input.item.json;\n\nreturn {\n  json: {\n    channel_id: inputData.channel_id,\n    _moveToNextChannel: true\n  }\n};"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2688,
        224
      ],
      "id": "6ac8afc1-3c7e-4495-8331-e5c621c10494",
      "name": "Signal After Success"
    },
    {
      "parameters": {
        "jsCode": "// DEBUG: Show static data values\nconst staticData = $getWorkflowStaticData(\"global\");\n\nreturn [{\n  json: {\n    channelsCheckedInCurrentRun: staticData.channelsCheckedInCurrentRun || 0,\n    lastChannelIndex: staticData.lastChannelIndex || -1,\n    currentRunChannelConfig: staticData.currentRunChannelConfig || {},\n    all_static_data: staticData\n  }\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "id": "2b47df87-9056-4a56-a0b6-e52f35fd4250",
      "name": "DEBUG: Static Data"
    },
    {
      "parameters": {
        "url": "http://localhost:3003/api/channels",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        208,
        160
      ],
      "id": "99df2a90-25cd-4c7e-a251-141fb70b353b",
      "name": "Get Channels from Dashboard"
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get Channels from Dashboard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Channel Configuration": {
      "main": [
        [
          {
            "node": "IF \u2022 Stop Execution?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Channel Sheet": {
      "main": [
        [
          {
            "node": "Merge Channel Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Channel Context": {
      "main": [
        [
          {
            "node": "Check Pending Videos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Pending Videos": {
      "main": [
        [
          {
            "node": "IF \u2022 Has Pending?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF \u2022 Has Pending?": {
      "main": [
        [
          {
            "node": "Download Video",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch TikTok Videos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch TikTok Videos": {
      "main": [
        [
          {
            "node": "Normalize TikTok Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize TikTok Data": {
      "main": [
        [
          {
            "node": "IF \u2022 Video Found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF \u2022 Video Found?": {
      "main": [
        [
          {
            "node": "Check for Duplicates",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Signal No Videos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Duplicates": {
      "main": [
        [
          {
            "node": "IF \u2022 Not Duplicate?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF \u2022 Not Duplicate?": {
      "main": [
        [
          {
            "node": "Append New Video Row",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Skipped Video",
            "type": "main",
            "index": 0
          },
          {
            "node": "Signal Next Channel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append New Video Row": {
      "main": [
        [
          {
            "node": "Get Video Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Video Data": {
      "main": [
        [
          {
            "node": "Download Video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Video": {
      "main": [
        [
          {
            "node": "Extract from File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract from File": {
      "main": [
        [
          {
            "node": "Convert to File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert to File": {
      "main": [
        [
          {
            "node": "Prepare YouTube Metadata",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare YouTube Metadata": {
      "main": [
        [
          {
            "node": "Calculate Scheduled Time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Scheduled Time": {
      "main": [
        [
          {
            "node": "Upload to YouTube",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to YouTube": {
      "main": [
        [
          {
            "node": "Edit Fields",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to Sheet",
            "type": "main",
            "index": 0
          },
          {
            "node": "Signal After Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Edit Fields": {
      "main": [
        [
          {
            "node": "Update Sheet (Success)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF \u2022 Stop Execution?": {
      "main": [
        [],
        [
          {
            "node": "Read Channel Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signal Next Channel": {
      "main": [
        [
          {
            "node": "Channel Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Sheet (Success)": {
      "main": [
        [
          {
            "node": "Signal After Success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signal After Error": {
      "main": [
        [
          {
            "node": "Channel Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signal No Videos": {
      "main": [
        [
          {
            "node": "Channel Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signal After Success": {
      "main": [
        [
          {
            "node": "Channel Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Channels from Dashboard": {
      "main": [
        [
          {
            "node": "Channel Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "021a484e-09ce-4a9d-b7c7-d1488b0111d8",
  "id": "XRv21vcQImTnjOrn",
  "tags": []
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Multi YT To TT. Uses googleSheets, httpRequest, youTube. Scheduled trigger; 30 nodes.

Source: https://gist.github.com/lichi20251-ctrl/6251b44fa66c6a63b2575e14fb391c75 — original creator credit. Request a take-down →

More Social Media workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Social Media

Are you a cord-cutter? Do you find yourself looking through the many titles of videos uploaded to Youtube, just to find the ones you want to watch? Even when you subscribe to the channels you like, do

Google Sheets, HTTP Request, YouTube +1
Social Media

YouTube AI analys. Uses youTube, httpRequest, googleSheets, lmChatOpenRouter. Scheduled trigger; 55 nodes.

YouTube, HTTP Request, Google Sheets +2
Social Media

AI YouTube transcript. Uses youTube, googleSheets, lmChatOpenRouter, chainLlm. Scheduled trigger; 26 nodes.

YouTube, Google Sheets, OpenRouter Chat +3
Social Media

YouTube AI Analys (modified) + community node transcript. Uses youTube, googleSheets, lmChatOpenRouter, chainLlm. Scheduled trigger; 26 nodes.

YouTube, Google Sheets, OpenRouter Chat +3
Social Media

YouTube AI Analys (modified). Uses youTube, httpRequest, googleSheets, lmChatOpenRouter. Scheduled trigger; 24 nodes.

YouTube, HTTP Request, Google Sheets +2