{ "name": "Content Pipeline - BlendedFamilyKitchen", "nodes": [ { "parameters": { "rule": { "interval": [{"field": "minutes", "minutesInterval": 30}] } }, "name": "Schedule Trigger", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [260, 300] }, { "parameters": { "method": "POST", "url": "http://192.168.2.40:5000/webapi/auth.cgi", "sendBody": true, "bodyParameters": { "parameters": [ {"name": "api", "value": "SYNO.API.Auth"}, {"name": "version", "value": "3"}, {"name": "method", "value": "login"}, {"name": "account", "value": "TODO_NAS_USER"}, {"name": "passwd", "value": "TODO_NAS_PASS"}, {"name": "session", "value": "FileStation"}, {"name": "format", "value": "sid"} ] } }, "name": "NAS Login", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [480, 300] }, { "parameters": { "method": "POST", "url": "http://192.168.2.40:5000/webapi/entry.cgi", "sendBody": true, "bodyParameters": { "parameters": [ {"name": "api", "value": "SYNO.FileStation.List"}, {"name": "version", "value": "2"}, {"name": "method", "value": "list"}, {"name": "folder_path", "value": "/BlendedFamilyKitchen/DropZone"}, {"name": "_sid", "value": "={{ $json.data.sid }}"} ] } }, "name": "Poll NAS DropZone", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [700, 300] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "cond1", "leftValue": "={{ $json.data?.files?.length }}", "rightValue": "0", "operator": { "type": "number", "operation": "gt" } } ], "combinator": "and" } }, "name": "IF New Files?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [920, 300] }, { "parameters": { "fieldToSplitOut": "data.files" }, "name": "Split Into Batches", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [1140, 200] }, { "parameters": { "mode": "manual", "duplicateItem": false, "assignments": { "assignments": [ {"id": "a1", "name": "filename", "value": "={{ $json.name }}", "type": "string"}, {"id": "a2", "name": "filepath", "value": "={{ $json.path }}", "type": "string"}, {"id": "a3", "name": "size", "value": "={{ $json.additional?.size }}", "type": "number"}, {"id": "a4", "name": "created", "value": "={{ $json.additional?.time?.crtime }}", "type": "string"}, {"id": "a5", "name": "extension", "value": "={{ $json.name.split('.').pop().toLowerCase() }}", "type": "string"} ] } }, "name": "Extract Metadata", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1360, 200] }, { "parameters": { "method": "GET", "url": "=http://192.168.2.40:5000/webapi/entry.cgi", "sendBody": true, "bodyParameters": { "parameters": [ {"name": "api", "value": "SYNO.FileStation.Download"}, {"name": "version", "value": "2"}, {"name": "method", "value": "download"}, {"name": "path", "value": "={{ $json.filepath }}"}, {"name": "_sid", "value": "={{ $('NAS Login').item.json.data.sid }}"} ] }, "options": { "response": {"response": {"responseFormat": "file"}} } }, "name": "Download Raw Video", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [1580, 200] }, { "parameters": { "command": "=ffmpeg -i /tmp/bfk_raw/{{ $json.filename }} -vn -acodec pcm_s16le -ar 16000 -ac 1 /tmp/bfk_audio/{{ $json.filename.replace(/\\.[^.]+$/, '.wav') }} && echo '{\"audio_path\": \"/tmp/bfk_audio/{{ $json.filename.replace(/\\.[^.]+$/, '.wav') }}\", \"video_path\": \"/tmp/bfk_raw/{{ $json.filename }}\"}'" }, "name": "FFmpeg Extract Audio", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [1800, 200] }, { "parameters": { "method": "POST", "url": "http://localhost:9000/asr", "sendBody": true, "contentType": "multipart-form-data", "bodyParameters": { "parameters": [ {"name": "audio_file", "value": "TODO: binary from audio extraction"}, {"name": "output", "value": "srt"}, {"name": "language", "value": "en"}, {"name": "word_timestamps", "value": "true"} ] } }, "name": "Whisper Transcribe", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [2020, 200] }, { "parameters": { "method": "POST", "url": "https://api.anthropic.com/v1/messages", "sendHeaders": true, "headerParameters": { "parameters": [ {"name": "x-api-key", "value": "TODO_CLAUDE_API_KEY"}, {"name": "anthropic-version", "value": "2023-06-01"} ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"model\": \"claude-sonnet-4-20250514\",\n \"max_tokens\": 500,\n \"messages\": [{\n \"role\": \"user\",\n \"content\": \"You are a TikTok content expert for a blended family cooking channel. Given this transcript, generate: 1) Three hook text options (max 8 words, attention-grabbing, food-focused), 2) A caption/description with hashtags, 3) Three title options. Transcript: {{ $json.text }}\"\n }]\n}" }, "name": "AI Generate Hooks", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [2240, 200] }, { "parameters": { "command": "=ffmpeg -i /tmp/bfk_raw/{{ $('Extract Metadata').item.json.filename }} -vf \"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,eq=brightness=0.04:contrast=1.05:saturation=1.15,unsharp=5:5:0.3\" -c:v libx264 -preset medium -crf 23 -c:a aac -b:a 128k -ar 44100 -movflags +faststart /tmp/bfk_processed/normalized_{{ $('Extract Metadata').item.json.filename }} && echo 'normalized'" }, "name": "FFmpeg Normalize Video", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [2460, 200] }, { "parameters": { "command": "=ffmpeg -i /tmp/bfk_processed/normalized_{{ $('Extract Metadata').item.json.filename }} -vf \"subtitles=/tmp/bfk_subs/{{ $('Extract Metadata').item.json.filename.replace(/\\.[^.]+$/, '.srt') }}:force_style='FontName=Arial,FontSize=14,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Shadow=1,Alignment=2,MarginV=40'\" -c:v libx264 -preset medium -crf 23 -c:a copy /tmp/bfk_processed/captioned_{{ $('Extract Metadata').item.json.filename }} && echo 'captioned'" }, "name": "FFmpeg Burn Captions", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [2680, 200] }, { "parameters": { "command": "=ffmpeg -i /tmp/bfk_processed/captioned_{{ $('Extract Metadata').item.json.filename }} -i /data/music/kitchen_bg_01.mp3 -filter_complex \"[1:a]volume=0.12[bg];[0:a][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]\" -map 0:v -map \"[aout]\" -c:v copy -c:a aac -b:a 128k /tmp/bfk_processed/final_{{ $('Extract Metadata').item.json.filename }} && echo 'music_mixed'" }, "name": "FFmpeg Mix Background Music", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [2900, 200] }, { "parameters": { "command": "=ffmpeg -i /tmp/bfk_processed/final_{{ $('Extract Metadata').item.json.filename }} -vf \"select=gt(scene\\,0.3)\" -frames:v 1 -q:v 2 /tmp/bfk_thumbs/thumb_{{ $('Extract Metadata').item.json.filename.replace(/\\.[^.]+$/, '.jpg') }} && echo 'thumbnail_extracted'" }, "name": "FFmpeg Extract Thumbnail", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [3120, 200] }, { "parameters": { "method": "GET", "url": "https://ads.tiktok.com/creative_radar_api/v1/popular_trend/sound/list", "sendQuery": true, "queryParameters": { "parameters": [ {"name": "period", "value": "7"}, {"name": "page", "value": "1"}, {"name": "limit", "value": "10"}, {"name": "country_code", "value": "US"} ] } }, "name": "Scrape Trending Sounds", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [2900, 520] }, { "parameters": { "mode": "manual", "duplicateItem": false, "assignments": { "assignments": [ { "id": "s1", "name": "trending_sounds", "value": "={{ $json.data?.sound_list?.slice(0,3).map(s => s.title + ' by ' + s.author).join('\\n') || 'No trending data available' }}", "type": "string" }, { "id": "s2", "name": "curated_kitchen_sounds", "value": "1. Upbeat Cooking Vibes - kitchen_bg_01.mp3\n2. Morning Kitchen Jazz - kitchen_bg_02.mp3\n3. Family Dinner Warmth - kitchen_bg_03.mp3\n4. Quick Recipe Energy - kitchen_bg_04.mp3\n5. Sunday Cook Chill - kitchen_bg_05.mp3", "type": "string" }, { "id": "s3", "name": "sound_recommendation", "value": "Curated kitchen tracks auto-applied at low volume. Trending sounds listed below for optional manual swap in TikTok app before posting.", "type": "string" } ] } }, "name": "Format Sound Suggestions", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [3120, 520] }, { "parameters": { "mode": "combine", "combinationMode": "mergeByPosition", "options": {} }, "name": "Merge Results", "type": "n8n-nodes-base.merge", "typeVersion": 3, "position": [3400, 300] }, { "parameters": { "method": "POST", "url": "http://192.168.2.40:5000/webapi/entry.cgi", "sendBody": true, "bodyParameters": { "parameters": [ {"name": "api", "value": "SYNO.FileStation.Upload"}, {"name": "version", "value": "2"}, {"name": "method", "value": "upload"}, {"name": "path", "value": "/BlendedFamilyKitchen/Processed"}, {"name": "_sid", "value": "={{ $('NAS Login').item.json.data.sid }}"} ] } }, "name": "Upload to NAS Processed", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [3620, 300] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_CLOE_CHAT_ID\",\n \"text\": \"šŸŽ¬ *New Video Ready!*\\n\\nšŸ“ {{ $('Extract Metadata').item.json.filename }}\\n\\n*Hook Options:*\\n{{ $('AI Generate Hooks').item.json.content?.[0]?.text || 'Processing...' }}\\n\\n*Sound Options:*\\nšŸŽµ _Auto-applied:_ Kitchen background track\\n\\nšŸ“ˆ _Trending sounds this week:_\\n{{ $json.trending_sounds || 'No trending data' }}\\n\\nšŸŽ¶ _Curated kitchen library:_\\n{{ $json.curated_kitchen_sounds }}\\n\\nTap below to decide:\",\n \"parse_mode\": \"Markdown\",\n \"reply_markup\": {\n \"inline_keyboard\": [\n [{\"text\": \"āœ… Approve & Post\", \"callback_data\": \"approve_{{ $('Extract Metadata').item.json.filename }}\"}, {\"text\": \"šŸ“ Edit First\", \"callback_data\": \"edit_{{ $('Extract Metadata').item.json.filename }}\"}],\n [{\"text\": \"šŸŽµ Change Sound\", \"callback_data\": \"sound_{{ $('Extract Metadata').item.json.filename }}\"}, {\"text\": \"ā° Schedule\", \"callback_data\": \"schedule_{{ $('Extract Metadata').item.json.filename }}\"}],\n [{\"text\": \"āŒ Reject\", \"callback_data\": \"reject_{{ $('Extract Metadata').item.json.filename }}\"}]\n ]\n }\n}" }, "name": "Notify Cloe Telegram", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [3840, 300] }, { "parameters": { "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/getUpdates", "sendQuery": true, "queryParameters": { "parameters": [ {"name": "offset", "value": "-1"}, {"name": "timeout", "value": "30"} ] } }, "name": "Wait for Cloe Response", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [4060, 300] }, { "parameters": { "rules": { "values": [ {"conditions": {"conditions": [{"leftValue": "={{ $json.result?.[0]?.callback_query?.data?.split('_')[0] }}", "rightValue": "approve", "operator": {"type": "string", "operation": "equals"}}]}, "renameOutput": true, "outputKey": "Approve"}, {"conditions": {"conditions": [{"leftValue": "={{ $json.result?.[0]?.callback_query?.data?.split('_')[0] }}", "rightValue": "edit", "operator": {"type": "string", "operation": "equals"}}]}, "renameOutput": true, "outputKey": "Edit"}, {"conditions": {"conditions": [{"leftValue": "={{ $json.result?.[0]?.callback_query?.data?.split('_')[0] }}", "rightValue": "sound", "operator": {"type": "string", "operation": "equals"}}]}, "renameOutput": true, "outputKey": "Sound"}, {"conditions": {"conditions": [{"leftValue": "={{ $json.result?.[0]?.callback_query?.data?.split('_')[0] }}", "rightValue": "reject", "operator": {"type": "string", "operation": "equals"}}]}, "renameOutput": true, "outputKey": "Reject"}, {"conditions": {"conditions": [{"leftValue": "={{ $json.result?.[0]?.callback_query?.data?.split('_')[0] }}", "rightValue": "schedule", "operator": {"type": "string", "operation": "equals"}}]}, "renameOutput": true, "outputKey": "Schedule"} ] }, "options": {} }, "name": "Switch Cloe Decision", "type": "n8n-nodes-base.switch", "typeVersion": 3, "position": [4280, 300] }, { "parameters": { "method": "POST", "url": "https://open.tiktokapis.com/v2/post/publish/video/init/", "sendBody": true, "specifyBody": "json", "jsonBody": "{\n \"post_info\": {\n \"title\": \"TODO: from AI hooks\",\n \"privacy_level\": \"PUBLIC_TO_EVERYONE\"\n },\n \"source_info\": {\n \"source\": \"FILE_UPLOAD\"\n }\n}" }, "name": "Handle Approve", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [4540, 60] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_CLOE_CHAT_ID\",\n \"text\": \"šŸ“ Opening video for edit. File is in NAS /Processed folder. Reply here when done editing and I'll re-process.\"\n}" }, "name": "Handle Edit", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [4540, 220] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_CLOE_CHAT_ID\",\n \"text\": \"šŸŽµ Sound change requested. Apply your chosen trending sound in the TikTok app, then tap Approve to post.\\n\\nThis week's trending:\\n{{ $('Format Sound Suggestions').item.json.trending_sounds }}\"\n}" }, "name": "Handle Sound Change", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [4540, 380] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_CLOE_CHAT_ID\",\n \"text\": \"āŒ Video rejected. Moving to /Rejected folder on NAS. File: {{ $('Extract Metadata').item.json.filename }}\"\n}" }, "name": "Handle Reject", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [4540, 540] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_CLOE_CHAT_ID\",\n \"text\": \"ā° Schedule mode. Reply with your preferred post time (e.g. 'tomorrow 6pm', 'friday 12pm') and I'll queue it up.\"\n}" }, "name": "Handle Schedule", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [4540, 700] }, { "parameters": { "method": "POST", "url": "http://192.168.2.40:5000/webapi/entry.cgi", "sendBody": true, "bodyParameters": { "parameters": [ {"name": "api", "value": "SYNO.FileStation.Rename"}, {"name": "version", "value": "2"}, {"name": "method", "value": "rename"}, {"name": "path", "value": "={{ $('Extract Metadata').item.json.filepath }}"}, {"name": "name", "value": "=/BlendedFamilyKitchen/Archive/{{ $('Extract Metadata').item.json.filename }}"}, {"name": "_sid", "value": "={{ $('NAS Login').item.json.data.sid }}"} ] } }, "name": "Archive Original on NAS", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [4760, 60] }, { "parameters": {}, "name": "Error Trigger", "type": "n8n-nodes-base.errorTrigger", "typeVersion": 1, "position": [260, 700] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_JORDAN_CHAT_ID\",\n \"text\": \"🚨 *Content Pipeline Error*\\n\\nWorkflow: {{ $workflow.name }}\\nNode: {{ $json.execution?.error?.node?.name || 'Unknown' }}\\nError: {{ $json.execution?.error?.message || 'Unknown error' }}\\nTimestamp: {{ new Date().toLocaleString('en-US', {timeZone: 'America/Denver'}) }}\",\n \"parse_mode\": \"Markdown\"\n}" }, "name": "Notify Jordan Error", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [480, 700] }, { "parameters": { "content": "## šŸ“‚ Stage 1: NAS Drop Zone Polling\n\nPolls Synology NAS every 30 min for new files in /BlendedFamilyKitchen/DropZone.\n\n**Folder Structure Needed on NAS:**\n- /BlendedFamilyKitchen/DropZone — Cloe drops raw videos here\n- /BlendedFamilyKitchen/Processed — enhanced videos land here\n- /BlendedFamilyKitchen/Archive — originals moved here after processing\n- /BlendedFamilyKitchen/Rejected — rejected videos moved here\n- /BlendedFamilyKitchen/Music — curated royalty-free background tracks\n\n**Synology FileStation API:**\n- Auth: POST /webapi/auth.cgi (SYNO.API.Auth v3)\n- List: POST /webapi/entry.cgi (SYNO.FileStation.List v2)\n- Download: POST /webapi/entry.cgi (SYNO.FileStation.Download v2)\n- Upload: POST /webapi/entry.cgi (SYNO.FileStation.Upload v2)\n\n**TODO:**\n1. Create NAS shared folders listed above\n2. Create a dedicated n8n API user on Synology\n3. Enable FileStation API in DSM\n4. Replace TODO_NAS_USER and TODO_NAS_PASS\n5. Test auth endpoint manually first\n6. Verify file extensions filter (.mp4, .mov, .avi)", "height": 580, "width": 560 }, "name": "Sticky - NAS Polling", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [200, -280] }, { "parameters": { "content": "## šŸ¤– Stage 2: AI Processing Pipeline\n\nDownloads raw video, processes through FFmpeg + Whisper + Claude.\n\n**Processing Steps (in order):**\n1. Download raw file from NAS to local /tmp/bfk_raw/\n2. FFmpeg: Extract audio (WAV 16kHz mono for Whisper)\n3. Whisper: Transcribe → generate .srt subtitle file\n4. Claude API: Generate 3 hook options + caption + hashtags\n5. FFmpeg: Normalize video (9:16, color correct, sharpen)\n6. FFmpeg: Burn captions from .srt (white text, black outline)\n7. FFmpeg: Mix low-volume background music from curated library\n8. FFmpeg: Extract best thumbnail frame (scene detection)\n\n**Infrastructure Needed:**\n- Whisper Docker container (e.g. openai/whisper or faster-whisper)\n - Recommend: onerahmet/openai-whisper-asr-webservice\n - Deploy on VM with GPU or on CT 113 (CPU-only, slower)\n - API: POST /asr with audio_file + output=srt\n- FFmpeg installed on CT 113 (apt install ffmpeg)\n- Claude API key in n8n credentials\n- Temp dirs: /tmp/bfk_raw, /tmp/bfk_audio, /tmp/bfk_subs, /tmp/bfk_processed, /tmp/bfk_thumbs\n\n**TODO:**\n1. Deploy Whisper container\n2. Install FFmpeg on CT 113\n3. Create temp directories\n4. Add Claude API credential in n8n\n5. Test Whisper with a sample audio file", "height": 660, "width": 560 }, "name": "Sticky - AI Processing", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [1520, -320] }, { "parameters": { "content": "## šŸŽµ Stage 3: Sound Suggestions\n\nParallel branch — scrapes trending TikTok sounds and merges with curated kitchen library.\n\n**Cloe's Creative Control:**\n- Background music auto-applied at LOW volume (12%) from curated library\n- Trending sounds presented as SUGGESTIONS only\n- Cloe can swap sounds in TikTok app before posting\n- Sound Change button in Telegram sends her the trending list\n\n**Curated Kitchen Music Library:**\n- Store royalty-free tracks in /BlendedFamilyKitchen/Music/ on NAS\n- Categorize: upbeat, chill, morning, family dinner, quick recipe\n- Sources: Epidemic Sound, Artlist, TikTok Commercial Music Library\n- Name format: kitchen_bg_01.mp3, kitchen_bg_02.mp3, etc.\n\n**Trending Sounds Scraping:**\n- TikTok Creative Center API (free, no auth needed)\n- Endpoint: ads.tiktok.com/creative_radar_api/v1/popular_trend/sound/list\n- Alternative: Apify TikTok Music Trend API\n- Refresh weekly, cache results\n\n**TODO:**\n1. Curate 10-20 royalty-free kitchen tracks\n2. Upload to NAS /BlendedFamilyKitchen/Music/\n3. Test TikTok Creative Center API endpoint\n4. Build fallback if API requires auth", "height": 620, "width": 560 }, "name": "Sticky - Sound Suggestions", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [2840, 700] }, { "parameters": { "content": "## šŸ“± Stage 4: Cloe Approval via Telegram\n\nSends processed video preview to Cloe with inline action buttons.\n\n**Telegram Message Includes:**\n- Video filename and preview thumbnail\n- 3 AI-generated hook text options\n- AI-generated caption with hashtags\n- Auto-applied background music info\n- Trending sound suggestions (this week)\n- Curated kitchen sound options\n- Action buttons: Approve | Edit | Change Sound | Schedule | Reject\n\n**Button Actions:**\n- āœ… Approve: Posts directly to TikTok via API\n- šŸ“ Edit: Notifies Cloe file is in /Processed, waits for re-trigger\n- šŸŽµ Sound: Sends trending sound list, Cloe applies in TikTok app\n- ā° Schedule: Asks for preferred time, queues for later posting\n- āŒ Reject: Moves to /Rejected folder, notifies\n\n**Cloe Maintains Full Autonomy:**\n- Nothing posts without her explicit approval\n- She can edit videos before approving\n- She chooses final sound/music\n- She sets posting schedule\n- Pipeline does the grunt work, she makes creative decisions\n\n**TODO:**\n1. Create Telegram bot for BFK notifications\n2. Get Cloe's Telegram chat_id\n3. Set TELEGRAM_BOT_TOKEN in n8n environment\n4. Set up TikTok API app + OAuth for posting\n5. Test inline keyboard callbacks\n6. Add webhook endpoint for Telegram callback responses", "height": 680, "width": 560 }, "name": "Sticky - Cloe Approval", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [3780, -380] }, { "parameters": { "content": "## 🚨 Stage 5: Error Handling\n\nCatches any workflow errors and notifies Jordan via Telegram.\n\n**Error Notification Includes:**\n- Workflow name\n- Failed node name\n- Error message\n- MST timestamp\n\n**TODO:**\n1. Set Jordan's Telegram chat_id\n2. Consider adding retry logic for transient failures\n3. Add dead letter queue for persistent failures", "height": 300, "width": 400 }, "name": "Sticky - Error Handling", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [200, 560] }, { "parameters": { "amount": 48, "unit": "hours" }, "name": "Wait 48hrs", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [4980, 60] }, { "parameters": { "method": "POST", "url": "https://open.tiktokapis.com/v2/video/query/", "sendHeaders": true, "headerParameters": { "parameters": [ {"name": "Authorization", "value": "=Bearer {{ $env.TIKTOK_ACCESS_TOKEN }}"}, {"name": "Content-Type", "value": "application/json"} ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"filters\": {\n \"video_ids\": [\"{{ $('Handle Approve').item.json.data?.publish_id || 'unknown' }}\"]\n },\n \"fields\": [\"id\", \"title\", \"view_count\", \"like_count\", \"comment_count\", \"share_count\", \"create_time\"]\n}" }, "name": "TikTok Query Video Stats", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [5200, 60] }, { "parameters": { "mode": "manual", "duplicateItem": false, "assignments": { "assignments": [ { "id": "an1", "name": "video_id", "value": "={{ $json.data?.videos?.[0]?.id || 'unknown' }}", "type": "string" }, { "id": "an2", "name": "title", "value": "={{ $json.data?.videos?.[0]?.title || $('Extract Metadata').item.json.filename }}", "type": "string" }, { "id": "an3", "name": "views", "value": "={{ $json.data?.videos?.[0]?.view_count || 0 }}", "type": "number" }, { "id": "an4", "name": "likes", "value": "={{ $json.data?.videos?.[0]?.like_count || 0 }}", "type": "number" }, { "id": "an5", "name": "comments", "value": "={{ $json.data?.videos?.[0]?.comment_count || 0 }}", "type": "number" }, { "id": "an6", "name": "shares", "value": "={{ $json.data?.videos?.[0]?.share_count || 0 }}", "type": "number" }, { "id": "an7", "name": "engagement_rate", "value": "={{ $json.data?.videos?.[0]?.view_count > 0 ? (($json.data.videos[0].like_count + $json.data.videos[0].comment_count + $json.data.videos[0].share_count) / $json.data.videos[0].view_count * 100).toFixed(2) : '0' }}", "type": "string" }, { "id": "an8", "name": "polled_at", "value": "={{ new Date().toLocaleString('en-US', {timeZone: 'America/Denver'}) }}", "type": "string" }, { "id": "an9", "name": "filename", "value": "={{ $('Extract Metadata').item.json.filename }}", "type": "string" } ] } }, "name": "Parse Analytics", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [5420, 60] }, { "parameters": { "method": "POST", "url": "http://192.168.2.40:5000/webapi/entry.cgi", "sendBody": true, "bodyParameters": { "parameters": [ {"name": "api", "value": "SYNO.FileStation.Upload"}, {"name": "version", "value": "2"}, {"name": "method", "value": "upload"}, {"name": "path", "value": "/BlendedFamilyKitchen/Analytics"}, {"name": "filename", "value": "=analytics_{{ $json.video_id }}_{{ Date.now() }}.json"}, {"name": "content", "value": "={{ JSON.stringify({video_id: $json.video_id, title: $json.title, views: $json.views, likes: $json.likes, comments: $json.comments, shares: $json.shares, engagement_rate: $json.engagement_rate, polled_at: $json.polled_at, filename: $json.filename}) }}"}, {"name": "_sid", "value": "={{ $('NAS Login').item.json.data.sid }}"} ] } }, "name": "Store Analytics to NAS", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [5640, 60] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_CLOE_CHAT_ID\",\n \"text\": \"šŸ“Š *48hr Performance Check*\\n\\nšŸ“ {{ $json.filename }}\\n\\nšŸ‘ļø Views: {{ $json.views.toLocaleString() }}\\nā¤ļø Likes: {{ $json.likes.toLocaleString() }}\\nšŸ’¬ Comments: {{ $json.comments }}\\nšŸ”„ Shares: {{ $json.shares }}\\nšŸ“ˆ Engagement: {{ $json.engagement_rate }}%\\n\\n_Polled {{ $json.polled_at }} MST_\",\n \"parse_mode\": \"Markdown\"\n}" }, "name": "Notify Cloe Analytics", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [5860, 60] }, { "parameters": { "rule": { "interval": [{"field": "cronExpression", "expression": "0 9 * * 0"}] } }, "name": "Weekly Summary Trigger", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [5200, 400] }, { "parameters": { "method": "POST", "url": "http://192.168.2.40:5000/webapi/entry.cgi", "sendBody": true, "bodyParameters": { "parameters": [ {"name": "api", "value": "SYNO.FileStation.List"}, {"name": "version", "value": "2"}, {"name": "method", "value": "list"}, {"name": "folder_path", "value": "/BlendedFamilyKitchen/Analytics"}, {"name": "sort_by", "value": "crtime"}, {"name": "sort_direction", "value": "desc"}, {"name": "limit", "value": "20"}, {"name": "_sid", "value": "TODO_REAUTH_NEEDED"} ] } }, "name": "Fetch Weekly Analytics Files", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [5420, 400] }, { "parameters": { "jsCode": "// Aggregate analytics from all JSON files fetched this week\nconst files = $input.all();\nlet totalViews = 0, totalLikes = 0, totalComments = 0, totalShares = 0;\nlet videoSummaries = [];\nlet videoCount = 0;\n\nfor (const item of files) {\n try {\n const data = typeof item.json === 'string' ? JSON.parse(item.json) : item.json;\n if (data.views !== undefined) {\n totalViews += Number(data.views) || 0;\n totalLikes += Number(data.likes) || 0;\n totalComments += Number(data.comments) || 0;\n totalShares += Number(data.shares) || 0;\n videoCount++;\n videoSummaries.push(`• ${data.title || data.filename}: ${Number(data.views).toLocaleString()} views, ${data.engagement_rate}% eng`);\n }\n } catch(e) { /* skip unparseable */ }\n}\n\nconst avgEngagement = totalViews > 0 \n ? ((totalLikes + totalComments + totalShares) / totalViews * 100).toFixed(2) \n : '0';\n\nreturn [{\n json: {\n total_views: totalViews,\n total_likes: totalLikes,\n total_comments: totalComments,\n total_shares: totalShares,\n avg_engagement: avgEngagement,\n video_count: videoCount,\n video_breakdown: videoSummaries.join('\\n') || 'No videos tracked this week',\n week_ending: new Date().toLocaleDateString('en-US', {timeZone: 'America/Denver'})\n }\n}];" }, "name": "Aggregate Weekly Stats", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [5640, 400] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"chat_id\": \"TODO_CLOE_CHAT_ID\",\n \"text\": \"šŸ“Š *Weekly Performance Summary*\\n_Week ending {{ $json.week_ending }}_\\n\\nšŸŽ¬ Videos tracked: {{ $json.video_count }}\\nšŸ‘ļø Total views: {{ $json.total_views.toLocaleString() }}\\nā¤ļø Total likes: {{ $json.total_likes.toLocaleString() }}\\nšŸ’¬ Total comments: {{ $json.total_comments }}\\nšŸ”„ Total shares: {{ $json.total_shares }}\\nšŸ“ˆ Avg engagement: {{ $json.avg_engagement }}%\\n\\n*Per-Video Breakdown:*\\n{{ $json.video_breakdown }}\\n\\n_Keep it up! šŸ”„_\",\n \"parse_mode\": \"Markdown\"\n}" }, "name": "Send Weekly Summary to Cloe", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [5860, 400] }, { "parameters": { "content": "## šŸ“Š Stage 6: Analytics Polling & Weekly Summary\n\nTracks video performance after posting and sends weekly digests.\n\n**Per-Video Flow (after Approve):**\n1. Wait 48 hours after TikTok post\n2. Query TikTok Display API for video stats\n3. Parse: views, likes, comments, shares, engagement rate\n4. Store analytics JSON to NAS /BlendedFamilyKitchen/Analytics/\n5. Send 48hr performance snapshot to Cloe via Telegram\n\n**Weekly Summary (Sundays 9am MST):**\n1. Fetch all analytics files from NAS\n2. Aggregate totals across all tracked videos\n3. Calculate average engagement rate\n4. Send formatted weekly digest to Cloe\n\n**Metrics Available via TikTok Display API:**\n- āœ… Views, likes, comments, shares (available)\n- āŒ Watch time, completion rate (TikTok Studio only)\n- āŒ Demographics, traffic sources (TikTok Studio only)\n\n**Engagement Rate Formula:**\n(likes + comments + shares) / views Ɨ 100\n\n**Folder Structure:**\n- /BlendedFamilyKitchen/Analytics/ — JSON files per video\n- Filename: analytics_{video_id}_{timestamp}.json\n\n**TikTok API Requirements:**\n- App: TikTok Developer Portal → Create App\n- Scopes: video.list, video.info\n- Auth: OAuth 2.0 → access_token stored in n8n env\n- Endpoint: POST https://open.tiktokapis.com/v2/video/query/\n- Rate limit: 100 requests/day\n\n**TODO:**\n1. Register TikTok Developer App\n2. Complete OAuth flow for Cloe's account\n3. Store TIKTOK_ACCESS_TOKEN in n8n env vars\n4. Create /BlendedFamilyKitchen/Analytics/ folder on NAS\n5. Set Cloe's Telegram chat_id\n6. Test Display API with a sample video ID\n7. Add token refresh logic (tokens expire every 24hrs)\n8. Consider adding 7-day follow-up poll for longer-term metrics", "height": 780, "width": 560 }, "name": "Sticky - Analytics", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [4920, -380] } ], "connections": { "Schedule Trigger": {"main": [[{"node": "NAS Login", "type": "main", "index": 0}]]}, "NAS Login": {"main": [[{"node": "Poll NAS DropZone", "type": "main", "index": 0}]]}, "Poll NAS DropZone": {"main": [[{"node": "IF New Files?", "type": "main", "index": 0}]]}, "IF New Files?": {"main": [[{"node": "Split Into Batches", "type": "main", "index": 0}]]}, "Split Into Batches": {"main": [[{"node": "Extract Metadata", "type": "main", "index": 0}]]}, "Extract Metadata": {"main": [[{"node": "Download Raw Video", "type": "main", "index": 0}]]}, "Download Raw Video": {"main": [[{"node": "FFmpeg Extract Audio", "type": "main", "index": 0}]]}, "FFmpeg Extract Audio": {"main": [[{"node": "Whisper Transcribe", "type": "main", "index": 0}]]}, "Whisper Transcribe": {"main": [[{"node": "AI Generate Hooks", "type": "main", "index": 0}]]}, "AI Generate Hooks": {"main": [[{"node": "FFmpeg Normalize Video", "type": "main", "index": 0}]]}, "FFmpeg Normalize Video": {"main": [[{"node": "FFmpeg Burn Captions", "type": "main", "index": 0}]]}, "FFmpeg Burn Captions": {"main": [[{"node": "FFmpeg Mix Background Music", "type": "main", "index": 0}]]}, "FFmpeg Mix Background Music": {"main": [[{"node": "FFmpeg Extract Thumbnail", "type": "main", "index": 0}]]}, "FFmpeg Extract Thumbnail": {"main": [[{"node": "Merge Results", "type": "main", "index": 0}]]}, "Scrape Trending Sounds": {"main": [[{"node": "Format Sound Suggestions", "type": "main", "index": 0}]]}, "Format Sound Suggestions": {"main": [[{"node": "Merge Results", "type": "main", "index": 0}]]}, "Merge Results": {"main": [[{"node": "Upload to NAS Processed", "type": "main", "index": 0}]]}, "Upload to NAS Processed": {"main": [[{"node": "Notify Cloe Telegram", "type": "main", "index": 0}]]}, "Notify Cloe Telegram": {"main": [[{"node": "Wait for Cloe Response", "type": "main", "index": 0}]]}, "Wait for Cloe Response": {"main": [[{"node": "Switch Cloe Decision", "type": "main", "index": 0}]]}, "Switch Cloe Decision": { "main": [ [{"node": "Handle Approve", "type": "main", "index": 0}], [{"node": "Handle Edit", "type": "main", "index": 0}], [{"node": "Handle Sound Change", "type": "main", "index": 0}], [{"node": "Handle Reject", "type": "main", "index": 0}], [{"node": "Handle Schedule", "type": "main", "index": 0}] ] }, "Handle Approve": {"main": [[{"node": "Archive Original on NAS", "type": "main", "index": 0}]]}, "Archive Original on NAS": {"main": [[{"node": "Wait 48hrs", "type": "main", "index": 0}]]}, "Wait 48hrs": {"main": [[{"node": "TikTok Query Video Stats", "type": "main", "index": 0}]]}, "TikTok Query Video Stats": {"main": [[{"node": "Parse Analytics", "type": "main", "index": 0}]]}, "Parse Analytics": {"main": [[{"node": "Store Analytics to NAS", "type": "main", "index": 0}]]}, "Store Analytics to NAS": {"main": [[{"node": "Notify Cloe Analytics", "type": "main", "index": 0}]]}, "Weekly Summary Trigger": {"main": [[{"node": "Fetch Weekly Analytics Files", "type": "main", "index": 0}]]}, "Fetch Weekly Analytics Files": {"main": [[{"node": "Aggregate Weekly Stats", "type": "main", "index": 0}]]}, "Aggregate Weekly Stats": {"main": [[{"node": "Send Weekly Summary to Cloe", "type": "main", "index": 0}]]}, "Error Trigger": {"main": [[{"node": "Notify Jordan Error", "type": "main", "index": 0}]]} }, "settings": { "executionOrder": "v1" } }