channel-worker 1.3.9 → 1.5.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 CHANGED
@@ -128,9 +128,32 @@ if (cmd === 'pair') {
128
128
  // Save merged config for next time
129
129
  saveConfig(config);
130
130
 
131
- const { Daemon } = require('../lib/daemon');
132
- const daemon = new Daemon(config);
133
- daemon.start();
131
+ if (args._daemon) {
132
+ // Actually run the daemon (spawned by ourselves)
133
+ const { Daemon } = require('../lib/daemon');
134
+ const daemon = new Daemon(config);
135
+ daemon.start();
136
+ } else {
137
+ // Spawn detached background process and exit
138
+ const { spawn } = require('child_process');
139
+ const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
140
+ const logFd = fs.openSync(LOG_FILE, 'a');
141
+
142
+ const child = spawn(process.execPath, [__filename, 'start', '--_daemon'], {
143
+ detached: true,
144
+ stdio: ['ignore', logFd, logFd],
145
+ cwd: os.homedir(),
146
+ });
147
+ child.unref();
148
+
149
+ // Save PID for stop command
150
+ const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
151
+ fs.writeFileSync(pidFile, String(child.pid));
152
+
153
+ console.log(`[channel-worker] Daemon started (PID: ${child.pid})`);
154
+ console.log(`[channel-worker] Logs: ${LOG_FILE}`);
155
+ process.exit(0);
156
+ }
134
157
 
135
158
  } else if (cmd === 'update') {
136
159
  const { checkAndUpdate, getLocalVersion } = require('../lib/updater');
@@ -150,6 +173,55 @@ if (cmd === 'pair') {
150
173
  }
151
174
  })();
152
175
 
