{
  "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": []
}