channel-worker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.channel-worker');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+
10
+ function parseArgs(args) {
11
+ const result = {};
12
+ for (let i = 0; i < args.length; i++) {
13
+ if (args[i].startsWith('--')) {
14
+ const key = args[i].slice(2);
15
+ const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true;
16
+ result[key] = val;
17
+ if (val !== true) i++;
18
+ } else if (!result._cmd) {
19
+ result._cmd = args[i];
20
+ }
21
+ }
22
+ return result;
23
+ }
24
+
25
+ function loadConfig() {
26
+ if (fs.existsSync(CONFIG_FILE)) {
27
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
28
+ }
29
+ return {};
30
+ }
31
+
32
+ function saveConfig(config) {
33
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
34
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
35
+ }
36
+
37
+ const args = parseArgs(process.argv.slice(2));
38
+ const cmd = args._cmd || 'start';
39
+
40
+ if (cmd === 'init') {
41
+ const config = {
42
+ worker_id: args.id || `worker-${os.hostname()}`,
43
+ api_url: args.api || 'http://localhost:3001',
44
+ max_concurrent: parseInt(args.concurrent || '2', 10),
45
+ nst_api_key: args['nst-key'] || '',
46
+ extension_path: args.extension || '',
47
+ };
48
+ saveConfig(config);
49
+ console.log(`[channel-worker] Config saved to ${CONFIG_FILE}`);
50
+ console.log(JSON.stringify(config, null, 2));
51
+ console.log('\nRun: channel-worker start');
52
+ process.exit(0);
53
+ }
54
+
55
+ if (cmd === 'start') {
56
+ // Merge CLI args over saved config
57
+ const saved = loadConfig();
58
+ const config = {
59
+ worker_id: args.id || saved.worker_id || `worker-${os.hostname()}`,
60
+ api_url: args.api || saved.api_url || 'http://localhost:3001',
61
+ max_concurrent: parseInt(args.concurrent || saved.max_concurrent || '2', 10),
62
+ nst_api_key: args['nst-key'] || saved.nst_api_key || '',
63
+ extension_path: args.extension || saved.extension_path || '',
64
+ verbose: !!args.verbose,
65
+ };
66
+
67
+ if (!config.worker_id) {
68
+ console.error('[channel-worker] Error: --id required. Run: channel-worker init --id <name> --api <url>');
69
+ process.exit(1);
70
+ }
71
+
72
+ // Save merged config for next time
73
+ saveConfig(config);
74
+
75
+ const { Daemon } = require('../lib/daemon');
76
+ const daemon = new Daemon(config);
77
+ daemon.start();
78
+
79
+ } else if (cmd === 'config') {
80
+ const config = loadConfig();
81
+ console.log(JSON.stringify(config, null, 2));
82
+
83
+ } else {
84
+ console.log(`
85
+ channel-worker — Channel Manager worker daemon
86
+
87
+ Commands:
88
+ init Configure worker (saves to ~/.channel-worker/config.json)
89
+ start Start the daemon
90
+ config Show current config
91
+
92
+ Options:
93
+ --id <name> Worker ID (unique name)
94
+ --api <url> Dashboard API URL (http://host:3001)
95
+ --concurrent <n> Max concurrent browser instances (default: 2)
96
+ --nst-key <key> Nstbrowser API key
97
+ --extension <path> Path to content-creator extension
98
+ --verbose Enable verbose logging
99
+
100
+ Examples:
101
+ channel-worker init --id win-worker --api http://192.168.1.52:3001
102
+ channel-worker start
103
+ channel-worker start --id win-worker --api http://192.168.1.52:3001
104
+ `);
105
+ }
@@ -0,0 +1,64 @@
1
+ class ApiClient {
2
+ constructor(baseUrl) {
3
+ this.baseUrl = baseUrl.replace(/\/$/, '');
4
+ }
5
+
6
+ async request(method, path, body = null) {
7
+ const url = `${this.baseUrl}${path}`;
8
+ const options = {
9
+ method,
10
+ headers: { 'Content-Type': 'application/json' },
11
+ };
12
+ if (body) options.body = JSON.stringify(body);
13
+
14
+ const res = await fetch(url, options);
15
+ const json = await res.json();
16
+
17
+ if (!json.success) {
18
+ throw new Error(json.message || `API error ${res.status}`);
19
+ }
20
+ return json.data;
21
+ }
22
+
23
+ // Worker
24
+ async register(workerData) {
25
+ return this.request('POST', '/workers/register', workerData);
26
+ }
27
+
28
+ async heartbeat(workerId) {
29
+ return this.request('POST', '/workers/heartbeat', { worker_id: workerId });
30
+ }
31
+
32
+ // Jobs
33
+ async getNextJob(workerId) {
34
+ return this.request('GET', `/jobs/next?worker_id=${workerId}`);
35
+ }
36
+
37
+ async claimJob(jobId, workerId) {
38
+ return this.request('PUT', `/jobs/${jobId}`, {
39
+ status: 'running',
40
+ worker_id: workerId,
41
+ });
42
+ }
43
+
44
+ async updateJob(jobId, data) {
45
+ return this.request('PUT', `/jobs/${jobId}`, data);
46
+ }
47
+
48
+ // Channel info
49
+ async getChannel(channelId) {
50
+ return this.request('GET', `/channels/${channelId}`);
51
+ }
52
+
53
+ async getMasterChannel(masterId) {
54
+ return this.request('GET', `/masters/${masterId}`);
55
+ }
56
+
57
+ // Settings
58
+ async getSetting(key) {
59
+ const data = await this.request('GET', `/settings/${key}`);
60
+ return data?.value;
61
+ }
62
+ }
63
+
64
+ module.exports = { ApiClient };
package/lib/daemon.js ADDED
@@ -0,0 +1,84 @@
1
+ const { ApiClient } = require('./api-client');
2
+ const { Heartbeat } = require('./heartbeat');
3
+ const { JobPoller } = require('./job-poller');
4
+ const os = require('os');
5
+
6
+ class Daemon {
7
+ constructor(config) {
8
+ this.config = config;
9
+ this.api = new ApiClient(config.api_url);
10
+ this.heartbeat = new Heartbeat(this.api, config.worker_id);
11
+ this.poller = new JobPoller(this.api, config);
12
+ }
13
+
14
+ async start() {
15
+ console.log(`
16
+ ╔══════════════════════════════════════╗
17
+ ║ channel-worker v1.0.0 ║
18
+ ╠══════════════════════════════════════╣
19
+ ║ Worker ID: ${this.config.worker_id.padEnd(22)}║
20
+ ║ API URL: ${this.config.api_url.padEnd(22)}║
21
+ ║ Concurrent: ${String(this.config.max_concurrent).padEnd(22)}║
22
+ ╚══════════════════════════════════════╝
23
+ `);
24
+
25
+ // Register with dashboard
26
+ try {
27
+ await this.api.register({
28
+ worker_id: this.config.worker_id,
29
+ name: this.config.worker_id,
30
+ ip_address: this.getLocalIP(),
31
+ max_concurrent: this.config.max_concurrent,
32
+ });
33
+ console.log('[daemon] Registered with dashboard ✓');
34
+ } catch (err) {
35
+ console.error(`[daemon] Failed to register: ${err.message}`);
36
+ console.error('[daemon] Is the dashboard API running? Retrying in 10s...');
37
+ setTimeout(() => this.start(), 10000);
38
+ return;
39
+ }
40
+
41
+ // Start heartbeat
42
+ this.heartbeat.start();
43
+ console.log('[daemon] Heartbeat started (every 30s)');
44
+
45
+ // Start job polling
46
+ this.poller.start();
47
+ console.log('[daemon] Job poller started (every 5s)');
48
+ console.log('[daemon] Waiting for jobs...\n');
49
+
50
+ // Graceful shutdown
51
+ const shutdown = async () => {
52
+ console.log('\n[daemon] Shutting down...');
53
+ this.heartbeat.stop();
54
+ this.poller.stop();
55
+
56
+ // Mark offline
57
+ try {
58
+ await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
59
+ status: 'offline',
60
+ });
61
+ } catch { /* ignore */ }
62
+
63
+ console.log('[daemon] Goodbye.');
64
+ process.exit(0);
65
+ };
66
+
67
+ process.on('SIGINT', shutdown);
68
+ process.on('SIGTERM', shutdown);
69
+ }
70
+
71
+ getLocalIP() {
72
+ const interfaces = os.networkInterfaces();
73
+ for (const name of Object.keys(interfaces)) {
74
+ for (const iface of interfaces[name]) {
75
+ if (iface.family === 'IPv4' && !iface.internal) {
76
+ return iface.address;
77
+ }
78
+ }
79
+ }
80
+ return '127.0.0.1';
81
+ }
82
+ }
83
+
84
+ module.exports = { Daemon };
@@ -0,0 +1,30 @@
1
+ class Heartbeat {
2
+ constructor(api, workerId, intervalMs = 30000) {
3
+ this.api = api;
4
+ this.workerId = workerId;
5
+ this.intervalMs = intervalMs;
6
+ this.timer = null;
7
+ }
8
+
9
+ start() {
10
+ this.send(); // immediate first heartbeat
11
+ this.timer = setInterval(() => this.send(), this.intervalMs);
12
+ }
13
+
14
+ stop() {
15
+ if (this.timer) {
16
+ clearInterval(this.timer);
17
+ this.timer = null;
18
+ }
19
+ }
20
+
21
+ async send() {
22
+ try {
23
+ await this.api.heartbeat(this.workerId);
24
+ } catch (err) {
25
+ console.error(`[heartbeat] Failed: ${err.message}`);
26
+ }
27
+ }
28
+ }
29
+
30
+ module.exports = { Heartbeat };
@@ -0,0 +1,122 @@
1
+ class JobExecutor {
2
+ constructor(api, config) {
3
+ this.api = api;
4
+ this.config = config;
5
+ }
6
+
7
+ async execute(job) {
8
+ const jobId = job._id;
9
+ console.log(`[executor] Starting job ${jobId} — ${job.flow_type} for channel ${job.channel_id}`);
10
+
11
+ try {
12
+ // 1. Get channel + master info
13
+ await this.updateStatus(jobId, 'sourcing');
14
+ const channel = await this.api.getChannel(job.channel_id);
15
+ const master = await this.api.getMasterChannel(channel.master_id);
16
+ const brandKit = master.brand_kit || {};
17
+
18
+ console.log(`[executor] Channel: ${channel.name} | Master: ${master.name} | Profile: ${channel.nst_profile_id}`);
19
+
20
+ // 2. Launch Nstbrowser profile
21
+ if (channel.nst_profile_id) {
22
+ await this.updateStatus(jobId, 'rendering', { progress: 10 });
23
+ await this.launchProfile(channel.nst_profile_id);
24
+ }
25
+
26
+ // 3. Execute based on flow type
27
+ switch (job.flow_type) {
28
+ case 'clone':
29
+ await this.executeClone(jobId, job, channel, brandKit);
30
+ break;
31
+ case 'generate':
32
+ await this.executeGenerate(jobId, job, channel, brandKit);
33
+ break;
34
+ case 'repurpose':
35
+ await this.executeRepurpose(jobId, job, channel, brandKit);
36
+ break;
37
+ default:
38
+ throw new Error(`Unknown flow type: ${job.flow_type}`);
39
+ }
40
+
41
+ // 4. Post-process
42
+ await this.updateStatus(jobId, 'post_processing', { progress: 80 });
43
+ // FFmpeg captions, trim, etc — TODO
44
+
45
+ // 5. Publish to platforms
46
+ await this.updateStatus(jobId, 'publishing', { progress: 90 });
47
+ // YouTube/TikTok/FB upload — TODO
48
+
49
+ // 6. Done
50
+ await this.updateStatus(jobId, 'published', { progress: 100 });
51
+ console.log(`[executor] Job ${jobId} completed successfully`);
52
+
53
+ } catch (err) {
54
+ console.error(`[executor] Job ${jobId} failed: ${err.message}`);
55
+ await this.api.updateJob(jobId, {
56
+ status: 'failed',
57
+ error_message: err.message,
58
+ });
59
+ }
60
+ }
61
+
62
+ async executeClone(jobId, job, channel, brandKit) {
63
+ // Step 1: Download source video
64
+ await this.updateStatus(jobId, 'sourcing', { progress: 20 });
65
+ console.log(`[executor:clone] Source: ${job.source_url}`);
66
+ // TODO: yt-dlp download
67
+
68
+ // Step 2: Transcribe
69
+ await this.updateStatus(jobId, 'scripting', { progress: 40 });
70
+ // TODO: Whisper/Groq transcribe
71
+
72
+ // Step 3: Rewrite script with brand voice
73
+ console.log(`[executor:clone] Rewriting with brand voice: ${brandKit.voice?.personality || 'default'}`);
74
+ // TODO: Claude API rewrite
75
+
76
+ // Step 4: Render new video
77
+ await this.updateStatus(jobId, 'rendering', { progress: 60 });
78
+ // TODO: Veo3 via extension auto-pilot OR Hailuo API
79
+ }
80
+
81
+ async executeGenerate(jobId, job, channel, brandKit) {
82
+ // Step 1: Generate script
83
+ await this.updateStatus(jobId, 'scripting', { progress: 30 });
84
+ console.log(`[executor:generate] Generating script for: ${job.caption || 'topic from planner'}`);
85
+ // TODO: Claude API generate
86
+
87
+ // Step 2: Render video
88
+ await this.updateStatus(jobId, 'rendering', { progress: 60 });
89
+ // TODO: Veo3 via extension OR Hailuo API
90
+ }
91
+
92
+ async executeRepurpose(jobId, job, channel, brandKit) {
93
+ // Step 1: Download long-form video
94
+ await this.updateStatus(jobId, 'sourcing', { progress: 20 });
95
+ // TODO: yt-dlp download
96
+
97
+ // Step 2: Extract clips
98
+ await this.updateStatus(jobId, 'rendering', { progress: 50 });
99
+ // TODO: Jiang-Clips or FunClip
100
+
101
+ // Step 3: Add captions
102
+ await this.updateStatus(jobId, 'post_processing', { progress: 70 });
103
+ // TODO: FFmpeg captions
104
+ }
105
+
106
+ async launchProfile(profileId) {
107
+ // TODO: Nstbrowser SDK launch profile
108
+ console.log(`[executor] Launching Nstbrowser profile: ${profileId}`);
109
+ // const { wsEndpoint } = await nstApi.launchProfile(profileId);
110
+ // return wsEndpoint;
111
+ }
112
+
113
+ async updateStatus(jobId, status, extra = {}) {
114
+ const update = { status, ...extra };
115
+ if (extra.progress) {
116
+ update.metadata = { progress: extra.progress };
117
+ }
118
+ await this.api.updateJob(jobId, update);
119
+ }
120
+ }
121
+
122
+ module.exports = { JobExecutor };
@@ -0,0 +1,63 @@
1
+ const { JobExecutor } = require('./job-executor');
2
+
3
+ class JobPoller {
4
+ constructor(api, config) {
5
+ this.api = api;
6
+ this.config = config;
7
+ this.executor = new JobExecutor(api, config);
8
+ this.running = new Set(); // job IDs currently executing
9
+ this.timer = null;
10
+ this.pollIntervalMs = 5000;
11
+ }
12
+
13
+ start() {
14
+ console.log(`[poller] Started — max concurrent: ${this.config.max_concurrent}`);
15
+ this.poll(); // immediate first poll
16
+ this.timer = setInterval(() => this.poll(), this.pollIntervalMs);
17
+ }
18
+
19
+ stop() {
20
+ if (this.timer) {
21
+ clearInterval(this.timer);
22
+ this.timer = null;
23
+ }
24
+ }
25
+
26
+ get availableSlots() {
27
+ return this.config.max_concurrent - this.running.size;
28
+ }
29
+
30
+ async poll() {
31
+ if (this.availableSlots <= 0) return;
32
+
33
+ try {
34
+ const job = await this.api.getNextJob(this.config.worker_id);
35
+ if (!job) return; // no jobs in queue
36
+
37
+ const jobId = job._id;
38
+ if (this.running.has(jobId)) return; // already running
39
+
40
+ // Claim the job
41
+ await this.api.claimJob(jobId, this.config.worker_id);
42
+ this.running.add(jobId);
43
+
44
+ console.log(`[poller] Claimed job ${jobId} (${this.running.size}/${this.config.max_concurrent} slots used)`);
45
+
46
+ // Execute in background — don't await
47
+ this.executor.execute(job)
48
+ .finally(() => {
49
+ this.running.delete(jobId);
50
+ console.log(`[poller] Slot freed (${this.running.size}/${this.config.max_concurrent})`);
51
+ // Immediately try to pick next job
52
+ if (this.availableSlots > 0) this.poll();
53
+ });
54
+
55
+ } catch (err) {
56
+ if (this.config.verbose) {
57
+ console.error(`[poller] Poll error: ${err.message}`);
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ module.exports = { JobPoller };
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "channel-worker",
3
+ "version": "1.0.0",
4
+ "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
+ "main": "lib/daemon.js",
6
+ "bin": {
7
+ "channel-worker": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/"
12
+ ],
13
+ "scripts": {
14
+ "start": "node bin/cli.js start",
15
+ "dev": "node bin/cli.js start --verbose"
16
+ },
17
+ "dependencies": {
18
+ "node-fetch": "^3.3.2"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "license": "MIT"
24
+ }