176
+ } else if (cmd === 'stop') {
177
+ const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
178
+ if (!fs.existsSync(pidFile)) {
179
+ console.log('[channel-worker] No daemon running (no PID file).');
180
+ process.exit(0);
181
+ }
182
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
183
+ try {
184
+ process.kill(pid, 'SIGTERM');
185
+ fs.unlinkSync(pidFile);
186
+ console.log(`[channel-worker] Daemon stopped (PID: ${pid})`);
187
+ } catch (err) {
188
+ if (err.code === 'ESRCH') {
189
+ fs.unlinkSync(pidFile);
190
+ console.log(`[channel-worker] Daemon was not running (stale PID: ${pid}). Cleaned up.`);
191
+ } else {
192
+ console.error(`[channel-worker] Failed to stop: ${err.message}`);
193
+ process.exit(1);
194
+ }
195
+ }
196
+
197
+ } else if (cmd === 'logs') {
198
+ const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
199
+ if (!fs.existsSync(LOG_FILE)) {
200
+ console.log('[channel-worker] No log file found.');
201
+ process.exit(0);
202
+ }
203
+ const lines = parseInt(args.lines || '50', 10);
204
+ const content = fs.readFileSync(LOG_FILE, 'utf-8');
205
+ const tail = content.split('\n').slice(-lines).join('\n');
206
+ console.log(tail);
207
+
208
+ } else if (cmd === 'restart') {
209
+ // Stop existing daemon, then start new one
210
+ const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
211
+ if (fs.existsSync(pidFile)) {
212
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
213
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
214
+ fs.unlinkSync(pidFile);
215
+ console.log(`[channel-worker] Stopped old daemon (PID: ${pid})`);
216
+ }
217
+ // Re-invoke start
218
+ const { spawn } = require('child_process');
219
+ const child = spawn(process.execPath, [__filename, 'start'], {
220
+ stdio: 'inherit',
221
+ cwd: process.cwd(),
222
+ });
223
+ child.on('exit', (code) => process.exit(code));
224
+
153
225
  } else if (cmd === 'config') {
154
226
  const config = loadConfig();
155
227
  // Hide token in display
@@ -164,7 +236,10 @@ channel-worker — Channel Manager worker daemon
164
236
  Commands:
165
237
  pair Pair with dashboard using a one-time code (recommended)
166
238
  init Configure worker manually
167
- start Start the daemon (auto-checks for updates every 5min)
239
+ start Start the daemon in background
240
+ stop Stop the daemon
241
+ restart Restart the daemon
242
+ logs Show recent daemon logs (--lines <n>, default 50)
168
243
  update Check and install updates manually
169
244
  config Show current config
170
245
 
@@ -182,6 +257,8 @@ Options:
182
257
  Examples:
183
258
  channel-worker pair --code A3F1B2 --api https://api.channel.tunasm.art
184
259
  channel-worker start
260
+ channel-worker logs --lines 100
261
+ channel-worker stop
185
262
  `);
186
263
  }
187
264
 
package/lib/api-client.js CHANGED
@@ -29,8 +29,11 @@ class ApiClient {
29
29
  return this.request('POST', '/workers/register', workerData);
30
30
  }
31
31
 
32
- async heartbeat(workerId) {
33
- return this.request('POST', '/workers/heartbeat', { worker_id: workerId });
32
+ async heartbeat(workerId, version, extensionVersion) {
33
+ const body = { worker_id: workerId };
34
+ if (version) body.version = version;
35
+ if (extensionVersion) body.extension_version = extensionVersion;
36
+ return this.request('POST', '/workers/heartbeat', body);
34
37
  }
35
38
 
36
39
  // Jobs
@@ -62,7 +65,7 @@ class ApiClient {
62
65
 
63
66
  // Commands
64
67
  async getNextCommand(workerId) {
65
- const workerTypes = 'launch_profile,close_profile,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension';
68
+ const workerTypes = 'launch_profile,close_profile,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats';
66
69
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
67
70
  }
68
71
 
@@ -1,5 +1,6 @@
1
1
  const { NstManager } = require('./nst-manager');
2
2
  const { checkAndUpdateExtension } = require('./extension-updater');
3
+ const { StatsSyncer } = require('./stats-syncer');
3
4
 
4
5
  class CommandPoller {
5
6
  constructor(api, config) {
@@ -68,6 +69,9 @@ class CommandPoller {
68
69
  case 'update_extension':
69
70
  await this.handleUpdateExtension(command);
70
71
  break;
72
+ case 'sync_youtube_stats':
73
+ await this.handleSyncYoutubeStats(command);
74
+ break;
71
75
  default:
72
76
  // Other commands (scan_facebook_pages, etc.) handled by extension
73
77
  console.log(`[commands] Skipping ${command.type} — handled by extension`);
@@ -856,6 +860,48 @@ class CommandPoller {
856
860
  // TODO: launch headless, check cookies for Google/Facebook/TikTok
857
861
  await this.api.updateCommand(command._id, { status: 'done' });
858
862
  }
863
+
864
+ async handleSyncYoutubeStats(command) {
865
+ const { channel_id } = command.payload || {};
866
+ console.log(`[commands] Syncing YouTube stats for channel: ${channel_id}`);
867
+
868
+ try {
869
+ if (!this.nst) {
870
+ const apiKey = await this.api.getSetting('nst_api_key');
871
+ if (apiKey) {
872
+ this.nst = new NstManager(apiKey);
873
+ } else {
874
+ throw new Error('Nstbrowser API key not configured.');
875
+ }
876
+ }
877
+
878
+ // Fetch channel info
879
+ const channel = await this.api.getChannel(channel_id);
880
+ if (!channel) throw new Error(`Channel ${channel_id} not found`);
881
+ if (!channel.nst_profile_id) throw new Error(`Channel "${channel.name}" has no Nstbrowser profile`);
882
+
883
+ const syncer = new StatsSyncer(this.nst, this.api);
884
+ const stats = await syncer.syncYouTubeStats(channel);
885
+
886
+ await this.api.updateCommand(command._id, {
887
+ status: 'done',
888
+ result: {
889
+ channel_id,
890
+ channel_name: channel.name,
891
+ ...stats,
892
+ synced_at: new Date().toISOString(),
893
+ },
894
+ });
895
+
896
+ console.log(`[commands] YouTube stats synced for "${channel.name}"`);
897
+ } catch (err) {
898
+ console.error(`[commands] YouTube stats sync failed: ${err.message}`);
899
+ await this.api.updateCommand(command._id, {
900
+ status: 'failed',
901
+ error: err.message,
902
+ });
903
+ }
904
+ }
859
905
  }
860
906
 
861
907
  module.exports = { CommandPoller };
package/lib/daemon.js CHANGED
@@ -9,7 +9,7 @@ class Daemon {
9
9
  constructor(config) {
10
10
  this.config = config;
11
11
  this.api = new ApiClient(config.api_url, config.worker_token);
12
- this.heartbeat = new Heartbeat(this.api, config.worker_id);
12
+ this.heartbeat = new Heartbeat(this.api, config.worker_id, 30000, config);
13
13
  this.poller = new JobPoller(this.api, config);
14
14
  this.commandPoller = new CommandPoller(this.api, config);
15
15
  this.updateChecker = new UpdateChecker(5 * 60 * 1000); // check every 5min
package/lib/heartbeat.js CHANGED
@@ -1,8 +1,12 @@
1
+ const { getLocalVersion } = require('./updater');
2
+ const { getLocalExtensionVersion } = require('./extension-updater');
3
+
1
4
  class Heartbeat {
2
- constructor(api, workerId, intervalMs = 30000) {
5
+ constructor(api, workerId, intervalMs = 30000, config = {}) {
3
6
  this.api = api;
4
7
  this.workerId = workerId;
5
8
  this.intervalMs = intervalMs;
9
+ this.config = config;
6
10
  this.timer = null;
7
11
  }
8
12
 
@@ -20,7 +24,9 @@ class Heartbeat {
20
24
 
21
25
  async send() {
22
26
  try {
23
- await this.api.heartbeat(this.workerId);
27
+ const version = getLocalVersion();
28
+ const extVersion = getLocalExtensionVersion(this.config.extension_path);
29
+ await this.api.heartbeat(this.workerId, version, extVersion);
24
30
  } catch (err) {
25
31
  console.error(`[heartbeat] Failed: ${err.message}`);
26
32
  }
@@ -0,0 +1,385 @@
1
+ const WebSocket = require('ws');
2
+
3
+ /**
4
+ * Sync YouTube Studio stats via Nstbrowser CDP.
5
+ * Flow: Launch profile → connect CDP → navigate to Studio → scrape stats → close
6
+ */
7
+ class StatsSyncer {
8
+ constructor(nstManager, apiClient) {
9
+ this.nst = nstManager;
10
+ this.api = apiClient;
11
+ }
12
+
13
+ /**
14
+ * Sync stats for a single channel via YouTube Studio
15
+ * @param {object} channel - { _id, name, nst_profile_id, proxy }
16
+ * @returns {object} { subscribers, total_views, video_count }
17
+ */
18
+ async syncYouTubeStats(channel) {
19
+ const profileId = channel.nst_profile_id;
20
+ if (!profileId) throw new Error(`Channel "${channel.name}" has no nst_profile_id`);
21
+
22
+ console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
23
+
24
+ // Launch profile (no extension needed for stats)
25
+ const { wsEndpoint } = await this.nst.launchProfile(profileId, {
26
+ proxy: channel.proxy || null,
27
+ });
28
+
29
+ if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
30
+
31
+ try {
32
+ const stats = await this.scrapeYouTubeStudio(wsEndpoint);
33
+ console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
34
+
35
+ // Send to API
36
+ await this.api.request('POST', '/analytics/sync', {
37
+ channel_id: channel._id,
38
+ platform: 'youtube',
39
+ subscribers: stats.subscribers,
40
+ total_views: stats.total_views,
41
+ video_count: stats.video_count,
42
+ });
43
+
44
+ console.log(`[stats] "${channel.name}" stats saved to API`);
45
+ return stats;
46
+ } finally {
47
+ // Close browser after scraping
48
+ try {
49
+ await this.nst.stopProfile(profileId);
50
+ } catch (err) {
51
+ console.warn(`[stats] Failed to stop profile: ${err.message}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Connect to browser via CDP WebSocket and scrape YouTube Studio
58
+ */
59
+ async scrapeYouTubeStudio(wsEndpoint) {
60
+ return new Promise((resolve, reject) => {
61
+ const ws = new WebSocket(wsEndpoint);
62
+ let msgId = 1;
63
+ const pending = new Map();
64
+
65
+ const send = (method, params = {}) => {
66
+ return new Promise((res, rej) => {
67
+ const id = msgId++;
68
+ pending.set(id, { resolve: res, reject: rej });
69
+ ws.send(JSON.stringify({ id, method, params }));
70
+ });
71
+ };
72
+
73
+ const timeout = setTimeout(() => {
74
+ ws.close();
75
+ reject(new Error('YouTube Studio scrape timed out (60s)'));
76
+ }, 60000);
77
+
78
+ ws.on('error', (err) => {
79
+ clearTimeout(timeout);
80
+ reject(err);
81
+ });
82
+
83
+ ws.on('message', (data) => {
84
+ try {
85
+ const msg = JSON.parse(data);
86
+ if (msg.id && pending.has(msg.id)) {
87
+ const p = pending.get(msg.id);
88
+ pending.delete(msg.id);
89
+ if (msg.error) p.reject(new Error(msg.error.message));
90
+ else p.resolve(msg.result);
91
+ }
92
+ } catch {}
93
+ });
94
+
95
+ ws.on('open', async () => {
96
+ try {
97
+ // Get list of targets (pages)
98
+ const targets = await send('Target.getTargets');
99
+ const pages = targets.targetInfos.filter(t => t.type === 'page');
100
+
101
+ // Find or create a page
102
+ let targetId;
103
+ if (pages.length > 0) {
104
+ targetId = pages[0].targetId;
105
+ } else {
106
+ const created = await send('Target.createTarget', { url: 'about:blank' });
107
+ targetId = created.targetId;
108
+ }
109
+
110
+ // Attach to target
111
+ const { sessionId } = await send('Target.attachToTarget', {
112
+ targetId,
113
+ flatten: true,
114
+ });
115
+
116
+ // Helper to send commands to the attached session
117
+ const sessionSend = (method, params = {}) => {
118
+ return new Promise((res, rej) => {
119
+ const id = msgId++;
120
+ pending.set(id, { resolve: res, reject: rej });
121
+ ws.send(JSON.stringify({ id, method, params, sessionId }));
122
+ });
123
+ };
124
+
125
+ // Enable page events
126
+ await sessionSend('Page.enable');
127
+
128
+ // Navigate to YouTube Studio dashboard
129
+ await sessionSend('Page.navigate', {
130
+ url: 'https://studio.youtube.com',
131
+ });
132
+
133
+ // Wait for page to load
134
+ await this.waitForLoad(sessionSend, 15000);
135
+
136
+ // Wait a bit for Studio SPA to render
137
+ await this.sleep(5000);
138
+
139
+ // Try to scrape stats from YouTube Studio dashboard
140
+ const stats = await this.extractStudioStats(sessionSend);
141
+
142
+ clearTimeout(timeout);
143
+ ws.close();
144
+ resolve(stats);
145
+ } catch (err) {
146
+ clearTimeout(timeout);
147
+ ws.close();
148
+ reject(err);
149
+ }
150
+ });
151
+ });
152
+ }
153
+
154
+ async waitForLoad(sessionSend, timeoutMs = 15000) {
155
+ const start = Date.now();
156
+ while (Date.now() - start < timeoutMs) {
157
+ try {
158
+ const result = await sessionSend('Runtime.evaluate', {
159
+ expression: 'document.readyState',
160
+ returnByValue: true,
161
+ });
162
+ if (result?.result?.value === 'complete') return;
163
+ } catch {}
164
+ await this.sleep(500);
165
+ }
166
+ }
167
+
168
+ async extractStudioStats(sessionSend) {
169
+ // YouTube Studio dashboard shows channel-level stats
170
+ // We'll try multiple approaches to get the data
171
+
172
+ // Approach 1: Navigate to channel analytics overview and extract from the page
173
+ await sessionSend('Page.navigate', {
174
+ url: 'https://studio.youtube.com/channel/UC/analytics/tab-overview/period-default',
175
+ });
176
+ await this.sleep(5000);
177
+
178
+ // Try to get subscriber count from the Studio header/sidebar
179
+ const statsResult = await sessionSend('Runtime.evaluate', {
180
+ expression: `
181
+ (function() {
182
+ const stats = { subscribers: 0, total_views: 0, video_count: 0 };
183
+
184
+ // Try to get subscriber count from channel dashboard
185
+ // YouTube Studio shows subscriber count in various places
186
+ const allText = document.body.innerText || '';
187
+
188
+ // Method 1: Look for subscriber count in the top bar
189
+ // YouTube Studio typically shows "X subscribers" somewhere
190
+ const subMatch = allText.match(/([\\d,\\.]+[KMB]?)\\s*subscribers?/i);
191
+ if (subMatch) {
192
+ stats.subscribers = parseHumanNumber(subMatch[1]);
193
+ }
194
+
195
+ // Method 2: Look for specific YouTube Studio elements
196
+ // The channel dashboard header often has subscriber count
197
+ const headerEl = document.querySelector('#channel-title .subscriber-count, .subscriber-count, [class*="subscriber"]');
198
+ if (headerEl) {
199
+ const text = headerEl.textContent.trim();
200
+ const num = text.match(/([\\d,\\.]+[KMB]?)/);
201
+ if (num) stats.subscribers = parseHumanNumber(num[1]);
202
+ }
203
+
204
+ // Try ytInitialData or ytcfg for exact numbers
205
+ try {
206
+ if (window.ytcfg) {
207
+ const data = window.ytcfg.data_ || {};
208
+ // Look through ytcfg for subscriber info
209
+ }
210
+ } catch {}
211
+
212
+ function parseHumanNumber(str) {
213
+ if (!str) return 0;
214
+ str = str.replace(/,/g, '').trim();
215
+ const mult = str.match(/([\\d\\.]+)\\s*([KMB])/i);
216
+ if (mult) {
217
+ const num = parseFloat(mult[1]);
218
+ const suffix = mult[2].toUpperCase();
219
+ if (suffix === 'K') return Math.round(num * 1000);
220
+ if (suffix === 'M') return Math.round(num * 1000000);
221
+ if (suffix === 'B') return Math.round(num * 1000000000);
222
+ }
223
+ return parseInt(str, 10) || 0;
224
+ }
225
+
226
+ return JSON.stringify(stats);
227
+ })()
228
+ `,
229
+ returnByValue: true,
230
+ });
231
+
232
+ let stats = { subscribers: 0, total_views: 0, video_count: 0 };
233
+ try {
234
+ stats = JSON.parse(statsResult?.result?.value || '{}');
235
+ } catch {}
236
+
237
+ // Approach 2: Use YouTube Studio's internal API via fetch in page context
238
+ // This gives exact numbers from the Studio API
239
+ const apiResult = await sessionSend('Runtime.evaluate', {
240
+ expression: `
241
+ (async function() {
242
+ try {
243
+ // YouTube Studio uses internal API calls
244
+ // We can intercept the channel data from the page's __INITIAL_DATA__ or similar
245
+
246
+ // Try getting data from the Studio SPA state
247
+ const scripts = document.querySelectorAll('script');
248
+ for (const s of scripts) {
249
+ const text = s.textContent || '';
250
+
251
+ // Look for serialized channel data
252
+ if (text.includes('subscriberCount') || text.includes('subscriber_count')) {
253
+ const subMatch = text.match(/"subscriberCount"\\s*:\\s*"?(\\d+)"?/);
254
+ const viewMatch = text.match(/"viewCount"\\s*:\\s*"?(\\d+)"?/);
255
+ const videoMatch = text.match(/"videoCount"\\s*:\\s*"?(\\d+)"?/);
256
+
257
+ if (subMatch || viewMatch || videoMatch) {
258
+ return JSON.stringify({
259
+ subscribers: parseInt(subMatch?.[1] || '0', 10),
260
+ total_views: parseInt(viewMatch?.[1] || '0', 10),
261
+ video_count: parseInt(videoMatch?.[1] || '0', 10),
262
+ });
263
+ }
264
+ }
265
+ }
266
+
267
+ // Fallback: try YouTube Data endpoint from within the page (same origin, authenticated)
268
+ const res = await fetch('https://studio.youtube.com/youtubei/v1/creator/get_creator_channels?key=AIzaSyBUPetSUmoZL-OhlxA7wSac5XinrGktHmQ', {
269
+ method: 'POST',
270
+ headers: { 'Content-Type': 'application/json' },
271
+ body: JSON.stringify({
272
+ context: {
273
+ client: { clientName: 62, clientVersion: '1.0' },
274
+ },
275
+ }),
276
+ });
277
+
278
+ if (res.ok) {
279
+ const data = await res.json();
280
+ const ch = data?.channels?.[0] || {};
281
+ return JSON.stringify({
282
+ subscribers: parseInt(ch.subscriberCount || '0', 10),
283
+ total_views: parseInt(ch.viewCount || '0', 10),
284
+ video_count: parseInt(ch.videoCount || '0', 10),
285
+ });
286
+ }
287
+ } catch (e) {
288
+ return JSON.stringify({ error: e.message });
289
+ }
290
+ return JSON.stringify({});
291
+ })()
292
+ `,
293
+ awaitPromise: true,
294
+ returnByValue: true,
295
+ });
296
+
297
+ try {
298
+ const apiStats = JSON.parse(apiResult?.result?.value || '{}');
299
+ if (!apiStats.error) {
300
+ // Merge: prefer API data if available
301
+ if (apiStats.subscribers > 0) stats.subscribers = apiStats.subscribers;
302
+ if (apiStats.total_views > 0) stats.total_views = apiStats.total_views;
303
+ if (apiStats.video_count > 0) stats.video_count = apiStats.video_count;
304
+ }
305
+ } catch {}
306
+
307
+ // Approach 3: Navigate to the public channel page as fallback
308
+ if (stats.subscribers === 0 && stats.total_views === 0) {
309
+ console.log('[stats] Studio scrape returned no data, trying public channel page...');
310
+ await sessionSend('Page.navigate', {
311
+ url: 'https://www.youtube.com/@me',
312
+ });
313
+ await this.waitForLoad(sessionSend, 10000);
314
+ await this.sleep(3000);
315
+
316
+ const publicResult = await sessionSend('Runtime.evaluate', {
317
+ expression: `
318
+ (function() {
319
+ const stats = { subscribers: 0, total_views: 0, video_count: 0 };
320
+
321
+ // ytInitialData contains channel info
322
+ try {
323
+ const data = window.ytInitialData;
324
+ const header = data?.header?.c4TabbedHeaderRenderer || data?.header?.pageHeaderRenderer;
325
+
326
+ // Subscriber count
327
+ const subText = header?.subscriberCountText?.simpleText || '';
328
+ const subMatch = subText.match(/([\\d,\\.]+[KMB]?)/);
329
+ if (subMatch) {
330
+ stats.subscribers = parseHumanNumber(subMatch[1]);
331
+ }
332
+
333
+ // Video count from tabs
334
+ const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
335
+ for (const tab of tabs) {
336
+ const title = tab?.tabRenderer?.title || '';
337
+ if (title === 'Videos' || title === 'Shorts') {
338
+ // Video tab might show count
339
+ }
340
+ }
341
+
342
+ // Try metadata
343
+ const meta = data?.metadata?.channelMetadataRenderer;
344
+ if (meta) {
345
+ // Some data available here
346
+ }
347
+ } catch {}
348
+
349
+ function parseHumanNumber(str) {
350
+ if (!str) return 0;
351
+ str = str.replace(/,/g, '').trim();
352
+ const mult = str.match(/([\\d\\.]+)\\s*([KMB])/i);
353
+ if (mult) {
354
+ const num = parseFloat(mult[1]);
355
+ const suffix = mult[2].toUpperCase();
356
+ if (suffix === 'K') return Math.round(num * 1000);
357
+ if (suffix === 'M') return Math.round(num * 1000000);
358
+ if (suffix === 'B') return Math.round(num * 1000000000);
359
+ }
360
+ return parseInt(str, 10) || 0;
361
+ }
362
+
363
+ return JSON.stringify(stats);
364
+ })()
365
+ `,
366
+ returnByValue: true,
367
+ });
368
+
369
+ try {
370
+ const publicStats = JSON.parse(publicResult?.result?.value || '{}');
371
+ if (publicStats.subscribers > 0) stats.subscribers = publicStats.subscribers;
372
+ if (publicStats.total_views > 0) stats.total_views = publicStats.total_views;
373
+ if (publicStats.video_count > 0) stats.video_count = publicStats.video_count;
374
+ } catch {}
375
+ }
376
+
377
+ return stats;
378
+ }
379
+
380
+ sleep(ms) {
381
+ return new Promise(resolve => setTimeout(resolve, ms));
382
+ }
383
+ }
384
+
385
+ module.exports = { StatsSyncer };
package/lib/updater.js CHANGED
@@ -43,23 +43,31 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
43
43
  installUpdate(latest);
