channel-worker 2.4.5 → 2.5.1

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/lib/api-client.js CHANGED
@@ -66,7 +66,9 @@ class ApiClient {
66
66
 
67
67
  // Commands
68
68
  async getNextCommand(workerId) {
69
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker';
69
+ // Daemon-handled types. `_pw` variants route to the Playwright pipeline
70
+ // (lib/playwright-runner → scripts/<base>.js) instead of the extension.
71
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw';
70
72
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
71
73
  }
72
74
 
@@ -74,6 +76,21 @@ class ApiClient {
74
76
  return this.request('PUT', `/workers/commands/${commandId}`, data);
75
77
  }
76
78
 
79
+ // Report a command result via the extension-style endpoint, which also
80
+ // mirrors per-platform status onto the ContentJob + ContentIdea (so the
81
+ // idea UI reflects pw-pipeline outcomes the same way it does for extension
82
+ // uploads). Use this for upload_*_pw results.
83
+ async postCommandResult(commandId, { status, result, error } = {}) {
84
+ return this.request('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
85
+ }
86
+
87
+ // Return the calling daemon's own Worker doc — primarily for reading
88
+ // parallel_limit (the per-daemon scene-generation concurrency cap that
89
+ // replaced the legacy global flowkit_max_concurrent setting).
90
+ async getMyWorker() {
91
+ return this.request('GET', '/workers/me');
92
+ }
93
+
77
94
  // Scene dispatch
78
95
  async getRenderers() {
79
96
  return this.request('GET', '/workers/renderers');
@@ -107,6 +124,20 @@ class ApiClient {
107
124
  return this.request('POST', '/workers/reset-renderer-commands', { nst_profile_id: nstProfileId });
108
125
  }
109
126
 
127
+ // Atomic acquire/refresh lease — daemon MUST call this and check success
128
+ // BEFORE invoking nst.launch(). Without the lease, two daemons sharing the
129
+ // same NST account could both open the same profile and corrupt sessions.
130
+ // Returns { success: bool, message?: string, data?: { nst_profile_id, leased_at, lessee } }.
131
+ async leaseRenderer(nstProfileId) {
132
+ return this.request('POST', '/veo3-workers/lease', { nst_profile_id: nstProfileId });
133
+ }
134
+
135
+ // Atomic release — call when daemon decides to close the profile (manual
136
+ // stop, parallel_limit drop, crash recovery, etc.).
137
+ async releaseRenderer(nstProfileId) {
138
+ return this.request('POST', '/veo3-workers/release', { nst_profile_id: nstProfileId });
139
+ }
140
+
110
141
  // Extension download
111
142
  async getExtensionVersion() {
112
143
  const data = await this.request('GET', '/extension-download/version');
@@ -125,6 +156,13 @@ class ApiClient {
125
156
  fs.writeFileSync(destPath, buffer);
126
157
  return destPath;
127
158
  }
159
+
160
+ // Ship a log line into the extension_log stream (same backend store the
161
+ // browser extension uses). Lets Playwright-script logs show up next to
162
+ // extension logs in the dashboard. Fire-and-forget — caller .catch()'s.
163
+ async postExtensionLog({ level = 'info', message = '', data = null, profile_id = '', job_id = null } = {}) {
164
+ return this.request('POST', '/extension/log', { level, message, data, profile_id, job_id });
165
+ }
128
166
  }
129
167
 
130
168
  module.exports = { ApiClient };
@@ -105,6 +105,14 @@ class CommandPoller {
105
105
  await this.handleSetProfileProxy(command);
106
106
  break;
107
107
  default:
108
+ // Playwright-based pipeline: any command whose type ends in '_pw'
109
+ // is routed to scripts/<base>.js (BrowserClaw-style automation
110
+ // attached to the NST profile via CDP). Keeps the legacy extension
111
+ // path alive for non-_pw commands during migration.
112
+ if (command.type.endsWith('_pw')) {
113
+ await this.handlePlaywrightCommand(command);
114
+ break;
115
+ }
108
116
  // Other commands (scan_facebook_pages, etc.) handled by extension
109
117
  console.log(`[commands] Skipping ${command.type} — handled by extension`);
110
118
  // Reset to pending so extension can pick it up
@@ -117,6 +125,61 @@ class CommandPoller {
117
125
  }
118
126
  }
119
127
 
128
+ // Playwright-based command dispatch (pipeline replacing channel-manager-ext
129
+ // for upload + page-management tasks). Command type 'upload_youtube_pw' maps
130
+ // to scripts/upload_youtube.js, etc. The script handles its own profile
131
+ // launch via NST + CDP attach; the daemon orchestrates + ships the result.
132
+ async handlePlaywrightCommand(command) {
133
+ const { runPlaywrightScript } = require('./playwright-runner');
134
+ const payload = command.payload || {};
135
+ const profileId = payload.profile_id;
136
+ if (!profileId) {
137
+ await this.api.updateCommand(command._id, { status: 'failed', error: 'no profile_id in payload' });
138
+ return;
139
+ }
140
+ // Strip the '_pw' suffix to derive the script name (upload_youtube_pw → upload_youtube).
141
+ const scriptName = command.type.replace(/_pw$/, '');
142
+ console.log(`[commands/pw] ${command.type} → scripts/${scriptName}.js (profile=${profileId})`);
143
+
144
+ // Lazy-init NST manager (same pattern as the other handlers).
145
+ if (!this.nst) {
146
+ try {
147
+ const apiKey = await this.api.getSetting('nst_api_key');
148
+ if (apiKey) { const NstManager = require('./nst-manager'); this.nst = new NstManager(apiKey); }
149
+ } catch {}
150
+ if (!this.nst) {
151
+ await this.api.updateCommand(command._id, { status: 'failed', error: 'NST API key not configured' });
152
+ return;
153
+ }
154
+ }
155
+
156
+ // Funnel progress/info into the same extension_log stream so the dashboard
157
+ // can show pw commands' logs the same way as extension logs.
158
+ const log = (level, message, data) => {
159
+ console.log(`[pw:${scriptName}] [${level}] ${message}`);
160
+ this.api.postExtensionLog?.({ level, message, data, profile_id: profileId, job_id: payload.job_id || null })
161
+ .catch(() => {});
162
+ };
163
+
164
+ try {
165
+ const result = await runPlaywrightScript({
166
+ nst: this.nst,
167
+ profileId,
168
+ scriptName,
169
+ payload,
170
+ log,
171
+ });
172
+ // postCommandResult (not updateCommand) so the existing extension-side
173
+ // logic mirrors status into ContentJob + ContentIdea.publish_results +
174
+ // broadcasts the WS update — pw and extension paths share idea-state UX.
175
+ await this.api.postCommandResult(command._id, { status: 'done', result: result || {} });
176
+ console.log(`[commands/pw] ${command.type} done`);
177
+ } catch (err) {
178
+ console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
179
+ await this.api.postCommandResult(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
180
+ }
181
+ }
182
+
120
183
  async handleLaunchProfile(command) {
121
184
  const { profile_id, channel_id } = command.payload || {};
122
185
  console.log(`[commands] Launching Nstbrowser profile: ${profile_id}`);
@@ -150,13 +213,11 @@ class CommandPoller {
150
213
  const ts = Date.now();
151
214
  const uniqueExtPath = baseExtPath + '-' + profile_id + '-' + ts;
152
215
  try {
153
- fs.mkdirSync(uniqueExtPath, { recursive: true });
154
- const files = fs.readdirSync(baseExtPath);
155
- for (const f of files) {
156
- const src = path.join(baseExtPath, f);
157
- if (fs.statSync(src).isFile()) fs.copyFileSync(src, path.join(uniqueExtPath, f));
158
- }
159
- // Write profile-specific config.json
216
+ // Recursive copy base extension has subfolders (icons/, src/) that
217
+ // the manifest references; a file-only copy drops them and Chromium
218
+ // fails with "Could not load icon 'icons/icon16.png'".
219
+ fs.cpSync(baseExtPath, uniqueExtPath, { recursive: true });
220
+ // Write profile-specific config.json (overwrites the copied one)
160
221
  fs.writeFileSync(path.join(uniqueExtPath, 'config.json'), JSON.stringify({
161
222
  channelManagerApi: 'https://api.channel.tunasm.art',
162
223
  profileId: profile_id,
@@ -1037,7 +1098,18 @@ class CommandPoller {
1037
1098
  async handleCloseProfile(command) {
1038
1099
  const { profile_id } = command.payload || {};
1039
1100
  console.log(`[commands] Closing profile: ${profile_id}`);
1040
- // TODO: close Nstbrowser profile
1101
+ try {
1102
+ if (profile_id && this.nst && typeof this.nst.closeProfile === 'function') {
1103
+ await this.nst.closeProfile(profile_id).catch((e) => console.warn(`[commands] nst.closeProfile failed: ${e.message}`));
1104
+ }
1105
+ } finally {
1106
+ // Release the lease so another machine can pick up the profile if
1107
+ // user re-assigns ownership. Safe to call even if not currently held
1108
+ // (API no-ops when lessee mismatch).
1109
+ if (profile_id && this.api.releaseRenderer) {
1110
+ try { await this.api.releaseRenderer(profile_id); } catch {}
1111
+ }
1112
+ }
1041
1113
  await this.api.updateCommand(command._id, { status: 'done' });
1042
1114
  }
1043
1115
 
@@ -1241,16 +1313,29 @@ class CommandPoller {
1241
1313
  const domQ = typeof queueInfo === 'number' ? queueCount : (queueInfo?.dom_count || 0);
1242
1314
  if (!queueCount) { this._dispatching = false; return; }
1243
1315
 
1244
- // 5. Pick the right parallel limit based on queue composition
1245
- // - queue toàn flowkit → dùng flowkit_max_concurrent
1246
- // - queue toàn DOM → dùng veo3_parallel_limit
1247
- // - mixed (hiếm) → dùng max của 2 để không bottleneck nhánh nào
1248
- const flowkitLimit = parseInt(await this.api.getSetting('flowkit_max_concurrent')) || 5;
1249
- const veo3Limit = parseInt(await this.api.getSetting('veo3_parallel_limit')) || 1;
1250
- let parallelLimit;
1251
- if (flowkitQ > 0 && domQ === 0) parallelLimit = flowkitLimit;
1252
- else if (domQ > 0 && flowkitQ === 0) parallelLimit = veo3Limit;
1253
- else parallelLimit = Math.max(flowkitLimit, veo3Limit);
1316
+ // 5. Parallel limit sourced from THIS daemon's Worker.parallel_limit
1317
+ // via /workers/me. Replaces the legacy global Settings
1318
+ // (flowkit_max_concurrent / veo3_parallel_limit) which throttled
1319
+ // across all daemons of a user making multi-machine setup
1320
+ // impossible to scale (set global=1 only one machine runs at a
1321
+ // time). Server-side enforcement in /scene-dispatch +
1322
+ // /flowkit/claim-command uses the same field, so a daemon trying
1323
+ // to over-launch profiles will simply get yielded responses on
1324
+ // overflow. We still cap launches here to avoid keeping idle
1325
+ // profiles open (each NST profile holds RAM + a Chromium tab).
1326
+ let parallelLimit = 2; // sane default if /workers/me fails or is
1327
+ // unavailable on an older API; effective limit
1328
+ // is still bounded by server-side enforcement.
1329
+ try {
1330
+ if (this.api.getMyWorker) {
1331
+ const me = await this.api.getMyWorker();
1332
+ if (me && typeof me.parallel_limit === 'number') {
1333
+ parallelLimit = Math.max(0, me.parallel_limit);
1334
+ }
1335
+ }
1336
+ } catch (e) {
1337
+ if (this.config.verbose) console.warn('[scene-dispatch] getMyWorker failed, using default:', e.message);
1338
+ }
1254
1339
  const stillOffline = renderers.filter(r => !runningRenderers.includes(r));
1255
1340
  console.log(`[scene-dispatch] running=${runningRenderers.length}/${parallelLimit} (flowkit=${flowkitQ} dom=${domQ}) offline=${stillOffline.length} queue=${queueCount} names=[${runningRenderers.map(r=>r.name)}]`);
1256
1341
 
@@ -1312,6 +1397,20 @@ class CommandPoller {
1312
1397
  return;
1313
1398
  }
1314
1399
 
1400
+ // 6.5. Renew leases for ALL running renderers we manage. The lease has
1401
+ // a TTL (10 min server-side) — if we forget to renew, another
1402
+ // daemon could takeover after the TTL. Renewing every 5s poll
1403
+ // cycle keeps launched_at fresh well within the TTL.
1404
+ // Fire-and-forget; lease failures are logged but don't disrupt
1405
+ // dispatch (they're checked again at next _launchRendererProfile).
1406
+ if (this.api.leaseRenderer) {
1407
+ for (const r of runningRenderers) {
1408
+ this.api.leaseRenderer(r.nst_profile_id).catch((e) => {
1409
+ console.warn(`[scene-dispatch] lease renew failed for ${r.name || r.nst_profile_id}: ${e.message}`);
1410
+ });
1411
+ }
1412
+ }
1413
+
1315
1414
  // 7. Assign 1 command per free profile (no pending/running commands)
1316
1415
  let assigned = 0;
1317
1416
  for (const r of runningRenderers) {
@@ -1332,6 +1431,27 @@ class CommandPoller {
1332
1431
  }
1333
1432
 
1334
1433
  async _launchRendererProfile(renderer, options = {}) {
1434
+ // LEASE GUARD — acquire (or renew) the lease BEFORE physically launching
1435
+ // the Nstbrowser profile. The API atomic-update either grants or refuses;
1436
+ // if refused, another daemon (sharing the same NST account) currently
1437
+ // holds the profile and we must NOT touch it. Without this check the
1438
+ // two daemons would happily double-launch and corrupt cookies / kill
1439
+ // sessions for the underlying Google/FB accounts.
1440
+ if (renderer.nst_profile_id && this.api.leaseRenderer) {
1441
+ try {
1442
+ const leaseRes = await this.api.leaseRenderer(renderer.nst_profile_id);
1443
+ if (!leaseRes || leaseRes.success !== true) {
1444
+ const reason = leaseRes && leaseRes.message ? leaseRes.message : 'unknown';
1445
+ console.warn(`[scene-dispatch] LEASE DENIED for ${renderer.name || renderer.nst_profile_id}: ${reason} — skipping launch`);
1446
+ return;
1447
+ }
1448
+ } catch (e) {
1449
+ // Treat lease failure as a soft block — the API might be down OR
1450
+ // running an older version. Better to skip than risk double-launch.
1451
+ console.warn(`[scene-dispatch] lease request failed for ${renderer.name || renderer.nst_profile_id}: ${e.message} — skipping launch`);
1452
+ return;
1453
+ }
1454
+ }
1335
1455
  await this.nst.ensureProfile(renderer.nst_profile_id, { os: renderer.os || 'windows', proxy: renderer.proxy || null });
1336
1456
 
1337
1457
  const path = require('path');
@@ -0,0 +1,67 @@
1
+ // Runs a worker-script attached to an NSTBrowser profile over CDP.
2
+ // - Calls nst.launchProfile to get the live ws:// debugger URL.
3
+ // - playwright-core connectOverCDP — DOES NOT launch a new browser.
4
+ // - Script receives { page, context, browser, payload, log } and returns a result.
5
+ // - On exit we disconnect Playwright (browser.close on a CDP-attached instance is
6
+ // detach, NOT terminate) so the NST profile keeps running. The daemon's idle
7
+ // timer closes the profile later.
8
+ //
9
+ // IMPORTANT: do NOT inject stealth scripts here — NSTBrowser owns the
10
+ // fingerprint. Overlaying our own anti-detect would weaken it.
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ function loadScript(scriptName) {
16
+ // Allow only [a-z0-9_-] to avoid path traversal in case scriptName ever comes
17
+ // from a payload field. Resolved relative to ../scripts/.
18
+ if (!/^[a-zA-Z0-9_-]+$/.test(scriptName)) throw new Error(`bad script name: ${scriptName}`);
19
+ const scriptPath = path.resolve(__dirname, '..', 'scripts', `${scriptName}.js`);
20
+ if (!fs.existsSync(scriptPath)) throw new Error(`script not found: ${scriptPath}`);
21
+ // Clear cache so hot-edits to scripts on disk apply on the next dispatch
22
+ // without restarting the daemon (useful while tuning selectors).
23
+ delete require.cache[require.resolve(scriptPath)];
24
+ const mod = require(scriptPath);
25
+ if (typeof mod.run !== 'function') throw new Error(`${scriptName} has no exported run()`);
26
+ return mod;
27
+ }
28
+
29
+ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {} }) {
30
+ let playwright;
31
+ try { playwright = require('playwright-core'); }
32
+ catch (e) { throw new Error(`playwright-core not installed in channel-worker (npm i playwright-core in the worker dir): ${e.message}`); }
33
+
34
+ const script = loadScript(scriptName);
35
+
36
+ log('info', `[pw] launching NST profile ${profileId}…`);
37
+ // Don't load any Chromium extension — Playwright is the automation, no need
38
+ // for the channel-manager-ext on this path. (The chagpt cgpt path still
39
+ // launches with content-creator-ext via a different code path.)
40
+ const launchRes = await nst.launchProfile(profileId, { /* extensionPath omitted */ });
41
+ let wsEndpoint = launchRes?.wsEndpoint;
42
+
43
+ // If the profile was already running, launchProfile returns { alreadyRunning:
44
+ // true } without a wsEndpoint. Force-relaunch to get a fresh endpoint.
45
+ if (!wsEndpoint && launchRes?.alreadyRunning) {
46
+ log('info', `[pw] profile already running — force-relaunching for fresh CDP endpoint`);
47
+ const r = await nst.launchProfile(profileId, { forceRelaunch: true });
48
+ wsEndpoint = r?.wsEndpoint;
49
+ }
50
+ if (!wsEndpoint) throw new Error(`No webSocketDebuggerUrl from NST for profile ${profileId}`);
51
+
52
+ log('info', `[pw] connectOverCDP…`);
53
+ const browser = await playwright.chromium.connectOverCDP(wsEndpoint);
54
+ try {
55
+ const context = browser.contexts()[0] || await browser.newContext();
56
+ const page = context.pages()[0] || await context.newPage();
57
+ log('info', `[pw] running script: ${scriptName}`);
58
+ const result = await script.run({ page, context, browser, payload, log });
59
+ log('info', `[pw] script done: ${scriptName}`);
60
+ return result;
61
+ } finally {
62
+ // connectOverCDP: this DETACHES Playwright, the NST browser keeps running.
63
+ try { await browser.close(); } catch {}
64
+ }
65
+ }
66
+
67
+ module.exports = { runPlaywrightScript };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.4.5",
3
+ "version": "2.5.1",
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": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "lib/"
11
+ "lib/",
12
+ "scripts/"
12
13
  ],
13
14
  "scripts": {
14
15
  "start": "node bin/cli.js start",
@@ -16,6 +17,7 @@
16
17
  },
17
18
  "dependencies": {
18
19
  "node-fetch": "^3.3.2",
20
+ "playwright-core": "^1.48.0",
19
21
  "ws": "^8.18.0"
20
22
  },
21
23
  "engines": {
@@ -0,0 +1,50 @@
1
+ // Stream a URL to a temp file on disk. Returns the absolute path.
2
+ // Used by upload scripts: Playwright's setInputFiles needs a local path, and
3
+ // the NST profile is local so the local file is reachable.
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const https = require('https');
9
+ const http = require('http');
10
+
11
+ async function downloadToTemp(url, { prefix = 'pw', ext = '.mp4', maxRedirects = 5 } = {}) {
12
+ const dir = path.join(os.tmpdir(), 'cm-worker-pw');
13
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
14
+ const dest = path.join(dir, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
15
+
16
+ const fetchOnce = (u, redirectsLeft) => new Promise((resolve, reject) => {
17
+ const get = u.startsWith('https:') ? https.get : http.get;
18
+ const req = get(u, (res) => {
19
+ // Follow redirects manually — the media server signed URLs may bounce.
20
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
21
+ if (redirectsLeft <= 0) { reject(new Error('too many redirects')); return; }
22
+ res.resume();
23
+ const next = new URL(res.headers.location, u).toString();
24
+ fetchOnce(next, redirectsLeft - 1).then(resolve, reject);
25
+ return;
26
+ }
27
+ if (res.statusCode !== 200) {
28
+ let body = '';
29
+ res.on('data', (c) => { body += c.toString().slice(0, 200); });
30
+ res.on('end', () => reject(new Error(`download ${res.statusCode} from ${u}: ${body.slice(0, 200)}`)));
31
+ return;
32
+ }
33
+ const file = fs.createWriteStream(dest);
34
+ res.pipe(file);
35
+ file.on('finish', () => file.close(() => resolve(dest)));
36
+ file.on('error', (e) => { try { fs.unlinkSync(dest); } catch {} reject(e); });
37
+ });
38
+ req.on('error', reject);
39
+ req.setTimeout(60_000, () => req.destroy(new Error('download timeout')));
40
+ });
41
+
42
+ return fetchOnce(url, maxRedirects);
43
+ }
44
+
45
+ function safeUnlink(p) {
46
+ if (!p) return;
47
+ try { fs.unlinkSync(p); } catch {}
48
+ }
49
+
50
+ module.exports = { downloadToTemp, safeUnlink };
@@ -0,0 +1,85 @@
1
+ {
2
+ "_notes": "YouTube Studio upload selectors. Prefer accessibility (role/name) — patched in scripts via getByRole/getByLabel. Falls back to CSS for things Studio doesn't expose nicely (Polymer ytcp-* shadow DOM). Bump 'version' after each fix so it's clear in logs which set is live.",
3
+ "version": "2026.06.01-strict-inputs",
4
+ "studioUrl": "https://studio.youtube.com",
5
+ "createButton": {
6
+ "_notes": "2026 Studio dropped the #create-icon id — Create is now a plain <ytcp-button>Tạo</ytcp-button> (no id, no aria). Target the WRAPPER ytcp-button with exact text 'Tạo'/'Create' so click hits the Polymer handler that opens the dropdown (clicking the inner native button doesn't propagate the dropdown-open).",
7
+ "cssCandidates": [
8
+ "ytcp-button:text-is('Tạo')",
9
+ "ytcp-button:text-is('Create')",
10
+ "ytcp-button-shape:has(button:text-is('Tạo'))",
11
+ "ytcp-button-shape:has(button:text-is('Create'))",
12
+ "ytcp-icon-button#create-icon",
13
+ "ytcp-button#create-icon"
14
+ ],
15
+ "byRoleName": ["Create", "Tạo"]
16
+ },
17
+ "uploadMenuItem": {
18
+ "cssCandidates": [
19
+ "tp-yt-paper-item[role='menuitem']:has-text('Upload videos')",
20
+ "tp-yt-paper-item[role='menuitem']:has-text('Tải video lên')",
21
+ "ytcp-text-menu-item:has-text('Upload videos')",
22
+ "ytcp-text-menu-item:has-text('Tải video lên')",
23
+ "tp-yt-paper-item:has-text('Upload videos')",
24
+ "tp-yt-paper-item:has-text('Tải video lên')"
25
+ ],
26
+ "byMenuItemName": ["Upload videos", "Tải video lên"]
27
+ },
28
+ "fileInput": "input[type='file']",
29
+ "titleField": {
30
+ "cssCandidates": [
31
+ "ytcp-uploads-dialog #title-textarea #textbox",
32
+ "ytcp-uploads-dialog ytcp-mention-textbox[label='Title'] #textbox",
33
+ "#title-textarea #textbox"
34
+ ],
35
+ "byLabel": ["Title", "Tiêu đề"]
36
+ },
37
+ "descriptionField": {
38
+ "cssCandidates": [
39
+ "ytcp-uploads-dialog #description-textarea #textbox",
40
+ "ytcp-uploads-dialog ytcp-mention-textbox[label='Description'] #textbox",
41
+ "#description-textarea #textbox"
42
+ ],
43
+ "byLabel": ["Description", "Mô tả"]
44
+ },
45
+ "nextButton": {
46
+ "_notes": "Scope inside ytcp-uploads-dialog so we don't match Tiếp/Next from Studio's tutorial onboarding overlay (separate widget).",
47
+ "cssCandidates": [
48
+ "ytcp-uploads-dialog #next-button",
49
+ "ytcp-uploads-dialog ytcp-button#next-button",
50
+ "ytcp-uploads-dialog ytcp-button-shape #next-button"
51
+ ]
52
+ },
53
+ "visibilityRadio": {
54
+ "public": {
55
+ "cssCandidates": [
56
+ "ytcp-uploads-dialog tp-yt-paper-radio-button[name='PUBLIC']",
57
+ "tp-yt-paper-radio-button[name='PUBLIC']"
58
+ ]
59
+ },
60
+ "unlisted": {
61
+ "cssCandidates": [
62
+ "ytcp-uploads-dialog tp-yt-paper-radio-button[name='UNLISTED']",
63
+ "tp-yt-paper-radio-button[name='UNLISTED']"
64
+ ]
65
+ },
66
+ "private": {
67
+ "cssCandidates": [
68
+ "ytcp-uploads-dialog tp-yt-paper-radio-button[name='PRIVATE']",
69
+ "tp-yt-paper-radio-button[name='PRIVATE']"
70
+ ]
71
+ }
72
+ },
73
+ "publishButton": {
74
+ "cssCandidates": [
75
+ "ytcp-uploads-dialog #done-button",
76
+ "ytcp-uploads-dialog ytcp-button#done-button"
77
+ ]
78
+ },
79
+ "thumbnailUploadButton": {
80
+ "cssCandidates": [
81
+ "#file-loader",
82
+ "ytcp-thumbnails-compact-editor-uploader input[type='file']"
83
+ ]
84
+ }
85
+ }