{ "nodes": [ { "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}];" }, "id": "1273ffea-39d3-42cc-bcb1-abd403ea3bd7", "name": "Extract YouTube URL", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -576, 336 ] }, { "parameters": { "guildIds": [ "YOUR_GUILD_ID_HERE" ], "channelIds": [ "YOUR_CHANNEL_ID_HERE" ], "pattern": "every", "additionalFields": {} }, "type": "n8n-nodes-discord-trigger.discordTrigger", "typeVersion": 1, "position": [ -800, 336 ], "id": "610f37f3-d1c9-491f-9fa9-eda1af30e9d6", "name": "Discord Trigger", "credentials": { "discordBotTriggerApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Discord Bot Trigger account" } } }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 1 }, "conditions": [ { "id": "condition-yt-check", "leftValue": "={{ $json.is_youtube }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ -32, 336 ], "id": "9e31fe2d-41e3-4405-8340-4a63650ea96d", "name": "Is YouTube URL?" }, { "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\"] }}\"" }, "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 192, 240 ], "id": "3a55d9c6-c8b5-4170-be43-5d9b19c82b4d", "name": "yt-dlp Get Metadata" }, { "parameters": { "jsCode": "const rawOutput = $input.first().json.stdout;\n\nif (!rawOutput) {\n throw new Error('yt-dlp returned no output — 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}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 416, 240 ], "id": "6121252c-8e1f-446b-9b00-48039ed9eaaa", "name": "Parse Metadata" }, { "parameters": { "command": "=FILE=$(ls /tmp/yt_{{ $json.video_id }}.*.vtt 2>/dev/null | head -1); [ -n \"$FILE\" ] && cat \"$FILE\" || echo \"\"" }, "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 640, 240 ], "id": "fd999ffd-80b4-4248-bf9a-77247fe5d9f8", "name": "Read Subtitle File", "onError": "continueRegularOutput" }, { "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}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 864, 240 ], "id": "f77139fe-ebad-4c6c-9d63-55561a004918", "name": "Parse Transcript" }, { "parameters": { "modelId": { "__rl": true, "value": "models/gemini-2.5-flash", "mode": "list", "cachedResultName": "models/gemini-2.5-flash" }, "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" } ] }, "options": {} }, "type": "@n8n/n8n-nodes-langchain.googleGemini", "typeVersion": 1, "position": [ 1088, 240 ], "id": "26965400-999e-4dea-bdf1-c8190da03f5f", "name": "Message a model", "credentials": { "googlePalmApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Google Gemini (PaLM) API account" } } }, { "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}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1440, 240 ], "id": "f0067574-707f-46b5-a725-8988539c20da", "name": "Prepare Insert Data" }, { "parameters": { "tableId": "videos", "dataToSend": "=autoMapInputData" }, "type": "n8n-nodes-base.supabase", "typeVersion": 1, "position": [ 1664, 240 ], "id": "b9f7473c-2bc1-4337-8efb-4b555cc8439c", "name": "Save to Supabase", "credentials": { "supabaseApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Supabase account" } } }, { "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}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1888, 240 ], "id": "5606a6df-0d30-4b1f-896f-5492ed37d048", "name": "Prepare Success Log" }, { "parameters": { "tableId": "runs", "dataToSend": "autoMapInputData" }, "type": "n8n-nodes-base.supabase", "typeVersion": 1, "position": [ 2112, 240 ], "id": "d94f1688-aed4-4cfa-8553-77519c45fa17", "name": "Log Run", "alwaysOutputData": true, "credentials": { "supabaseApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Supabase account" } } }, { "parameters": { "resource": "message", "guildId": { "__rl": true, "value": "YOUR_GUILD_ID_HERE", "mode": "list", "cachedResultName": "Your Server", "cachedResultUrl": "" }, "channelId": { "__rl": true, "value": "YOUR_CHANNEL_ID_HERE", "mode": "list", "cachedResultName": "your-channel", "cachedResultUrl": "" }, "content": "={{ $json.chunk }}", "options": {} }, "type": "n8n-nodes-base.discord", "typeVersion": 2, "position": [ 2528, 240 ], "id": "e932de7a-bc59-4523-bd96-81b8855e4cc7", "name": "Discord Reply", "webhookId": "", "credentials": { "discordBotApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Discord Bot account" } } }, { "parameters": { "resource": "message", "guildId": { "__rl": true, "value": "YOUR_GUILD_ID_HERE", "mode": "list", "cachedResultName": "Your Server", "cachedResultUrl": "" }, "channelId": { "__rl": true, "value": "YOUR_CHANNEL_ID_HERE", "mode": "list", "cachedResultName": "your-channel", "cachedResultUrl": "" }, "content": "That doesn't look like a YouTube link. Please share a valid YouTube URL.", "options": {} }, "type": "n8n-nodes-base.discord", "typeVersion": 2, "position": [ 192, 432 ], "id": "ac5c0018-638c-4c15-9138-ce6989427dae", "name": "Discord Not YouTube Reply", "webhookId": "", "credentials": { "discordBotApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Discord Bot account" } } }, { "parameters": {}, "type": "n8n-nodes-base.errorTrigger", "typeVersion": 1, "position": [ -800, 656 ], "id": "6516263a-56ab-4892-9a54-7699850fda00", "name": "Error Trigger" }, { "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}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -576, 656 ], "id": "8fa011ac-baa2-43d0-9ed6-a731d5532d62", "name": "Prepare Error Data" }, { "parameters": { "tableId": "runs", "dataToSend": "autoMapInputData" }, "type": "n8n-nodes-base.supabase", "typeVersion": 1, "position": [ -32, 656 ], "id": "1c208dab-6014-4835-9a68-00a9e721a6bd", "name": "Log Run Error", "credentials": { "supabaseApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Supabase account" } } }, { "parameters": { "resource": "message", "guildId": { "__rl": true, "value": "YOUR_GUILD_ID_HERE", "mode": "list", "cachedResultName": "Your Server", "cachedResultUrl": "" }, "channelId": { "__rl": true, "value": "YOUR_CHANNEL_ID_HERE", "mode": "list", "cachedResultName": "your-channel", "cachedResultUrl": "" }, "content": "=Error processing video: {{ $('Prepare Error Data').first().json.notes }}", "options": {} }, "type": "n8n-nodes-base.discord", "typeVersion": 2, "position": [ 192, 656 ], "id": "6ddf1a0a-ed65-45de-b758-3f8040cc026d", "name": "Discord Error Reply", "webhookId": "", "credentials": { "discordBotApi": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Discord Bot account" } } }, { "parameters": { "content": "# YouTube Video Summarizer — 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!", "height": 892, "width": 496, "color": 4 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -1408, -64 ], "id": "6dcab5da-8423-482c-b6d9-ffee1fa6c9a4", "name": "Sticky Note - Overview" }, { "parameters": { "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 → processing, other messages → friendly reply", "height": 228, "width": 720, "color": 7 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -832, 32 ], "id": "48bfa715-4fed-473f-b804-ba0f735ec43b", "name": "Sticky Note - Trigger" }, { "parameters": { "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)", "height": 256, "width": 480, "color": 7 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -16, -80 ], "id": "f6446181-cbbe-470a-b654-fc8274e71379", "name": "Sticky Note - Video Data Extraction" }, { "parameters": { "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", "height": 240, "width": 480, "color": 7 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 528, -80 ], "id": "dfb5a5b4-921a-44b8-863a-f0a34ef284f6", "name": "Sticky Note - Transcript Processing" }, { "parameters": { "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", "height": 240, "width": 536, "color": 7 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 1088, -80 ], "id": "116e832f-4c11-451c-a3bb-66680d3b7f03", "name": "Sticky Note - AI Summarization" }, { "parameters": { "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)", "height": 240, "width": 900, "color": 7 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 1712, -80 ], "id": "25b52b6c-db1c-4439-9e23-9481690c3837", "name": "Sticky Note - Save & Notify" }, { "parameters": { "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", "height": 256, "width": 740, "color": 7 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -816, 832 ], "id": "646554d2-c672-415b-a33a-1da62f3ed27d", "name": "Sticky Note - Error Handling" }, { "parameters": { "content": "### Discord Bot Setup\n\n1. Go to [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create a new Application → 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", "height": 300, "width": 440, "color": 3 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -800, 1120 ], "id": "eb2d6b48-a92c-4b0e-a778-e16fbe698b57", "name": "Sticky Note - Discord Setup" }, { "parameters": { "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 → Credential → Create New\n5. Paste your API key and save\n6. Model: `gemini-2.5-flash` (default)", "height": 240, "width": 400, "color": 3 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 1088, 496 ], "id": "4c21a5a4-694b-4ca8-bbb1-83c9cf42915f", "name": "Sticky Note - Gemini Setup" }, { "parameters": { "content": "### Supabase Setup\n\n1. Create a project at [supabase.com](https://supabase.com)\n2. Go to Settings → API → 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```", "height": 480, "width": 440, "color": 3 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 1664, 496 ], "id": "447e0689-b5bc-4f93-aea4-e6a27a977d3c", "name": "Sticky Note - Supabase Setup" }, { "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;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2320, 240 ], "id": "b32a9d85-67a9-4217-984c-603702b292c0", "name": "Prepare Messages for Discord" } ], "connections": { "Extract YouTube URL": { "main": [ [ { "node": "Is YouTube URL?", "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 } ] ] }, "yt-dlp Get Metadata": { "main": [ [ { "node": "Parse Metadata", "type": "main", "index": 0 } ] ] }, "Parse Metadata": { "main": [ [ { "node": "Read Subtitle File", "type": "main", "index": 0 } ] ] }, "Read Subtitle File": { "main": [ [ { "node": "Parse Transcript", "type": "main", "index": 0 } ] ] }, "Parse Transcript": { "main": [ [ { "node": "Message a model", "type": "main", "index": 0 } ] ] }, "Message a model": { "main": [ [ { "node": "Prepare Insert Data", "type": "main", "index": 0 } ] ] }, "Prepare Insert Data": { "main": [ [ { "node": "Save to Supabase", "type": "main", "index": 0 } ] ] }, "Save to Supabase": { "main": [ [ { "node": "Prepare Success Log", "type": "main", "index": 0 } ] ] }, "Prepare Success Log": { "main": [ [ { "node": "Log Run", "type": "main", "index": 0 } ] ] }, "Log Run": { "main": [ [ { "node": "Prepare Messages for Discord", "type": "main", "index": 0 } ] ] }, "Error Trigger": { "main": [ [ { "node": "Prepare Error Data", "type": "main", "index": 0 } ] ] }, "Prepare Error Data": { "main": [ [ { "node": "Log Run Error", "type": "main", "index": 0 } ] ] }, "Log Run Error": { "main": [ [ { "node": "Discord Error Reply", "type": "main", "index": 0 } ] ] }, "Prepare Messages for Discord": { "main": [ [ { "node": "Discord Reply", "type": "main", "index": 0 } ] ] } }, "pinData": {}, "meta": { "templateCredsSetupCompleted": true, "instanceId": "" } }