44
44
 
45
45
  if (autoRestart) {
46
- console.log('[updater] Restarting daemon...');
46
+ console.log('[updater] Restarting daemon with new version...');
47
47
  const { spawn } = require('child_process');
48
- const isWindows = process.platform === 'win32';
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+ const os = require('os');
49
51
 
50
- // Use the global binary name instead of process.argv to pick up new version
51
- const cmd = isWindows ? 'cmd' : 'sh';
52
- const args = isWindows
53
- ? ['/c', 'timeout /t 2 /nobreak >nul && channel-worker start']
54
- : ['-c', 'sleep 2 && channel-worker start'];
52
+ const CONFIG_DIR = path.join(os.homedir(), '.channel-worker');
53
+ const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
54
+ const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
55
+ const logFd = fs.openSync(LOG_FILE, 'a');
55
56
 
56
- const child = spawn(cmd, args, {
57
+ // Spawn new daemon using global binary (picks up new version)
58
+ const isWindows = process.platform === 'win32';
59
+ const binName = isWindows ? 'channel-worker.cmd' : 'channel-worker';
60
+ const child = spawn(binName, ['start'], {
57
61
  detached: true,
58
- stdio: 'ignore',
59
- cwd: process.cwd(),
60
- shell: false,
62
+ stdio: ['ignore', logFd, logFd],
63
+ cwd: os.homedir(),
64
+ shell: isWindows,
61
65
  });
62
66
  child.unref();
67
+
68
+ // Update PID file
69
+ fs.writeFileSync(pidFile, String(child.pid));
70
+
63
71
  process.exit(0);
64
72
  }
65
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "1.3.9",
3
+ "version": "1.5.0",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {