{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "1273ffea-39d3-42cc-bcb1-abd403ea3bd7",
      "name": "Extract YouTube URL",
      "type": "n8n-nodes-base.code",
      "position": [
        -576,
        336
      ],
      "parameters": {
        "jsCode": "// Extract YouTube URL from Discord message\nconst message = $input.first().json;\nconst messageContent = message.content;\n\nif (!messageContent) {\n  return [];\n}\n\n// Match youtube.com/watch?v= and youtu.be/ formats\nconst ytRegex = /(?:https?:\\/\\/)?(?:www\\.)?(?:youtube\\.com\\/(?:watch\\?v=|live\\/|shorts\\/)|youtu\\.be\\/)([a-zA-Z0-9_-]{11})(?:[&?][^\\s]*)*/;\nconst match = messageContent.match(ytRegex);\n\nif (!match) {\n  return [{\n    json: {\n      is_youtube: false,\n      channel_id: message.channelId\n    }\n  }];\n}\n\nconst videoId = match[1];\nconst videoUrl = `https://www.youtube.com/watch?v=${videoId}`;\n\nreturn [{\n  json: {\n    is_youtube: true,\n    video_id: videoId,\n    video_url: videoUrl,\n    discord_message: messageContent,\n    discord_shared_at: new Date().toISOString(),\n    channel_id: message.channelId\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "610f37f3-d1c9-491f-9fa9-eda1af30e9d6",
      "name": "Discord Trigger",
      "type": "n8n-nodes-discord-trigger.discordTrigger",
      "position": [
        -800,
        336
      ],
      "parameters": {
        "pattern": "every",
        "guildIds": [
          "YOUR_GUILD_ID_HERE"
        ],
        "channelIds": [
          "YOUR_CHANNEL_ID_HERE"
        ],
        "additionalFields": {}
      },
      "credentials": {
        "discordBotTriggerApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "9e31fe2d-41e3-4405-8340-4a63650ea96d",
      "name": "Is YouTube URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        -32,
        336
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "condition-yt-check",
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.is_youtube }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "3a55d9c6-c8b5-4170-be43-5d9b19c82b4d",
      "name": "yt-dlp Get Metadata",
      "type": "n8n-nodes-base.executeCommand",
      "position": [
        192,
        240
      ],
      "parameters": {
        "command": "=yt-dlp --js-runtime node --remote-components ejs:github --cookies /home/node/.n8n/cookies.txt --write-auto-sub --sub-lang \"en.*,vi.*\" --skip-download -o \"/tmp/yt_%(id)s\" \"{{ $node[\"Extract YouTube URL\"].json[\"video_url\"] }}\" 2>/dev/null; yt-dlp --js-runtime node --remote-components ejs:github --cookies /home/node/.n8n/cookies.txt --print \"{\\\"id\\\":%(id)#j,\\\"title\\\":%(title)#j,\\\"description\\\":%(description)#j,\\\"view_count\\\":%(view_count)s,\\\"channel\\\":%(channel)#j,\\\"upload_date\\\":%(upload_date)#j,\\\"duration\\\":%(duration)s}\" --skip-download \"{{ $node[\"Extract YouTube URL\"].json[\"video_url\"] }}\""
      },
      "typeVersion": 1
    },
    {
      "id": "6121252c-8e1f-446b-9b00-48039ed9eaaa",
      "name": "Parse Metadata",
      "type": "n8n-nodes-base.code",
      "position": [
        416,
        240
      ],
      "parameters": {
        "jsCode": "const rawOutput = $input.first().json.stdout;\n\nif (!rawOutput) {\n  throw new Error('yt-dlp returned no output \u2014 video may be private, deleted, or geo-blocked');\n}\n\n// Decode Unicode escapes from JSON string values\nfunction decodeUnicode(str) {\n  try { return JSON.parse('\"' + str + '\"'); } catch (e) { return str; }\n}\n\n// Extract fields using regex since description may break JSON\nconst getId = rawOutput.match(/\"id\":\"([^\"]+)\"/);\nconst getTitle = rawOutput.match(/\"title\":\"([^\"]+)\"/);\nconst getChannel = rawOutput.match(/\"channel\":\"([^\"]+)\"/);\nconst getDate = rawOutput.match(/\"upload_date\":\"([^\"]+)\"/);\nconst getDuration = rawOutput.match(/\"duration\":(\\d+)/);\nconst getViews = rawOutput.match(/\"view_count\":(\\d+)/);\n\nif (!getId) throw new Error('MISSING: video id not found');\nif (!getTitle) throw new Error('MISSING: title not found');\nif (!getChannel) throw new Error('MISSING: channel not found');\nif (!getDate) throw new Error('MISSING: upload_date not found');\nif (!getDuration) throw new Error('MISSING: duration not found');\nif (!getViews) throw new Error('MISSING: view_count not found');\n\n// Extract description: match up to the next known key\nconst descMatch = rawOutput.match(/\"description\":\"([\\s\\S]*?)\",\"view_count\"/);\nconst description = descMatch ? decodeUnicode(descMatch[1]) : '';\n\nconst prevData = $('Extract YouTube URL').first().json;\n\nreturn [{\n  json: {\n    video_id: getId[1],\n    title: decodeUnicode(getTitle[1]),\n    channel: decodeUnicode(getChannel[1]),\n    upload_date: getDate[1],\n    duration: parseInt(getDuration[1]),\n    view_count: parseInt(getViews[1]),\n    description: description,\n    thumbnail_url: `https://img.youtube.com/vi/${getId[1]}/maxresdefault.jpg`,\n    video_url: prevData.video_url,\n    discord_shared_at: prevData.discord_shared_at,\n    channel_id: prevData.channel_id,\n    video_id_for_subs: getId[1]\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "fd999ffd-80b4-4248-bf9a-77247fe5d9f8",
      "name": "Read Subtitle File",
      "type": "n8n-nodes-base.executeCommand",
      "onError": "continueRegularOutput",
      "position": [
        640,
        240
      ],
      "parameters": {
        "command": "=FILE=$(ls /tmp/yt_{{ $json.video_id }}.*.vtt 2>/dev/null | head -1); [ -n \"$FILE\" ] && cat \"$FILE\" || echo \"\""
      },
      "typeVersion": 1
    },
    {
      "id": "f77139fe-ebad-4c6c-9d63-55561a004918",
      "name": "Parse Transcript",
      "type": "n8n-nodes-base.code",
      "position": [
        864,
        240
      ],
      "parameters": {
        "jsCode": "const input = $input.first().json;\nconst prevData = $('Parse Metadata').first().json;\n\n// Check if Read Subtitle File failed (continueOnFail)\nif (input.error || !input.stdout || input.stdout.trim().length === 0) {\n  throw new Error('No subtitles available for video: ' + prevData.video_id);\n}\n\nconst vttContent = input.stdout;\nconst lines = vttContent.split('\\n');\nconst textLines = [];\nlet lastLine = \"\";\n\nfor (let line of lines) {\n    let trimmed = line.trim();\n    \n    // Skip VTT metadata and timestamps\n    if (!trimmed || \n        trimmed === 'WEBVTT' || \n        trimmed.startsWith('Kind:') || \n        trimmed.startsWith('Language:') || \n        /^(\\d{2}:)?\\d{2}:\\d{2}\\.\\d{3}/.test(trimmed) || \n        trimmed.includes('-->')) {\n        continue;\n    }\n\n    // Remove HTML-like tags (styling)\n    const cleaned = trimmed.replace(/<[^>]+>/g, '').trim();\n    \n    // Prevent duplicate adjacent lines (common in VTT)\n    if (cleaned && cleaned !== lastLine) {\n        textLines.push(cleaned);\n        lastLine = cleaned;\n    }\n}\n\nconst transcript = textLines.join(' ');\n\nreturn [{\n    json: {\n        ...prevData,\n        transcript: transcript\n    }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "26965400-999e-4dea-bdf1-c8190da03f5f",
      "name": "Message a model",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "position": [
        1088,
        240
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-2.5-flash",
          "cachedResultName": "models/gemini-2.5-flash"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=Please summarize this YouTube video.\n\nVideo Title: {{ $json.title }}\nChannel: {{ $json.channel }}\n\nTranscript:\n{{ $json.transcript }}\n\nIMPORTANT: Write the summary in the SAME LANGUAGE as the transcript. Do not translate to English.\n\nProvide a concise summary of the video. Format your response exactly as follows:\n\n**TLDR**\n[Write a single paragraph, 3-4 sentence summary of the main idea here]\n\n**Summary**\n[Write a detailed 3-5 paragraph summary capturing key points, arguments, and conclusions here]\n\nWrite in clear, informative prose. No bullet points.\n"
            }
          ]
        }
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "f0067574-707f-46b5-a725-8988539c20da",
      "name": "Prepare Insert Data",
      "type": "n8n-nodes-base.code",
      "position": [
        1440,
        240
      ],
      "parameters": {
        "jsCode": "const currentData = $('Parse Transcript').first().json;\nconst geminiOutput = $input.first().json;\n\nconst summary = geminiOutput.content.parts[0].text;\n\nif (!summary) {\n  throw new Error('MISSING: Gemini returned no summary text');\n}\n\nreturn [{\n  json: {\n    video_id: currentData.video_id,\n    title: currentData.title,\n    channel: currentData.channel,\n    upload_date: currentData.upload_date,\n    duration: currentData.duration,\n    view_count: currentData.view_count,\n    description: currentData.description,\n    transcript: currentData.transcript,\n    ai_summary: summary,\n    thumbnail_url: currentData.thumbnail_url,\n    discord_shared_at: currentData.discord_shared_at,\n    channel_id: currentData.channel_id\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "b9f7473c-2bc1-4337-8efb-4b555cc8439c",
      "name": "Save to Supabase",
      "type": "n8n-nodes-base.supabase",
      "position": [
        1664,
        240
      ],
      "parameters": {
        "tableId": "videos",
        "dataToSend": "=autoMapInputData"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "5606a6df-0d30-4b1f-896f-5492ed37d048",
      "name": "Prepare Success Log",
      "type": "n8n-nodes-base.code",
      "position": [
        1888,
        240
      ],
      "parameters": {
        "jsCode": "const data = $('Prepare Insert Data').first().json;\n\nreturn [{\n  json: {\n    video_id: data.video_id,\n    process_status: 'success',\n    error_type: null,\n    notes: null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "d94f1688-aed4-4cfa-8553-77519c45fa17",
      "name": "Log Run",
      "type": "n8n-nodes-base.supabase",
      "position": [
        2112,
        240
      ],
      "parameters": {
        "tableId": "runs",
        "dataToSend": "autoMapInputData"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "e932de7a-bc59-4523-bd96-81b8855e4cc7",
      "name": "Discord Reply",
      "type": "n8n-nodes-base.discord",
      "position": [
        2528,
        240
      ],
      "parameters": {
        "content": "={{ $json.chunk }}",
        "guildId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GUILD_ID_HERE",
          "cachedResultUrl": "",
          "cachedResultName": "Your Server"
        },
        "options": {},
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_CHANNEL_ID_HERE",
          "cachedResultUrl": "",
          "cachedResultName": "your-channel"
        }
      },
      "credentials": {
        "discordBotApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "ac5c0018-638c-4c15-9138-ce6989427dae",
      "name": "Discord Not YouTube Reply",
      "type": "n8n-nodes-base.discord",
      "position": [
        192,
        432
      ],
      "parameters": {
        "content": "That doesn't look like a YouTube link. Please share a valid YouTube URL.",
        "guildId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GUILD_ID_HERE",
          "cachedResultUrl": "",
          "cachedResultName": "Your Server"
        },
        "options": {},
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_CHANNEL_ID_HERE",
          "cachedResultUrl": "",
          "cachedResultName": "your-channel"
        }
      },
      "credentials": {
        "discordBotApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "6516263a-56ab-4892-9a54-7699850fda00",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "position": [
        -800,
        656
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "8fa011ac-baa2-43d0-9ed6-a731d5532d62",
      "name": "Prepare Error Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -576,
        656
      ],
      "parameters": {
        "jsCode": "const errorData = $input.first().json;\nconst errorMessage = errorData.execution?.error?.message || 'Unknown error';\nconst errorNode = errorData.execution?.error?.node?.name || 'Unknown node';\n\n// Determine error_type based on failing node\nlet errorType = 'unknown';\nif (errorNode.includes('yt-dlp') || errorNode === 'Parse Metadata') {\n  errorType = 'yt_dlp_failed';\n} else if (errorNode === 'Parse Transcript') {\n  errorType = errorMessage.includes('No subtitles') ? 'no_subtitles' : 'transcript_error';\n} else if (errorNode.includes('model') || errorNode.includes('Gemini')) {\n  errorType = 'gemini_error';\n} else if (errorNode.includes('Supabase') || errorNode.includes('Insert')) {\n  errorType = 'supabase_error';\n} else if (errorNode.includes('Prepare')) {\n  errorType = 'gemini_error';\n}\n\n// Try to extract video_id from error message\nconst vidMatch = errorMessage.match(/(?:video:\\s*|video_id:\\s*|yt_)([a-zA-Z0-9_-]{11})/);\nconst videoId = vidMatch ? vidMatch[1] : 'unknown';\n\nreturn [{\n  json: {\n    video_id: videoId,\n    process_status: 'error',\n    error_type: errorType,\n    notes: `[${errorNode}] ${errorMessage}`\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "1c208dab-6014-4835-9a68-00a9e721a6bd",
      "name": "Log Run Error",
      "type": "n8n-nodes-base.supabase",
      "position": [
        -32,
        656
      ],
      "parameters": {
        "tableId": "runs",
        "dataToSend": "autoMapInputData"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "6ddf1a0a-ed65-45de-b758-3f8040cc026d",
      "name": "Discord Error Reply",
      "type": "n8n-nodes-base.discord",
      "position": [
        192,
        656
      ],
      "parameters": {
        "content": "=Error processing video: {{ $('Prepare Error Data').first().json.notes }}",
        "guildId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GUILD_ID_HERE",
          "cachedResultUrl": "",
          "cachedResultName": "Your Server"
        },
        "options": {},
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_CHANNEL_ID_HERE",
          "cachedResultUrl": "",
          "cachedResultName": "your-channel"
        }
      },
      "credentials": {
        "discordBotApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "6dcab5da-8423-482c-b6d9-ffee1fa6c9a4",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1408,
        -64
      ],
      "parameters": {
        "color": 4,
        "width": 496,
        "height": 892,
        "content": "# YouTube Video Summarizer \u2014 Discord Bot\n\n## Who is this for?\nTeams or creators who want to automatically summarize YouTube videos shared in a Discord channel.\n\n## What does it do?\n- Listens for YouTube links posted in a Discord channel\n- Downloads subtitles and metadata via yt-dlp\n- Summarizes the transcript using Gemini 2.5 Flash\n- Saves the full data (metadata + transcript + summary) to Supabase\n- Replies in Discord with a summary preview\n- Logs every run (success or error) to a `runs` table\n- Replies with helpful error messages when something goes wrong\n\n## Setup Requirements\n1. **Discord Bot** with message read + send permissions\n2. **yt-dlp** installed in the n8n container (with cookies.txt)\n3. **Google Gemini API Key** (Gemini 2.5 Flash)\n4. **Supabase** project with `videos` and `runs` tables\n\n## Quick Start\n1. Import the workflow into n8n\n2. Configure Discord Bot Trigger + Discord Bot credentials\n3. Configure Gemini API credential\n4. Configure Supabase credential\n5. Create `videos` and `runs` tables in Supabase\n6. Update Discord guild/channel IDs to match your server\n7. Activate the workflow\n8. Post a YouTube link in your Discord channel!"
      },
      "typeVersion": 1
    },
    {
      "id": "48bfa715-4fed-473f-b804-ba0f735ec43b",
      "name": "Sticky Note - Trigger",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -832,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 228,
        "content": "## 1. Trigger & URL Detection\n\nListens for every message in the configured Discord channel and checks if it contains a YouTube URL.\n\n**How it works:**\n1. Discord Trigger fires on every new message\n2. Code node extracts YouTube video ID via RegEx\n3. IF node routes: YouTube URL \u2192 processing, other messages \u2192 friendly reply"
      },
      "typeVersion": 1
    },
    {
      "id": "f6446181-cbbe-470a-b654-fc8274e71379",
      "name": "Sticky Note - Video Data Extraction",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -16,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 256,
        "content": "## 2. Video Data Extraction\n\nRuns yt-dlp to download subtitles and fetch video metadata in a single shell command.\n\n**How it works:**\n1. yt-dlp downloads auto-generated English subtitles (.vtt) to /tmp\n2. yt-dlp prints metadata JSON (title, channel, views, duration)\n3. Parse Metadata extracts fields via RegEx (handles broken JSON from descriptions)"
      },
      "typeVersion": 1
    },
    {
      "id": "dfb5a5b4-921a-44b8-863a-f0a34ef284f6",
      "name": "Sticky Note - Transcript Processing",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        528,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 240,
        "content": "## 3. Transcript Processing\n\nReads the .vtt subtitle file and cleans it into plain text.\n\n**How it works:**\n1. `cat` reads the VTT file (continueOnFail enabled)\n2. Parse Transcript strips timestamps, HTML tags, and deduplicates lines\n3. If no subtitles exist, throws a descriptive error caught by Error Trigger"
      },
      "typeVersion": 1
    },
    {
      "id": "116e832f-4c11-451c-a3bb-66680d3b7f03",
      "name": "Sticky Note - AI Summarization",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1088,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 536,
        "height": 240,
        "content": "## 4. AI Summarization\n\nSends the clean transcript to Gemini 2.5 Flash for a concise summary.\n\n**How it works:**\n1. Gemini receives title, channel, and full transcript\n2. Returns 3-5 paragraphs of prose (no bullet points)\n3. Prepare Insert Data merges summary with all metadata fields"
      },
      "typeVersion": 1
    },
    {
      "id": "25b52b6c-db1c-4439-9e23-9481690c3837",
      "name": "Sticky Note - Save & Notify",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1712,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 900,
        "height": 240,
        "content": "## 5. Save & Notify\n\nPersists data to Supabase and replies in Discord.\n\n**How it works:**\n1. Save to Supabase inserts all fields into the `videos` table\n2. Prepare Success Log builds a run record (status: success)\n3. Log Run inserts into the `runs` table\n4. Discord Reply posts a summary preview (title, duration, views, first 500 chars)"
      },
      "typeVersion": 1
    },
    {
      "id": "646554d2-c672-415b-a33a-1da62f3ed27d",
      "name": "Sticky Note - Error Handling",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -816,
        832
      ],
      "parameters": {
        "color": 7,
        "width": 740,
        "height": 256,
        "content": "## 6. Error Handling\n\nCatches any workflow crash and replies in Discord with the error details.\n\n**How it works:**\n1. Error Trigger fires on any unhandled node failure\n2. Prepare Error Data classifies the error type (yt_dlp_failed, no_subtitles, gemini_error, supabase_error)\n3. Log Run Error saves the error to the `runs` table\n4. Discord Error Reply posts the error message to the channel"
      },
      "typeVersion": 1
    },
    {
      "id": "eb2d6b48-a92c-4b0e-a778-e16fbe698b57",
      "name": "Sticky Note - Discord Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -800,
        1120
      ],
      "parameters": {
        "color": 3,
        "width": 440,
        "height": 300,
        "content": "### Discord Bot Setup\n\n1. Go to [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create a new Application \u2192 Bot\n3. Enable **Message Content Intent** under Privileged Intents\n4. Copy the Bot Token\n5. Invite bot to your server with Send Messages + Read Messages permissions\n6. In n8n: Create **Discord Bot Trigger** credential (for listening)\n7. Create **Discord Bot** credential (for sending replies)\n8. Update guild ID and channel ID in Trigger + Reply nodes"
      },
      "typeVersion": 1
    },
    {
      "id": "4c21a5a4-694b-4ca8-bbb1-83c9cf42915f",
      "name": "Sticky Note - Gemini Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1088,
        496
      ],
      "parameters": {
        "color": 3,
        "width": 400,
        "height": 240,
        "content": "### Gemini API Setup\n\n1. Go to [Google AI Studio](https://aistudio.google.com/apikey)\n2. Click **Create API Key**\n3. Copy the key\n4. In n8n: Click the Gemini node \u2192 Credential \u2192 Create New\n5. Paste your API key and save\n6. Model: `gemini-2.5-flash` (default)"
      },
      "typeVersion": 1
    },
    {
      "id": "447e0689-b5bc-4f93-aea4-e6a27a977d3c",
      "name": "Sticky Note - Supabase Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        496
      ],
      "parameters": {
        "color": 3,
        "width": 440,
        "height": 480,
        "content": "### Supabase Setup\n\n1. Create a project at [supabase.com](https://supabase.com)\n2. Go to Settings \u2192 API \u2192 copy the **URL** and **anon key**\n3. In n8n: Create Supabase credential with URL + API key\n4. Run the SQL below to create required tables:\n\n```sql\nCREATE TABLE videos (\n  video_id TEXT PRIMARY KEY,\n  title TEXT, channel TEXT,\n  upload_date TEXT, duration INT,\n  view_count INT, description TEXT,\n  transcript TEXT, ai_summary TEXT,\n  thumbnail_url TEXT,\n  discord_shared_at TIMESTAMPTZ,\n  channel_id TEXT\n);\n\nCREATE TABLE runs (\n  video_id TEXT PRIMARY KEY,\n  process_status TEXT NOT NULL,\n  error_type TEXT, notes TEXT,\n  date_added TIMESTAMPTZ DEFAULT now()\n);\n```"
      },
      "typeVersion": 1
    },
    {
      "id": "b32a9d85-67a9-4217-984c-603702b292c0",
      "name": "Prepare Messages for Discord",
      "type": "n8n-nodes-base.code",
      "position": [
        2320,
        240
      ],
      "parameters": {
        "jsCode": "// Get the full summary and title from the previous node\nconst summary = $('Prepare Insert Data').first().json.ai_summary || \"No summary available\";\nconst data = $('Prepare Insert Data').first().json;\nconst discordLimit = 2000; // Discord max message length\nconst safeLimit = 1900; // Leave some buffer\nconst chunks = [];\n\n// Create the header for the very first message\nconst header = `Video saved! **${data.title}** by **${data.channel}** | Duration: ${Math.floor(data.duration / 60)}m ${data.duration % 60}s | ${data.view_count} views ---\\n\\n`;\n\nlet remainingText = summary;\n\n// Loop through the text and break it into chunks intelligently\nwhile (remainingText.length > 0) {\n    // For the first chunk, reduce max length by the header size\n    const isFirstChunk = chunks.length === 0;\n    const maxLength = isFirstChunk ? safeLimit - header.length : safeLimit;\n\n    if (remainingText.length <= maxLength) {\n        // If the remaining text fits, add it and break\n        chunks.push({ json: { chunk: (isFirstChunk ? header : \"\") + remainingText } });\n        break;\n    }\n\n    // Find the last space within the limit to avoid breaking a word\n    let splitIndex = remainingText.lastIndexOf(\" \", maxLength);\n    \n    // If no space is found, split strictly at maxLength (fallback for long words)\n    if (splitIndex === -1) {\n        splitIndex = maxLength;\n    }\n\n    let chunkText = remainingText.substring(0, splitIndex);\n    \n    // Prepend the header only to the first chunk\n    if (isFirstChunk) {\n        chunkText = header + chunkText;\n    }\n\n    chunks.push({ json: { chunk: chunkText } });\n    \n    // Update remaining text for the next iteration\n    remainingText = remainingText.substring(splitIndex).trim();\n}\n\nreturn chunks;"
      },
      "typeVersion": 2
    }
  ],
  "connections": {
    "Log Run": {
      "main": [
        [
          {
            "node": "Prepare Messages for Discord",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Prepare Error Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Run Error": {
      "main": [
        [
          {
            "node": "Discord Error Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Metadata": {
      "main": [
        [
          {
            "node": "Read Subtitle File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Discord Trigger": {
      "main": [
        [
          {
            "node": "Extract YouTube URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is YouTube URL?": {
      "main": [
        [
          {
            "node": "yt-dlp Get Metadata",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Discord Not YouTube Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Message a model": {
      "main": [
        [
          {
            "node": "Prepare Insert Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Transcript": {
      "main": [
        [
          {
            "node": "Message a model",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save to Supabase": {
      "main": [
        [
          {
            "node": "Prepare Success Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Error Data": {
      "main": [
        [
          {
            "node": "Log Run Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Subtitle File": {
      "main": [
        [
          {
            "node": "Parse Transcript",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract YouTube URL": {
      "main": [
        [
          {
            "node": "Is YouTube URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Insert Data": {
      "main": [
        [
          {
            "node": "Save to Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Success Log": {
      "main": [
        [
          {
            "node": "Log Run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "yt-dlp Get Metadata": {
      "main": [
        [
          {
            "node": "Parse Metadata",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Messages for Discord": {
      "main": [
        [
          {
            "node": "Discord Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}