@ing.jorgeu/wargaming-overlay 2.5.0 → 3.1.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/PUBLISHING.md +9 -1
- package/README.md +324 -11
- package/THIRD_PARTY_ASSETS.md +8 -0
- package/battle_reader.py +4 -1
- package/cli.js +27 -7
- package/game-store.js +2 -1
- package/package.json +19 -6
- package/public/army-list.css +28 -0
- package/public/army-list.html +1 -0
- package/public/army-list.js +106 -0
- package/public/control.html +2 -2
- package/public/control.js +7 -1
- package/public/detachments.css +2 -2
- package/public/remote-lists-control.js +21 -0
- package/public/roster-view.js +12 -0
- package/public/rosters-control.js +82 -0
- package/public/style.css +7 -0
- package/remote-lists/.env.example +16 -0
- package/remote-lists/backend.js +70 -0
- package/remote-lists/demo.js +4 -0
- package/remote-lists/dev.js +2 -0
- package/remote-lists/examples/demo-list.txt +10 -0
- package/remote-lists/importer.js +60 -0
- package/remote-lists/local.js +55 -0
- package/remote-lists/public/player.css +7 -0
- package/remote-lists/public/player.html +1 -0
- package/remote-lists/public/player.js +13 -0
- package/remote-lists/references.js +39 -0
- package/remote-lists/start.js +10 -0
- package/rosters.js +46 -0
- package/scripts/setup-kokoro.js +2 -0
- package/scripts/tts-demo.js +0 -1
- package/server.js +46 -4
- package/tts/kokoro-assets.js +27 -0
- package/tts/kokoro-engine.js +31 -0
- package/tts/kokoro-worker.js +13 -0
- package/tts/licenses/espeak-ng-COPYING +674 -0
- package/tts/playback.js +28 -0
- package/tts/speech.js +55 -3
- package/tts/voice-command.js +14 -0
- package/tts/wav.js +6 -0
- package/tts/windows-playback.js +14 -0
- package/youtube/index.js +5 -2
- package/youtube/live-chat.js +5 -3
- package/youtube/poll-chat.js +15 -0
- package/youtube/service.js +18 -9
package/tts/playback.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// No external player: the npm package contains native CoreAudio/WASAPI/ALSA bindings.
|
|
2
|
+
async function play(samples,sampleRate,{cpal=require('node-cpal')}={}){
|
|
3
|
+
let host,device,stream;
|
|
4
|
+
try{
|
|
5
|
+
host=cpal.defaultHost();device=host.defaultOutputDevice();
|
|
6
|
+
if(!device)throw Error('No hay salida de audio predeterminada.');
|
|
7
|
+
let supported=device.defaultOutputConfig();
|
|
8
|
+
if(supported.sampleFormat()!==cpal.SampleFormat.F32){
|
|
9
|
+
const candidate=[...device.supportedOutputConfigs()].find(c=>c.sampleFormat()===cpal.SampleFormat.F32);
|
|
10
|
+
if(!candidate)throw Error('La salida de audio no admite float32.');
|
|
11
|
+
supported=candidate.tryWithSampleRate(supported.sampleRate())||candidate.tryWithStandardSampleRate()||candidate.withMaxSampleRate();
|
|
12
|
+
}
|
|
13
|
+
const config=supported.config();let position=0,finished=false,drainTimer;
|
|
14
|
+
await new Promise((resolve,reject)=>{
|
|
15
|
+
stream=device.buildOutputStream(config,cpal.SampleFormat.F32,data=>{
|
|
16
|
+
for(let frame=0;frame<data.length/config.channels;frame++){
|
|
17
|
+
const left=Math.floor(position),fraction=position-left;
|
|
18
|
+
const sample=left<samples.length?(samples[left]*(1-fraction)+(samples[Math.min(left+1,samples.length-1)]||0)*fraction):0;
|
|
19
|
+
for(let channel=0;channel<config.channels;channel++)data[frame*config.channels+channel]=sample;
|
|
20
|
+
position+=sampleRate/config.sampleRate;
|
|
21
|
+
}
|
|
22
|
+
if(position>=samples.length&&!finished){finished=true;drainTimer=setTimeout(resolve,Math.ceil(data.length/config.channels/config.sampleRate*1000)+250);}
|
|
23
|
+
},error=>{clearTimeout(drainTimer);reject(error);});
|
|
24
|
+
stream.play();
|
|
25
|
+
});
|
|
26
|
+
}finally{stream?.close();device?.close();host?.close();}
|
|
27
|
+
}
|
|
28
|
+
module.exports={play};
|
package/tts/speech.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
const {spawn}=require('node:child_process');
|
|
2
|
-
function
|
|
2
|
+
function say(text,{spawnImpl=spawn,platform=process.platform,signal,timeoutMs=120000}={}){
|
|
3
3
|
if(platform!=='darwin')return Promise.reject(Error('El TTS local requiere macOS.'));
|
|
4
4
|
if(signal?.aborted)return Promise.resolve();
|
|
5
5
|
return new Promise((resolve,reject)=>{
|
|
6
6
|
// Feed text through stdin, not a shell/option. Remove Apple's embedded speech commands.
|
|
7
7
|
const child=spawnImpl('/usr/bin/say',[],{stdio:['pipe','ignore','pipe'],shell:false});
|
|
8
8
|
let timedOut=false,done=false;
|
|
9
|
-
const stop=()=>
|
|
9
|
+
const stop=()=>{
|
|
10
|
+
if(platform==='win32'&&child.pid){const killer=spawnImpl('taskkill.exe',['/PID',String(child.pid),'/T','/F'],{shell:false,windowsHide:true,stdio:'ignore'});killer.once('error',()=>child.kill('SIGKILL'));killer.unref?.();}
|
|
11
|
+
else child.kill('SIGKILL');
|
|
12
|
+
};
|
|
10
13
|
const timer=setTimeout(()=>{timedOut=true;stop();},timeoutMs);
|
|
11
14
|
const finish=(error)=>{if(done)return;done=true;clearTimeout(timer);signal?.removeEventListener('abort',stop);error?reject(error):resolve();};
|
|
12
15
|
signal?.addEventListener('abort',stop,{once:true});
|
|
@@ -17,4 +20,53 @@ function speak(text,{spawnImpl=spawn,platform=process.platform,signal,timeoutMs=
|
|
|
17
20
|
if(signal?.aborted)stop();
|
|
18
21
|
});
|
|
19
22
|
}
|
|
20
|
-
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const path=require('node:path'),fs=require('node:fs'),os=require('node:os');
|
|
26
|
+
const {cacheDirectory}=require('./kokoro-assets');
|
|
27
|
+
function settings(config={},env=process.env,platform=process.platform){
|
|
28
|
+
const opts={provider:env.TTS_PROVIDER||config.ttsProvider||'kokoro',fallback:env.TTS_FALLBACK||config.ttsFallback||(platform==='darwin'?'say':'none'),voice:env.KOKORO_VOICE||config.kokoroVoice||'ef_dora',speed:Number(env.KOKORO_SPEED??config.kokoroSpeed??1),cacheDir:env.KOKORO_CACHE_DIR||config.kokoroCacheDir||cacheDirectory()};
|
|
29
|
+
if(!['kokoro','say'].includes(opts.provider))throw Error('ttsProvider debe ser kokoro o say.');
|
|
30
|
+
if(!['say','none'].includes(opts.fallback))throw Error('ttsFallback debe ser say o none.');
|
|
31
|
+
if((opts.provider==='say'||opts.fallback==='say')&&platform!=='darwin')throw Error('say solo está disponible en macOS. Usa kokoro y ttsFallback=none.');
|
|
32
|
+
if(!['ef_dora','em_alex','em_santa'].includes(opts.voice))throw Error('kokoroVoice debe ser ef_dora, em_alex o em_santa.');
|
|
33
|
+
if(!Number.isFinite(opts.speed)||opts.speed<0.5||opts.speed>2)throw Error('kokoroSpeed debe estar entre 0.5 y 2.');
|
|
34
|
+
if(typeof opts.cacheDir!=='string'||!opts.cacheDir)throw Error('kokoroCacheDir debe ser una ruta.');
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
function kokoro(text,{spawnImpl=spawn,platform=process.platform,signal,timeoutMs=120000,log=console.log,...opts}={}){
|
|
38
|
+
if(signal?.aborted)return Promise.resolve();
|
|
39
|
+
return new Promise((resolve,reject)=>{
|
|
40
|
+
const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'overlay-tts-'));
|
|
41
|
+
const child=spawnImpl(process.execPath,[path.join(__dirname,'kokoro-worker.js')],{stdio:['pipe','ignore','pipe'],shell:false,windowsHide:true,cwd:temporary,...(temporary?{env:{...process.env,OVERLAY_TTS_TEMP_DIR:temporary}}:{})});
|
|
42
|
+
let done=false,timedOut=false;
|
|
43
|
+
const stop=()=>{
|
|
44
|
+
if(platform==='win32'&&child.pid){const killer=spawnImpl('taskkill.exe',['/PID',String(child.pid),'/T','/F'],{shell:false,windowsHide:true,stdio:'ignore'});killer.once('error',()=>child.kill('SIGKILL'));killer.unref?.();}
|
|
45
|
+
else child.kill('SIGKILL');
|
|
46
|
+
};
|
|
47
|
+
const timer=setTimeout(()=>{timedOut=true;stop();},timeoutMs);
|
|
48
|
+
const finish=error=>{if(done)return;done=true;clearTimeout(timer);signal?.removeEventListener('abort',stop);
|
|
49
|
+
const cleanup=temporary?require('node:fs/promises').rm(temporary,{recursive:true,force:true,maxRetries:5,retryDelay:100}).catch(()=>log('[TTS] No se pudo limpiar un archivo de audio temporal.')):Promise.resolve();
|
|
50
|
+
cleanup.then(()=>error?reject(error):resolve());
|
|
51
|
+
};
|
|
52
|
+
signal?.addEventListener('abort',stop,{once:true});
|
|
53
|
+
let stderr='';child.stderr?.setEncoding('utf8');child.stderr?.on('data',chunk=>{stderr=(stderr+chunk).slice(-4000);});
|
|
54
|
+
child.stdin.on('error',()=>{});
|
|
55
|
+
child.once('error',()=>finish(Error('No se pudo iniciar Kokoro.')));
|
|
56
|
+
child.once('close',code=>{if(stderr.trim())log(stderr.trim());finish(signal?.aborted?null:timedOut?Error('Se agotó el tiempo de Kokoro.'):code===0?null:Error('Falló Kokoro o la salida de audio.'));});
|
|
57
|
+
child.stdin.end(JSON.stringify({text:String(text),voice:opts.voice,speed:opts.speed,cacheDir:opts.cacheDir}));
|
|
58
|
+
if(signal?.aborted)stop();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function createSpeech(config={}, {env=process.env,platform=process.platform,log=console.log,spawnImpl=spawn}={}){
|
|
62
|
+
const opts=settings(config,env,platform);
|
|
63
|
+
return async(text,options={})=>{
|
|
64
|
+
if(options.signal?.aborted)return;
|
|
65
|
+
const common={...options,platform,spawnImpl,log};
|
|
66
|
+
if(opts.provider==='say')return say(text,common);
|
|
67
|
+
try{await kokoro(text,{...common,...opts});}
|
|
68
|
+
catch(e){if(options.signal?.aborted)return;if(opts.fallback!=='say')throw e;log('[TTS] Kokoro falló; leyendo este mensaje con say.');await say(text,common);}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function speak(text,options={}){return createSpeech(options.config,{env:options.env,platform:options.platform,log:options.log,spawnImpl:options.spawnImpl})(text,options);}
|
|
72
|
+
module.exports={speak,say,kokoro,createSpeech,settings};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const fs=require('node:fs'),path=require('node:path');
|
|
2
|
+
const {createSpeech}=require('./speech');
|
|
3
|
+
async function testVoice(text,{dataDir,provider,voice,speed,fallback}={}, {speechFactory=createSpeech,signals=process,env=process.env}={}){
|
|
4
|
+
if(typeof text!=='string'||!text.trim())throw Error('Indica un texto: wargaming-overlay --test-tts "Hola, esta es una prueba."');
|
|
5
|
+
let config={};
|
|
6
|
+
if(dataDir){try{config=JSON.parse(fs.readFileSync(path.join(dataDir,'youtube-tts.json'),'utf8'));}catch(e){if(e.code!=='ENOENT')throw Error('Configuración youtube-tts.json inválida.');}}
|
|
7
|
+
// CLI overrides environment and file settings, without changing the saved configuration.
|
|
8
|
+
const overrides={...env};
|
|
9
|
+
for(const [key,value] of Object.entries({TTS_PROVIDER:provider,KOKORO_VOICE:voice,KOKORO_SPEED:speed,TTS_FALLBACK:fallback}))if(value!==undefined)overrides[key]=String(value);
|
|
10
|
+
const speech=speechFactory(config,{env:overrides});const controller=new AbortController();
|
|
11
|
+
const stop=()=>controller.abort();signals.once('SIGINT',stop);signals.once('SIGTERM',stop);
|
|
12
|
+
try{await speech(text,{signal:controller.signal});}finally{signals.removeListener('SIGINT',stop);signals.removeListener('SIGTERM',stop);}
|
|
13
|
+
}
|
|
14
|
+
module.exports={testVoice};
|
package/tts/wav.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
function wav(samples,sampleRate){
|
|
2
|
+
const data=Buffer.alloc(44+samples.length*2);data.write('RIFF');data.writeUInt32LE(data.length-8,4);data.write('WAVEfmt ',8);data.writeUInt32LE(16,16);data.writeUInt16LE(1,20);data.writeUInt16LE(1,22);data.writeUInt32LE(sampleRate,24);data.writeUInt32LE(sampleRate*2,28);data.writeUInt16LE(2,32);data.writeUInt16LE(16,34);data.write('data',36);data.writeUInt32LE(samples.length*2,40);
|
|
3
|
+
for(let i=0;i<samples.length;i++)data.writeInt16LE(Math.round(Math.max(-1,Math.min(1,samples[i]))*32767),44+i*2);
|
|
4
|
+
return data;
|
|
5
|
+
}
|
|
6
|
+
module.exports={wav};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');
|
|
2
|
+
const {spawn}=require('node:child_process');const {wav}=require('./wav');
|
|
3
|
+
async function playWindows(samples,sampleRate,{spawnImpl=spawn}={}){
|
|
4
|
+
const dir=process.env.OVERLAY_TTS_TEMP_DIR||await fs.mkdtemp(path.join(os.tmpdir(),'overlay-tts-'));const file=path.join(dir,'speech.wav');
|
|
5
|
+
try{
|
|
6
|
+
await fs.writeFile(file,wav(samples,sampleRate));
|
|
7
|
+
await new Promise((resolve,reject)=>{
|
|
8
|
+
const child=spawnImpl('powershell.exe',['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop'; $player=New-Object System.Media.SoundPlayer; try { $player.SoundLocation=$env:OVERLAY_TTS_WAV; $player.Load(); $player.PlaySync() } finally { $player.Dispose() }"],{shell:false,windowsHide:true,stdio:'ignore',env:{...process.env,OVERLAY_TTS_WAV:file}});
|
|
9
|
+
child.once('error',()=>reject(Error('No se pudo abrir el audio integrado de Windows.')));
|
|
10
|
+
child.once('close',code=>code===0?resolve():reject(Error('Falló el audio integrado de Windows.')));
|
|
11
|
+
});
|
|
12
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
13
|
+
}
|
|
14
|
+
module.exports={playWindows};
|
package/youtube/index.js
CHANGED
|
@@ -5,16 +5,19 @@ function configFor(dataDir,env=process.env){
|
|
|
5
5
|
for(const [key,min,max] of [['maxMessageLength',50,300],['maxQueueLength',1,100]])if(!Number.isInteger(config[key])||config[key]<min||config[key]>max)throw Error(`Configuración ${key} fuera de rango.`);
|
|
6
6
|
for(const key of ['enabled','readUsername','filterUrls'])if(typeof config[key]!=='boolean')throw Error(`Configuración ${key} debe ser true/false.`);
|
|
7
7
|
if(!Array.isArray(config.extraBlockedTerms)||config.extraBlockedTerms.some(t=>typeof t!=='string'||t.length>100))throw Error('extraBlockedTerms debe ser una lista de textos cortos.');
|
|
8
|
+
if(config.httpFallback!==undefined&&typeof config.httpFallback!=='boolean')throw Error('httpFallback debe ser true/false.');
|
|
9
|
+
if(config.apiKeyFile){if(typeof config.apiKeyFile!=='string')throw Error('apiKeyFile debe ser una ruta.');try{config.apiKey=fs.readFileSync(path.resolve(config.apiKeyFile),'utf8').trim();}catch{throw Error('No se pudo leer apiKeyFile.');}if(!config.apiKey||/\s/.test(config.apiKey))throw Error('El archivo API key debe contener únicamente la clave.');}
|
|
8
10
|
return config;
|
|
9
11
|
}
|
|
10
12
|
function startYouTube(dataDir,{env=process.env,platform=process.platform,log=console.log}={}){
|
|
11
13
|
if(env.YOUTUBE_CHAT_TTS_ENABLED!=='true'){log('[YouTube] Chat TTS desactivado. Actívalo con YOUTUBE_CHAT_TTS_ENABLED=true.');return null;}
|
|
12
14
|
try{
|
|
13
|
-
if(
|
|
15
|
+
if(!['darwin','win32','linux'].includes(platform))throw Error('Sistema no compatible con TTS.');
|
|
14
16
|
const config=configFor(dataDir,env);if(!config.enabled){log('[YouTube] TTS desactivado en configuración.');return null;}
|
|
17
|
+
require('../tts/speech').settings(config,env,platform);
|
|
15
18
|
const {YouTubeService}=require('./service');
|
|
16
19
|
log('[YouTube] Iniciando chat TTS. Si falta autorización, ejecuta wargaming-overlay youtube-auth --client-secret /ruta/client.json.');
|
|
17
|
-
return new YouTubeService({config,log}).start();
|
|
20
|
+
return new YouTubeService({config,log,speech:require('../tts/speech').createSpeech(config,{env,platform,log})}).start();
|
|
18
21
|
}catch(e){log('[YouTube] '+e.message+' El overlay continúa funcionando.');return null;}
|
|
19
22
|
}
|
|
20
23
|
module.exports={startYouTube,configFor};
|
package/youtube/live-chat.js
CHANGED
|
@@ -3,7 +3,7 @@ function normalize(item){
|
|
|
3
3
|
if(item.snippet?.type!==1 || !item.id || typeof item.snippet.textMessageDetails?.messageText!=='string')return null;
|
|
4
4
|
return {id:item.id,type:'youtube_chat_message',author:item.authorDetails?.displayName||'Usuario',text:item.snippet.textMessageDetails.messageText,timestamp:item.snippet.publishedAt,messageType:'textMessageEvent'};
|
|
5
5
|
}
|
|
6
|
-
async function* streamChat({auth,liveChatId,pageToken,signal,createClient}){
|
|
6
|
+
async function* streamChat({auth,liveChatId,pageToken,signal,createClient,apiKey}){
|
|
7
7
|
const grpc=require('@grpc/grpc-js'),loader=require('@grpc/proto-loader');
|
|
8
8
|
const def=loader.loadSync(path.join(__dirname,'chat.proto'));
|
|
9
9
|
const Service=grpc.loadPackageDefinition(def).youtube.api.v3.V3DataLiveChatMessageService;
|
|
@@ -11,8 +11,10 @@ async function* streamChat({auth,liveChatId,pageToken,signal,createClient}){
|
|
|
11
11
|
let call;
|
|
12
12
|
const cancel=()=>call?.cancel();
|
|
13
13
|
try{
|
|
14
|
-
const
|
|
15
|
-
|
|
14
|
+
const meta=new grpc.Metadata();
|
|
15
|
+
if(apiKey)meta.set('x-goog-api-key',apiKey);
|
|
16
|
+
else meta.set('authorization',`Bearer ${await auth.token(signal)}`);
|
|
17
|
+
if(signal?.aborted)return;
|
|
16
18
|
// Reopen periodically with fresh credentials, retaining the continuation token.
|
|
17
19
|
call=client.streamList({liveChatId,pageToken,part:['id','snippet','authorDetails']},meta,{deadline:Date.now()+45*60*1000});
|
|
18
20
|
signal?.addEventListener('abort',cancel,{once:true});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const {setTimeout:delay}=require('node:timers/promises');
|
|
2
|
+
async function* pollChat({auth,liveChatId,pageToken,signal,fetchImpl=fetch,sleep=ms=>delay(ms,null,{signal})}){
|
|
3
|
+
while(!signal?.aborted){
|
|
4
|
+
const url=new URL('https://www.googleapis.com/youtube/v3/liveChat/messages');
|
|
5
|
+
url.search=new URLSearchParams({liveChatId,part:'id,snippet,authorDetails',...(pageToken?{pageToken}:{})});
|
|
6
|
+
const response=await fetchImpl(url,{headers:{Authorization:`Bearer ${await auth.token(signal)}`},signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000)});
|
|
7
|
+
const data=await response.json();
|
|
8
|
+
if(!response.ok){const reason=data.error?.errors?.[0]?.reason;const code=response.status===401?16:response.status===404?5:['liveChatEnded','liveChatDisabled'].includes(reason)?9:response.status===400?3:response.status===403?7:14;throw Object.assign(Error('No se pudo consultar el chat por HTTP.'),{code});}
|
|
9
|
+
yield {...data,items:(data.items||[]).map(item=>({...item,snippet:{...item.snippet,type:({textMessageEvent:1,chatEndedEvent:4})[item.snippet?.type]||0}}))};
|
|
10
|
+
if(data.offlineAt)return;
|
|
11
|
+
pageToken=data.nextPageToken||pageToken;
|
|
12
|
+
await sleep(Math.max(15000,Number(data.pollingIntervalMillis)||0));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
module.exports={pollChat};
|
package/youtube/service.js
CHANGED
|
@@ -3,13 +3,14 @@ const {setTimeout:delay}=require('node:timers/promises');
|
|
|
3
3
|
const {Auth}=require('./auth');
|
|
4
4
|
const {findBroadcast}=require('./live-broadcast');
|
|
5
5
|
const {streamChat,normalize}=require('./live-chat');
|
|
6
|
+
const {pollChat}=require('./poll-chat');
|
|
6
7
|
const {SpeechQueue}=require('../tts/queue');
|
|
7
8
|
const {sanitize}=require('../tts/sanitizer');
|
|
8
9
|
const backoff=[1000,2000,5000,10000,30000];
|
|
9
10
|
class YouTubeService extends EventEmitter {
|
|
10
|
-
constructor({config={},auth=new Auth(),discover=findBroadcast,stream=streamChat,queue,log=console.log,sleep=(ms,signal)=>delay(ms,null,{signal}),now=Date.now}={}){
|
|
11
|
-
super();this.config=config;this.auth=auth;this.discover=discover;this.stream=stream;this.log=log;this.sleep=sleep;this.now=now;
|
|
12
|
-
this.queue=queue||new SpeechQueue({maxQueueLength:config.maxQueueLength||10,log});this.state='DISCONNECTED';this.seen=new Set();this.controller=new AbortController();this.chat=null;this.pageToken=undefined;this.task=null;
|
|
11
|
+
constructor({config={},auth=new Auth(),discover=findBroadcast,stream=streamChat,poll=pollChat,queue,speech,log=console.log,sleep=(ms,signal)=>delay(ms,null,{signal}),now=Date.now}={}){
|
|
12
|
+
super();this.config=config;this.auth=auth;this.discover=discover;this.stream=stream;this.poll=poll;this.polling=false;this.log=log;this.sleep=sleep;this.now=now;
|
|
13
|
+
this.queue=queue||new SpeechQueue({maxQueueLength:config.maxQueueLength||10,speech:speech||require('../tts/speech').createSpeech(config,{log}),log});this.state='DISCONNECTED';this.seen=new Set();this.controller=new AbortController();this.chat=null;this.pageToken=undefined;this.task=null;
|
|
13
14
|
}
|
|
14
15
|
status(state,message){if(this.state!==state||this.lastMessage!==message){this.state=state;this.lastMessage=message;this.log('[YouTube] '+message);}}
|
|
15
16
|
start(){if(!this.task)this.task=this.run().catch(()=>{if(!this.controller.signal.aborted)this.status('DISCONNECTED','Servicio detenido por un error inesperado; el overlay continúa.');});return this;}
|
|
@@ -26,10 +27,11 @@ class YouTubeService extends EventEmitter {
|
|
|
26
27
|
this.chat=broadcast.liveChatId;this.pageToken=undefined;this.startedAt=this.now();
|
|
27
28
|
this.log(`[YouTube] Directo detectado${broadcast.multiple?' (hay varios; se usa el primero por ID)':''}.`);
|
|
28
29
|
}
|
|
29
|
-
let ended=false;
|
|
30
|
-
this.status('
|
|
31
|
-
for await(const batch of this.stream({auth:this.auth,liveChatId:this.chat,pageToken:this.pageToken,signal})){
|
|
30
|
+
let ended=false,received=false,continuation=false;
|
|
31
|
+
if(this.state!=='CONNECTED_TO_CHAT')this.status('CONNECTING_TO_CHAT',this.polling?'Conectando al chat mediante HTTP (respaldo, cada 15 s)…':'Conectando al chat en vivo mediante streamList…');
|
|
32
|
+
for await(const batch of (this.polling?this.poll:this.stream)({auth:this.auth,liveChatId:this.chat,pageToken:this.pageToken,signal,apiKey:this.config.apiKey})){
|
|
32
33
|
if(signal.aborted)break;
|
|
34
|
+
received=true;continuation=!!batch.nextPageToken;
|
|
33
35
|
failures=0;this.status('CONNECTED_TO_CHAT','Chat conectado; esperando mensajes nuevos.');
|
|
34
36
|
if(batch.offlineAt || (batch.items||[]).some(i=>i.snippet?.type===4)){ended=true;break;}
|
|
35
37
|
for(const item of batch.items||[]){
|
|
@@ -47,15 +49,22 @@ class YouTubeService extends EventEmitter {
|
|
|
47
49
|
this.pageToken=batch.nextPageToken||this.pageToken;
|
|
48
50
|
}
|
|
49
51
|
if(ended){this.endChat();await this.sleep(60000,signal);continue;}
|
|
50
|
-
if(!signal.aborted)
|
|
52
|
+
if(!signal.aborted){
|
|
53
|
+
// Successful EOF with a cursor is a continuation, not an authentication failure.
|
|
54
|
+
if(received&&continuation){await this.sleep(1000,signal);continue;}
|
|
55
|
+
throw Object.assign(Error('Chat stream ended without continuation'),{code:14});
|
|
56
|
+
}
|
|
51
57
|
}catch(e){
|
|
52
58
|
if(signal.aborted)break;
|
|
53
|
-
if(e.code===16)
|
|
59
|
+
if(e.code===16){
|
|
60
|
+
if(!this.config.apiKey||this.polling)this.auth.invalidate();
|
|
61
|
+
if(!this.polling&&this.config.httpFallback===true){this.polling=true;this.pageToken=undefined;this.status('DISCONNECTED','streamList rechazó las credenciales. Cambiando a HTTP de respaldo (cada 15 s).');continue;}
|
|
62
|
+
}
|
|
54
63
|
if([5,9].includes(e.code)){this.endChat();await this.sleep(60000,signal);continue;}
|
|
55
64
|
if(e.code===3)this.pageToken=undefined;
|
|
56
65
|
const ms=backoff[Math.min(failures++,backoff.length-1)];
|
|
57
66
|
// Do not print upstream payloads or request objects: they may contain credentials.
|
|
58
|
-
this.status('DISCONNECTED'
|
|
67
|
+
this.status('DISCONNECTED',e.code===16?`streamList/YouTube rechazó las credenciales (código 16). Reintento en ${ms/1000}s. Si OAuth funciona por HTTP, configura apiKeyFile para streamList. El respaldo HTTP requiere httpFallback: true.`:`Conexión/autorización no disponible${Number.isInteger(e.code)?' (código '+e.code+')':''}. Reintento en ${ms/1000}s. Si persiste, revisa OAuth, API y cuota.`);
|
|
59
68
|
await this.sleep(ms,signal);
|
|
60
69
|
}
|
|
61
70
|
}
|