{
  "name": "YouTube Upload - Local Whisper + Ollama",
  "nodes": [
    {
      "parameters": {
        "triggerOn": "folder",
        "path": "/data/youtube",
        "events": [
          "add"
        ],
        "options": {
          "awaitWriteFinish": true,
          "ignoreInitial": true,
          "depth": 0
        }
      },
      "id": "e9a33071-3ee8-407e-b1af-7a6586b54936",
      "name": "Watch YouTube Queue Folder",
      "type": "n8n-nodes-base.localFileTrigger",
      "typeVersion": 1,
      "position": [
        240,
        304
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "video-extension",
              "leftValue": "={{ $json.path }}",
              "operator": {
                "type": "string",
                "operation": "regex"
              },
              "rightValue": "[.](mp4|mov|mkv|webm)$"
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9628156f-f0d8-4310-8e1d-0367cbb0e934",
      "name": "Only Video Files",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.3,
      "position": [
        528,
        304
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "cfg-whisper",
              "name": "whisperModel",
              "type": "string",
              "value": "Systran/faster-distil-whisper-large-v3"
            },
            {
              "id": "cfg-ollama",
              "name": "ollamaModel",
              "type": "string",
              "value": "gemma4:12b-it-qat"
            },
            {
              "id": "cfg-schedule",
              "name": "schedulePublish",
              "type": "boolean",
              "value": true
            },
            {
              "id": "cfg-time",
              "name": "publishTime",
              "type": "string",
              "value": "09:00"
            },
            {
              "id": "cfg-tz",
              "name": "publishTimezone",
              "type": "string",
              "value": "America/New_York"
            },
            {
              "id": "cfg-days",
              "name": "publishDaysAhead",
              "type": "number",
              "value": 1
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "df2ec199-fd39-43f2-920c-0a8fa07b4ebb",
      "name": "Workflow Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.5,
      "position": [
        800,
        304
      ]
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.path }}",
        "options": {
          "dataPropertyName": "data"
        }
      },
      "id": "a01a237e-9f5c-420c-afb2-55c7212344c9",
      "name": "Read Video",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        1088,
        304
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://speaches:8000/v1/audio/transcriptions",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            },
            {
              "name": "model",
              "value": "={{ $('Workflow Settings').first().json.whisperModel }}"
            },
            {
              "name": "response_format",
              "value": "verbose_json"
            }
          ]
        },
        "options": {
          "timeout": 900000
        }
      },
      "id": "c4277b05-9860-46e5-b32a-c6ac57d49758",
      "name": "Transcribe with Local Whisper",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1360,
        304
      ]
    },
    {
      "parameters": {
        "jsCode": "const res = $input.first().json;\nconst settings = $('Workflow Settings').first().json;\nconst segments = res.segments || [];\nconst NL = String.fromCharCode(10);\n\nfunction fmt(t) {\n  const h = Math.floor(t / 3600);\n  const m = Math.floor((t % 3600) / 60);\n  const s = Math.floor(t % 60);\n  const sec = (s < 10 ? '0' : '') + s;\n  if (h > 0) {\n    return h + ':' + (m < 10 ? '0' : '') + m + ':' + sec;\n  }\n  return m + ':' + sec;\n}\n\nconst transcript = segments\n  .map(function (seg) { return '[' + fmt(seg.start) + '] ' + String(seg.text).trim(); })\n  .join(NL);\n\n// Rough token estimate, then size the context window to fit (capped to protect VRAM)\nconst approxTokens = Math.ceil(transcript.length / 3.5);\nconst numCtx = Math.min(16384, Math.max(8192, Math.ceil((approxTokens + 2500) / 1024) * 1024));\n\nconst system = [\n  'You write YouTube metadata for a tutorial channel.',\n  'You receive a timestamped transcript where each line looks like [m:ss] text.',\n  'Return ONLY a JSON object with these keys:',\n  'title: string, max 70 characters, specific and benefit-led, no false claims.',\n  'description: string, two short plain-text paragraphs saying what the viewer will learn. No chapter list, no hashtags.',\n  'tags: array of 8 to 12 short keyword strings.',\n  'chapters: array of objects with keys time and title.',\n  'Chapter rules: use only timestamps that appear in the transcript. The first chapter must be 0:00. Give 5 to 12 chapters, at least 30 seconds apart, titles of 6 words or fewer.',\n  'Do not invent facts that are not in the transcript.'\n].join(NL);\n\nconst ollamaBody = {\n  model: settings.ollamaModel,\n  stream: false,\n  format: 'json',\n  keep_alive: 0,\n  options: { num_ctx: numCtx, temperature: 0.4 },\n  messages: [\n    { role: 'system', content: system },\n    { role: 'user', content: 'Transcript:' + NL + transcript }\n  ]\n};\n\nreturn [{\n  json: {\n    ollamaBody: ollamaBody,\n    segmentCount: segments.length,\n    transcriptChars: transcript.length,\n    approxTokens: approxTokens,\n    numCtx: numCtx,\n    truncationRisk: (approxTokens + 2500) > 16384,\n    durationSec: res.duration || 0,\n    filePath: settings.path\n  }\n}];"
      },
      "id": "191373bc-b3cb-4905-ac16-81cb0444281d",
      "name": "Format Transcript and Build Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1648,
        304
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://ollama:11434/api/chat",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.ollamaBody }}",
        "options": {
          "timeout": 900000
        }
      },
      "id": "24598d9c-1af3-47e3-ac8b-707e90d541f1",
      "name": "Generate Metadata with Ollama",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1920,
        304
      ]
    },
    {
      "parameters": {
        "jsCode": "const ctx = $('Format Transcript and Build Prompt').first().json;\nconst settings = $('Workflow Settings').first().json;\nconst raw = ($input.first().json.message && $input.first().json.message.content) || '';\nconst NL = String.fromCharCode(10);\nconst warnings = [];\n\nfunction clean(s) {\n  return String(s || '').split('<').join('').split('>').join('').trim();\n}\nfunction toSec(t) {\n  const parts = String(t).trim().split(':').map(Number);\n  if (parts.some(function (n) { return isNaN(n); })) { return null; }\n  if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2]; }\n  if (parts.length === 2) { return parts[0] * 60 + parts[1]; }\n  return null;\n}\nfunction fmt(t) {\n  const h = Math.floor(t / 3600);\n  const m = Math.floor((t % 3600) / 60);\n  const s = Math.floor(t % 60);\n  const sec = (s < 10 ? '0' : '') + s;\n  if (h > 0) { return h + ':' + (m < 10 ? '0' : '') + m + ':' + sec; }\n  return m + ':' + sec;\n}\n\n// Time zone helpers (daylight-saving aware, no libraries needed)\nfunction tzParts(ms, tz) {\n  const fmtr = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });\n  const o = {};\n  fmtr.formatToParts(new Date(ms)).forEach(function (p) { o[p.type] = p.value; });\n  return { year: Number(o.year), month: Number(o.month), day: Number(o.day), hour: Number(o.hour), minute: Number(o.minute), second: Number(o.second) };\n}\nfunction tzOffsetMs(ms, tz) {\n  const p = tzParts(ms, tz);\n  return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;\n}\nfunction zonedToUtc(y, mo, d, h, mi, tz) {\n  const guess = Date.UTC(y, mo, d, h, mi, 0);\n  let utc = guess - tzOffsetMs(guess, tz);\n  utc = guess - tzOffsetMs(utc, tz);\n  return utc;\n}\n\n// Parse the model output (tolerates stray text or code fences around the JSON)\nlet meta = {};\ntry {\n  const start = raw.indexOf('{');\n  const end = raw.lastIndexOf('}');\n  meta = JSON.parse(raw.slice(start, end + 1));\n} catch (e) {\n  warnings.push('Model output was not valid JSON; using fallback title');\n}\n\n// Title\nconst base = String(ctx.filePath).split('/').pop();\nconst dot = base.lastIndexOf('.');\nconst fileTitle = dot > 0 ? base.slice(0, dot) : base;\nlet title = clean(meta.title);\nif (!title) { title = fileTitle; warnings.push('No title generated'); }\nif (title.length > 100) { title = title.slice(0, 97) + '...'; }\n\n// Chapters: YouTube needs first at 0:00, at least 3, each 10s+ apart\nconst input = Array.isArray(meta.chapters) ? meta.chapters : [];\nconst parsed = input\n  .map(function (c) { return { sec: toSec(c.time), title: clean(c.title) }; })\n  .filter(function (c) { return c.sec !== null && c.title; })\n  .sort(function (a, b) { return a.sec - b.sec; });\nconst hasZero = parsed.length > 0 && parsed[0].sec === 0;\nconst ordered = hasZero ? parsed : [{ sec: 0, title: 'Intro' }].concat(parsed);\nlet chapters = [];\nlet last = -100;\nordered.forEach(function (c) {\n  const inRange = ctx.durationSec > 0 ? c.sec <= ctx.durationSec : true;\n  if (c.sec - last >= 10 && inRange) { chapters.push(c); last = c.sec; }\n});\nif (chapters.length < 3) {\n  if (parsed.length) { warnings.push('Fewer than 3 valid chapters; chapter list omitted'); }\n  chapters = [];\n}\n\n// Description\nlet description = clean(meta.description);\nif (chapters.length) {\n  description = description + NL + NL + chapters.map(function (c) { return fmt(c.sec) + ' ' + c.title; }).join(NL);\n}\n\n// Tags\nconst tags = (Array.isArray(meta.tags) ? meta.tags : [])\n  .map(function (t) { return clean(t).split(',').join(' '); })\n  .filter(Boolean)\n  .slice(0, 12)\n  .join(',');\n\n// Scheduled publish time (YouTube requires privacyStatus=private when publishAt is set)\n// Publishes at publishTime in publishTimezone, publishDaysAhead days from today.\n// If that moment is less than an hour away, it rolls to the next day.\nlet publishAt = '';\nlet publishLocal = '';\nif (settings.schedulePublish !== false) {\n  const nowMs = Date.now();\n  try {\n    const tz = String(settings.publishTimezone || 'UTC');\n    const hm = String(settings.publishTime || '09:00').split(':');\n    const hh = Number(hm[0]);\n    const mm = Number(hm[1] || 0);\n    if (isNaN(hh) || isNaN(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { throw new Error('publishTime must look like 09:00'); }\n    let daysAhead = Number(settings.publishDaysAhead);\n    if (isNaN(daysAhead) || daysAhead < 0) { daysAhead = 1; }\n    const today = tzParts(nowMs, tz);\n    let target = zonedToUtc(today.year, today.month - 1, today.day + daysAhead, hh, mm, tz);\n    if (target - nowMs < 3600 * 1000) {\n      target = zonedToUtc(today.year, today.month - 1, today.day + daysAhead + 1, hh, mm, tz);\n    }\n    publishAt = new Date(target).toISOString();\n    publishLocal = new Intl.DateTimeFormat('en-US', { timeZone: tz, dateStyle: 'medium', timeStyle: 'short' }).format(new Date(target)) + ' (' + tz + ')';\n  } catch (e) {\n    warnings.push('Could not compute scheduled time (' + e.message + '); using 24 hours from now');\n    publishAt = new Date(nowMs + 24 * 3600 * 1000).toISOString();\n    publishLocal = publishAt;\n  }\n}\n\nif (ctx.truncationRisk) { warnings.push('Transcript may exceed the model context window; chapters may miss the end of the video'); }\n\nreturn [{\n  json: {\n    title: title,\n    description: description,\n    tags: tags,\n    publishAt: publishAt,\n    publishLocal: publishLocal,\n    chapterCount: chapters.length,\n    warnings: warnings.join(' | '),\n    filePath: ctx.filePath\n  }\n}];"
      },
      "id": "92e304c0-adb6-4e03-8bc1-2539b045f22c",
      "name": "Validate Metadata and Chapters",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2208,
        304
      ]
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.filePath }}",
        "options": {
          "dataPropertyName": "data"
        }
      },
      "id": "a95f99c9-8ddf-4b9d-9064-31f602f170d2",
      "name": "Read Video for Upload",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        2480,
        304
      ]
    },
    {
      "parameters": {
        "resource": "video",
        "operation": "upload",
        "title": "={{ $('Validate Metadata and Chapters').first().json.title }}",
        "regionCode": "US",
        "categoryId": "28",
        "options": {
          "description": "={{ $('Validate Metadata and Chapters').first().json.description }}",
          "privacyStatus": "private",
          "publishAt": "={{ $('Validate Metadata and Chapters').first().json.publishAt }}",
          "selfDeclaredMadeForKids": false,
          "tags": "={{ $('Validate Metadata and Chapters').first().json.tags }}"
        }
      },
      "id": "86f42bea-ffa6-4c2c-b85c-350e11d7e764",
      "name": "Upload to YouTube (Private + Scheduled)",
      "type": "n8n-nodes-base.youTube",
      "typeVersion": 1,
      "position": [
        2768,
        304
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "*Discord Webhook Link Here*",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { content: 'YouTube upload finished (private, publishes ' + $('Validate Metadata and Chapters').first().json.publishLocal + '): ' + $('Validate Metadata and Chapters').first().json.title + ' | https://youtu.be/' + ($json.id || $json.uploadId) + ($('Validate Metadata and Chapters').first().json.warnings ? ' | Warnings: ' + $('Validate Metadata and Chapters').first().json.warnings : '') } }}",
        "options": {}
      },
      "id": "2d32c73b-4868-4ba3-bd59-c0aa097914e0",
      "name": "Notify Discord",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        3040,
        304
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "content": "## Setup\n1. Connect a **YouTube OAuth2** credential on the upload node (Google Cloud project with YouTube Data API v3 enabled).\n2. Paste your **Discord webhook URL** into the Notify Discord node.\n3. Edit models and the publish schedule (time, time zone, days ahead) in **Workflow Settings**.\n4. Optional thumbnail: drop a PNG/JPG with the same name as the video (myvideo.mp4 -> myvideo.png), max 2 MB. The channel must be allowed to set custom thumbnails.\n\nVideos from unaudited API projects are forced private by YouTube.",
        "height": 224,
        "width": 448,
        "color": 4
      },
      "id": "74fbe18a-fbd9-4f5e-b0b1-5ba369f7ffb7",
      "name": "Sticky Note c72a245e",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2000,
        608
      ]
    },
    {
      "parameters": {
        "fileSelector": "={{ $('Validate Metadata and Chapters').first().json.filePath.replace(/[.][^.]+$/, '') + '.{png,jpg,jpeg}' }}",
        "options": {
          "dataPropertyName": "data"
        }
      },
      "id": "c3eedb14-a678-4d8a-a4b8-c5d064ec6e31",
      "name": "Read Thumbnail Image",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        2768,
        512
      ],
      "onError": "continueRegularOutput",
      "notes": "Looks for an image with the same base name as the video, e.g. myvideo.mp4 -> myvideo.png / .jpg / .jpeg"
    },
    {
      "parameters": {
        "conditions": {
          "combinator": "and",
          "conditions": [
            {
              "id": "has-thumbnail-binary",
              "leftValue": "={{ !!$binary.data }}",
              "operator": {
                "operation": "true",
                "singleValue": true,
                "type": "boolean"
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          }
        },
        "options": {}
      },
      "id": "90b2bcdb-3814-490d-8a3d-aaeb331ddad9",
      "name": "Thumbnail Found",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.3,
      "position": [
        3040,
        512
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://www.googleapis.com/upload/youtube/v3/thumbnails/set",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "youTubeOAuth2Api",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "videoId",
              "value": "={{ $('Upload to YouTube (Private + Scheduled)').first().json.id || $('Upload to YouTube (Private + Scheduled)').first().json.uploadId }}"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "={{ $binary.data.mimeType }}"
            }
          ]
        },
        "sendBody": true,
        "contentType": "binaryData",
        "inputDataFieldName": "data",
        "options": {
          "timeout": 120000
        }
      },
      "id": "32c1faf4-a7a0-41c2-b27f-109753fb92d0",
      "name": "Set YouTube Thumbnail",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        3312,
        512
      ],
      "onError": "continueRegularOutput"
    }
  ],
  "pinData": {},
  "connections": {
    "Watch YouTube Queue Folder": {
      "main": [
        [
          {
            "node": "Only Video Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only Video Files": {
      "main": [
        [
          {
            "node": "Workflow Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Workflow Settings": {
      "main": [
        [
          {
            "node": "Read Video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Video": {
      "main": [
        [
          {
            "node": "Transcribe with Local Whisper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Transcribe with Local Whisper": {
      "main": [
        [
          {
            "node": "Format Transcript and Build Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Transcript and Build Prompt": {
      "main": [
        [
          {
            "node": "Generate Metadata with Ollama",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Metadata with Ollama": {
      "main": [
        [
          {
            "node": "Validate Metadata and Chapters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Metadata and Chapters": {
      "main": [
        [
          {
            "node": "Read Video for Upload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Video for Upload": {
      "main": [
        [
          {
            "node": "Upload to YouTube (Private + Scheduled)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to YouTube (Private + Scheduled)": {
      "main": [
        [
          {
            "node": "Notify Discord",
            "type": "main",
            "index": 0
          },
          {
            "node": "Read Thumbnail Image",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Thumbnail Image": {
      "main": [
        [
          {
            "node": "Thumbnail Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Thumbnail Found": {
      "main": [
        [
          {
            "node": "Set YouTube Thumbnail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "nodeGroups": [
    {
      "id": "7405c1da-a2f8-42b6-b323-2d63dace66c8",
      "name": "Intake",
      "nodeIds": [
        "9628156f-f0d8-4310-8e1d-0367cbb0e934",
        "df2ec199-fd39-43f2-920c-0a8fa07b4ebb"
      ],
      "description": "Ignores non-video files and holds the models and the publish schedule"
    },
    {
      "id": "6bc8b863-04de-470f-862a-4458a45c4ef1",
      "name": "Transcribe locally",
      "nodeIds": [
        "a01a237e-9f5c-420c-afb2-55c7212344c9",
        "c4277b05-9860-46e5-b32a-c6ac57d49758",
        "191373bc-b3cb-4905-ac16-81cb0444281d"
      ],
      "description": "Sends the video to local Whisper and builds the Ollama prompt"
    },
    {
      "id": "56bba120-b33d-4405-a99b-30b045357274",
      "name": "Generate metadata",
      "nodeIds": [
        "24598d9c-1af3-47e3-ac8b-707e90d541f1",
        "92e304c0-adb6-4e03-8bc1-2539b045f22c"
      ],
      "description": "Local Ollama writes title, tags, chapters; code validates them"
    },
    {
      "id": "bb2600e0-8f20-4286-876a-72d43bb559d3",
      "name": "Thumbnail (optional)",
      "nodeIds": [
        "c3eedb14-a678-4d8a-a4b8-c5d064ec6e31",
        "90b2bcdb-3814-490d-8a3d-aaeb331ddad9",
        "32c1faf4-a7a0-41c2-b27f-109753fb92d0"
      ],
      "description": "If an image named like the video sits in the folder, sets it as the custom thumbnail"
    },
    {
      "id": "b30b88bb-eaa3-4e31-a463-3aac685828b3",
      "name": "Upload to YouTube",
      "nodeIds": [
        "a95f99c9-8ddf-4b9d-9064-31f602f170d2",
        "86f42bea-ffa6-4c2c-b85c-350e11d7e764"
      ],
      "description": "Re-reads the video and uploads it as private with a scheduled publish time"
    }
  ],
  "tags": []
}