{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "8614b18c-d37b-44cf-859a-61963f81d495",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -272,
        0
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "2a864e15-008d-460c-b002-c7e6369b393a",
      "name": "List Video Files",
      "type": "n8n-nodes-base.executeCommand",
      "position": [
        -64,
        0
      ],
      "parameters": {
        "command": "find /opt/downloads/\u5355\u8bcd\u5361/A1-A2 -type f -name '*.mp4' -print0"
      },
      "typeVersion": 1
    },
    {
      "id": "89a41e82-e8e5-41d1-9077-55441e0f44b9",
      "name": "Sort and Generate Items",
      "type": "n8n-nodes-base.code",
      "position": [
        144,
        0
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json.stdout ?? '';\nconst paths = raw.split('\\u0000').filter(Boolean);\n\nfunction extractDayNum(p) {\n  const name = p.split('/').pop();\n  const m = name.match(/day[-_ ]*(\\d+)/i);\n  return m ? parseInt(m[1], 10) : Number.POSITIVE_INFINITY;\n}\n\nconst sorted = paths\n  .filter(p => p.toLowerCase().endsWith('.mp4'))\n  .map(p => ({\n    path: p,\n    filename: p.split('/').pop(),\n    day: extractDayNum(p),\n  }))\n  .sort((a, b) => (a.day - b.day) || a.filename.localeCompare(b.filename));\n\nreturn sorted.map((it, i) => ({\n  json: {\n    path: it.path,\n    filename: it.filename,\n    day: it.day,\n    order: i\n  }\n}));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "ab5c0131-dc14-4207-9256-b181343b0118",
      "name": "Calculate Publish Schedule (+12h Interval)",
      "type": "n8n-nodes-base.code",
      "position": [
        368,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// === Parameters (can be changed) ===\nconst SPAN_HOURS   = 12;          // Each interval hour\nconst TZ_OFFSET_MS = 9 * 3600e3;  // JST +09:00\nconst BUFFER_MIN   = 30;          // Buffer minutes for processing the platform\n\n// === Read and check the serial number ===\nconst rawOrder = $json.order ?? $json.index ?? 0;\nconst order = Number(rawOrder);\nif (!Number.isFinite(order) || order < 0) {\n  throw new Error(`Invalid order/index: ${rawOrder}`);\n}\n\n// === Calculate \"the next whole point of JST + buffer\" in pure milliseconds ===\n// Move the current UTC time to the \"wall clock time\" of JST\nconst nowUtcMs   = Date.now();\nconst nowJstMs   = nowUtcMs + TZ_OFFSET_MS;\n\n// Align to the next whole point of JST (up to the next hour)\nconst HOUR_MS    = 3600e3;\nconst MIN_MS     = 60e3;\nconst nextHourJstMs = Math.ceil(nowJstMs / HOUR_MS) * HOUR_MS;\n\n// Add buffer minutes\nconst baseJstMs  = nextHourJstMs + BUFFER_MIN * MIN_MS;\n\n// JST release time of the current entry (overlay interval by serial number)\nconst publishJstMs = baseJstMs + order * SPAN_HOURS * HOUR_MS;\n\n// Then move back to UTC and give YouTube's publishAt\nconst publishUtcMs  = publishJstMs - TZ_OFFSET_MS;\nconst publishAtUtc  = new Date(publishUtcMs).toISOString();\n\n// Log only (JST readable)\nconst publishAtLocal = new Date(publishJstMs).toISOString().slice(0,19) + '+09:00';\n\nreturn {\n  publishAtUtc,            // To the YouTube node\n  publishAtLocal,          // Log only\n  filename: ($json.path ?? '').split('/').pop(),\n  order\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "b4fafaac-c6aa-46a2-bba7-67d9552ca667",
      "name": "Split in Batches (1 per video)",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        576,
        0
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "b077ea9f-5e76-48da-a3d0-09cf548c0da4",
      "name": "Read Video File",
      "type": "n8n-nodes-base.readWriteFile",
      "position": [
        832,
        16
      ],
      "parameters": {
        "options": {},
        "fileSelector": "=/opt/downloads/\u5355\u8bcd\u5361/A1-A2/{{ $json.filename }}"
      },
      "retryOnFail": true,
      "typeVersion": 1
    },
    {
      "id": "3e2cd625-012a-4451-aa2a-f3f049f82c0f",
      "name": "Upload to YouTube (Scheduled)",
      "type": "n8n-nodes-base.youTube",
      "position": [
        1072,
        16
      ],
      "parameters": {
        "title": "A1\u2013A2\u5355\u8bcd\u6253\u5361\u8ba1\u5212 #\u8f7b\u677e\u5b66\u82f1\u8bed #\u82f1\u8bed\u5b66\u4e60 #\u82f1\u8bed\u5355\u8bcd #\u82f1\u8bed\u6253\u5361 #\u96f6\u57fa\u7840\u82f1\u8bed",
        "options": {
          "tags": "\u8f7b\u677e\u5b66\u82f1\u8bed,\u82f1\u8bed\u5b66\u4e60,\u82f1\u8bed\u5355\u8bcd,\u82f1\u8bed\u6253\u5361,\u96f6\u57fa\u7840\u82f1\u8bed",
          "publishAt": "={{ $('Split in Batches (1 per video)').item.json.publishAtUtc }}",
          "description": "=A1\u2013A2 \u82f1\u8bed\u5355\u8bcd\u5b66\u4e60\u7cfb\u5217 | \u96f6\u57fa\u7840\u82f1\u8bed | \u6bcf\u592910\u5206\u949f\u638c\u63e1\u6838\u5fc3\u8bcd\u6c47 \u5e2e\u52a9\u521d\u5b66\u8005\u7cfb\u7edf\u5b66\u4e60\u57fa\u7840\u82f1\u8bed\u8bcd\u6c47\uff0c\u4eceA1\u5230A2\u9010\u6b65\u63d0\u5347\u3002 \u8f7b\u677e\u5b66\u82f1\u8bed\u3001\u82f1\u8bed\u6253\u5361\u3001\u81ea\u5b66\u590d\u4e60\u5fc5\u5907\u3002 #\u82f1\u8bed\u5b66\u4e60 #\u82f1\u8bed\u5355\u8bcd #\u96f6\u57fa\u7840\u82f1\u8bed #EnglishVocabulary #A1A2English",
          "privacyStatus": "private"
        },
        "resource": "video",
        "operation": "upload",
        "categoryId": "27",
        "regionCode": "CN"
      },
      "credentials": {
        "youTubeOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 3000
    },
    {
      "id": "ee967e9e-66b7-475c-b4c6-cb1f194a2304",
      "name": "Add to Playlist",
      "type": "n8n-nodes-base.youTube",
      "position": [
        1280,
        16
      ],
      "parameters": {
        "options": {},
        "videoId": "={{ $json.uploadId }}",
        "resource": "playlistItem",
        "playlistId": "PLJ3aD-smyb90ZmBJ9jcuW7AcnGeHF_jfF"
      },
      "credentials": {
        "youTubeOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 3000
    },
    {
      "id": "a52f2b0e-546a-4dd9-b929-a3ed35a28b31",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -672,
        -464
      ],
      "parameters": {
        "color": 5,
        "width": 480,
        "height": 224,
        "content": "# Overview\n\n\nThis workflow automates video publishing to YouTube.\nIt lists local video files, generates upload metadata, schedules each upload every 12 hours, then uploads and adds them to a YouTube playlist."
      },
      "typeVersion": 1
    },
    {
      "id": "7bfa8a61-3eab-42eb-a3fe-52bf180cf7b0",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -176,
        -208
      ],
      "parameters": {
        "color": 4,
        "width": 320,
        "height": 176,
        "content": "## List & Prepare Files\n\nRetrieves all video files from the local folder and prepares them as workflow items.\nEach item includes the file path and basic information for later processing."
      },
      "typeVersion": 1
    },
    {
      "id": "2ff489ca-c9ff-4f99-b0ce-849a05b21ccc",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        176
      ],
      "parameters": {
        "color": 4,
        "width": 368,
        "height": 192,
        "content": "## Sort & Schedule Uploads\n\nSorts videos in the correct order and calculates a publish schedule.\nEach video is assigned a publishAt time spaced 12 hours apart from the previous one."
      },
      "typeVersion": 1
    },
    {
      "id": "9d4dec38-c545-4e79-8607-eaddf83ff697",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        448,
        -208
      ],
      "parameters": {
        "color": 4,
        "width": 368,
        "height": 192,
        "content": "## Process in Batches\n\nEnsures videos are handled one by one.\nPrevents large uploads from overloading memory and makes error recovery easier."
      },
      "typeVersion": 1
    },
    {
      "id": "e1ea7552-4668-4d16-bd9c-d47464bdd3d9",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        944,
        240
      ],
      "parameters": {
        "color": 4,
        "width": 368,
        "height": 192,
        "content": "## Upload to YouTube\n\nReads each video from disk and uploads it to YouTube as a scheduled private upload.\nThe video becomes public automatically at its scheduled publishAt time."
      },
      "typeVersion": 1
    },
    {
      "id": "f9380df2-f7e4-439c-a07c-cdf679ff01e7",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1168,
        -208
      ],
      "parameters": {
        "color": 4,
        "width": 368,
        "height": 192,
        "content": "## Add to Playlist\n\nAfter upload, each video is automatically added to the specified YouTube playlist to keep the channel content organized."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "List Video Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add to Playlist": {
      "main": [
        [
          {
            "node": "Split in Batches (1 per video)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Video File": {
      "main": [
        [
          {
            "node": "Upload to YouTube (Scheduled)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List Video Files": {
      "main": [
        [
          {
            "node": "Sort and Generate Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sort and Generate Items": {
      "main": [
        [
          {
            "node": "Calculate Publish Schedule (+12h Interval)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to YouTube (Scheduled)": {
      "main": [
        [
          {
            "node": "Add to Playlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split in Batches (1 per video)": {
      "main": [
        [],
        [
          {
            "node": "Read Video File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Publish Schedule (+12h Interval)": {
      "main": [
        [
          {
            "node": "Split in Batches (1 per video)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}