You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
970 lines
29 KiB
970 lines
29 KiB
'use strict';
|
|
const crypto = require('crypto');
|
|
const superagent = require('superagent');
|
|
const fsPromises = require('fs/promises');
|
|
const { URL } = require('url');
|
|
const path = require('path');
|
|
const WebSocket = require('ws');
|
|
|
|
/**
|
|
* [生成CheckSum签名]
|
|
* @param {string} appSecret - 应用密钥
|
|
* @param {string} nonce - 随机字符串
|
|
* @param {string} curTime - 当前UTC时间戳
|
|
* @returns {string} - SHA256加密后的CheckSum
|
|
*/
|
|
function generateCheckSum(appSecret, nonce, curTime) {
|
|
const content = `${appSecret}${nonce}${curTime}`;
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
function getWidgetTypeByUrl(url) {
|
|
const lowerUrl = String(url || '').toLowerCase();
|
|
const cleanUrl = lowerUrl.split('?')[0];
|
|
|
|
if (/\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(cleanUrl)) {
|
|
return 'PICTURE';
|
|
}
|
|
if (/\.(mp4|mov|avi|wmv|flv|mkv|webm|m4v)$/i.test(cleanUrl)) {
|
|
return 'VIDEO';
|
|
}
|
|
if (/^(rtsp|rtmp):\/\//i.test(lowerUrl) || /\.(m3u8|ts)$/i.test(cleanUrl)) {
|
|
return 'STREAM_MEDIA';
|
|
}
|
|
return 'HTML';
|
|
}
|
|
|
|
async function getMediaMetadata(url) {
|
|
try {
|
|
const res = await superagent
|
|
.get(url)
|
|
.buffer(true)
|
|
.timeout(30000);
|
|
const contentBuffer = Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.text || '', 'utf8');
|
|
return {
|
|
size: contentBuffer.length,
|
|
md5: crypto.createHash('md5').update(contentBuffer).digest('hex')
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
size: 0,
|
|
md5: '00000000000000000000000000000000'
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildXfyunAuthUrl(hostUrl, apiKey, apiSecret) {
|
|
const url = new URL(hostUrl);
|
|
const date = new Date().toUTCString();
|
|
const signatureOrigin = `host: ${url.host}\ndate: ${date}\nGET ${url.pathname} HTTP/1.1`;
|
|
const signature = crypto
|
|
.createHmac('sha256', apiSecret)
|
|
.update(signatureOrigin)
|
|
.digest('base64');
|
|
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
|
|
const authorization = Buffer.from(authorizationOrigin).toString('base64');
|
|
|
|
url.searchParams.set('authorization', authorization);
|
|
url.searchParams.set('date', date);
|
|
url.searchParams.set('host', url.host);
|
|
return url.toString();
|
|
}
|
|
|
|
function getXfyunText(result) {
|
|
return (result?.ws || [])
|
|
.map(item => item?.cw?.[0]?.w || '')
|
|
.join('');
|
|
}
|
|
|
|
function getSpeechAudioOptions(file, body = {}) {
|
|
const ext = path.extname(file?.originalname || '').toLowerCase();
|
|
const encoding = body.encoding || (ext === '.mp3' ? 'lame' : 'raw');
|
|
const format = body.format || 'audio/L16;rate=16000';
|
|
const supportedEncodings = ['raw', 'lame', 'speex', 'speex-wb'];
|
|
|
|
if (!supportedEncodings.includes(encoding)) {
|
|
throw '不支持的音频编码,仅支持 raw、lame、speex、speex-wb';
|
|
}
|
|
|
|
return { encoding, format };
|
|
}
|
|
|
|
async function recognizeSpeechByXfyun(audioBuffer, config, audioOptions) {
|
|
const { appId, apiKey, apiSecret, hostUrl } = config;
|
|
const authUrl = buildXfyunAuthUrl(hostUrl, apiKey, apiSecret);
|
|
const frameSize = 1280;
|
|
const finalResults = [];
|
|
const { encoding, format } = audioOptions;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const socket = new WebSocket(authUrl);
|
|
let offset = 0;
|
|
let closed = false;
|
|
|
|
const fail = (error) => {
|
|
if (!closed) {
|
|
closed = true;
|
|
try {
|
|
socket.close();
|
|
} catch (_) {
|
|
// ignore close error
|
|
}
|
|
}
|
|
reject(error);
|
|
};
|
|
|
|
const sendNextFrame = () => {
|
|
if (offset >= audioBuffer.length) {
|
|
socket.send(JSON.stringify({
|
|
data: {
|
|
status: 2,
|
|
format,
|
|
encoding,
|
|
audio: ''
|
|
}
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const chunk = audioBuffer.subarray(offset, offset + frameSize);
|
|
const status = offset === 0 ? 0 : 1;
|
|
const payload = {
|
|
data: {
|
|
status,
|
|
format,
|
|
encoding,
|
|
audio: chunk.toString('base64')
|
|
}
|
|
};
|
|
|
|
if (status === 0) {
|
|
payload.common = { app_id: appId };
|
|
payload.business = {
|
|
language: 'zh_cn',
|
|
domain: 'iat',
|
|
accent: 'mandarin',
|
|
vad_eos: 10000
|
|
};
|
|
}
|
|
|
|
socket.send(JSON.stringify(payload));
|
|
offset += frameSize;
|
|
setTimeout(sendNextFrame, 40);
|
|
};
|
|
|
|
socket.addEventListener('open', () => {
|
|
sendNextFrame();
|
|
});
|
|
|
|
socket.addEventListener('message', (event) => {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(event.data);
|
|
} catch (error) {
|
|
fail(new Error('讯飞语音识别返回数据格式错误'));
|
|
return;
|
|
}
|
|
|
|
if (message.code !== 0) {
|
|
fail(new Error(message.message || '讯飞语音识别失败'));
|
|
return;
|
|
}
|
|
|
|
if (message.data?.result) {
|
|
finalResults[message.data.result.sn] = getXfyunText(message.data.result);
|
|
}
|
|
|
|
if (message.data?.status === 2) {
|
|
closed = true;
|
|
socket.close();
|
|
resolve(finalResults.filter(Boolean).join(''));
|
|
}
|
|
});
|
|
|
|
socket.addEventListener('error', () => {
|
|
fail(new Error('讯飞语音识别连接失败'));
|
|
});
|
|
});
|
|
}
|
|
|
|
function callVoiceFastGpt(config, text) {
|
|
return superagent
|
|
.post(`${config.apiUrl}/api/v1/chat/completions`)
|
|
.send({
|
|
stream: false,
|
|
detail: false,
|
|
messages: [{
|
|
role: 'user',
|
|
content: [{
|
|
type: 'text',
|
|
text
|
|
}]
|
|
}]
|
|
})
|
|
.set({
|
|
Authorization: `Bearer ${config.voiceAppKey}`,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(60000);
|
|
}
|
|
|
|
module.exports.setupVoiceStream = (app) => {
|
|
app.fs.socket.on('connection', (clientSocket, req) => {
|
|
if (req.url !== '/voice/stream') {
|
|
return;
|
|
}
|
|
app.fs.logger.info('[voice stream] client connected');
|
|
|
|
const { xfyunSpeech, fastGpt } = app.fs.config;
|
|
if (!xfyunSpeech?.appId || !xfyunSpeech?.apiKey || !xfyunSpeech?.apiSecret || !xfyunSpeech?.hostUrl) {
|
|
clientSocket.send(JSON.stringify({ type: 'error', message: '讯飞语音识别配置未正确设置' }));
|
|
clientSocket.close();
|
|
return;
|
|
}
|
|
if (!fastGpt?.apiUrl || !fastGpt?.voiceAppKey) {
|
|
clientSocket.send(JSON.stringify({ type: 'error', message: 'FastGPT语音应用配置未正确设置' }));
|
|
clientSocket.close();
|
|
return;
|
|
}
|
|
|
|
const authUrl = buildXfyunAuthUrl(xfyunSpeech.hostUrl, xfyunSpeech.apiKey, xfyunSpeech.apiSecret);
|
|
const xfyunSocket = new WebSocket(authUrl);
|
|
const results = [];
|
|
const pendingAudioChunks = [];
|
|
let started = false;
|
|
let ended = false;
|
|
const totalStartedAt = Date.now();
|
|
let speechEndedAt = null;
|
|
const idleTimer = setTimeout(() => {
|
|
fail('流式语音识别超时');
|
|
}, 30000);
|
|
|
|
const sendToXfyun = (status, audio = '') => {
|
|
const payload = {
|
|
data: {
|
|
status,
|
|
format: 'audio/L16;rate=16000',
|
|
encoding: 'raw',
|
|
audio
|
|
}
|
|
};
|
|
if (status === 0) {
|
|
payload.common = { app_id: xfyunSpeech.appId };
|
|
payload.business = {
|
|
language: 'zh_cn',
|
|
domain: 'iat',
|
|
accent: 'mandarin',
|
|
vad_eos: 1000
|
|
};
|
|
}
|
|
xfyunSocket.send(JSON.stringify(payload));
|
|
};
|
|
|
|
const fail = (message) => {
|
|
clearTimeout(idleTimer);
|
|
app.fs.logger.error('[voice stream] failed:', message);
|
|
if (clientSocket.readyState === 1) {
|
|
clientSocket.send(JSON.stringify({ type: 'error', message }));
|
|
}
|
|
try { clientSocket.close(); } catch (_) {}
|
|
try { xfyunSocket.close(); } catch (_) {}
|
|
};
|
|
|
|
const beginStream = () => {
|
|
started = true;
|
|
const firstChunk = pendingAudioChunks.shift();
|
|
if (!firstChunk) {
|
|
return;
|
|
}
|
|
app.fs.logger.info('[voice stream] begin stream');
|
|
sendToXfyun(0, firstChunk.toString('base64'));
|
|
pendingAudioChunks.splice(0).forEach(chunk => {
|
|
sendToXfyun(1, chunk.toString('base64'));
|
|
});
|
|
};
|
|
|
|
xfyunSocket.addEventListener('message', async (event) => {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(event.data);
|
|
} catch (_) {
|
|
fail('讯飞语音识别返回数据格式错误');
|
|
return;
|
|
}
|
|
|
|
if (message.code !== 0) {
|
|
fail(message.message || '讯飞语音识别失败');
|
|
return;
|
|
}
|
|
|
|
if (message.data?.result) {
|
|
results[message.data.result.sn] = getXfyunText(message.data.result);
|
|
}
|
|
|
|
if (message.data?.status === 2) {
|
|
clearTimeout(idleTimer);
|
|
app.fs.logger.info('[voice stream] speech finished');
|
|
speechEndedAt = Date.now();
|
|
const text = results.filter(Boolean).join('');
|
|
if (!text) {
|
|
fail('未识别到有效语音文本');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const fastGptStartedAt = Date.now();
|
|
const fastGptRes = await callVoiceFastGpt(fastGpt, text);
|
|
if (clientSocket.readyState === 1) {
|
|
clientSocket.send(JSON.stringify({
|
|
type: 'result',
|
|
text,
|
|
fastGpt: fastGptRes.body,
|
|
timings: {
|
|
speechMs: speechEndedAt - totalStartedAt,
|
|
fastGptMs: Date.now() - fastGptStartedAt,
|
|
totalMs: Date.now() - totalStartedAt
|
|
}
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
fail(error.message || 'FastGPT调用失败');
|
|
}
|
|
}
|
|
});
|
|
|
|
xfyunSocket.addEventListener('error', () => fail('讯飞语音识别连接失败'));
|
|
xfyunSocket.addEventListener('open', () => {
|
|
app.fs.logger.info('[voice stream] xfyun connected');
|
|
});
|
|
|
|
clientSocket.on('message', (data, isBinary) => {
|
|
if (isBinary) {
|
|
if (ended) {
|
|
return;
|
|
}
|
|
const chunk = Buffer.from(data);
|
|
if (!started) {
|
|
pendingAudioChunks.push(chunk);
|
|
if (xfyunSocket.readyState === 1) {
|
|
beginStream();
|
|
}
|
|
return;
|
|
}
|
|
if (xfyunSocket.readyState !== 1) {
|
|
pendingAudioChunks.push(chunk);
|
|
return;
|
|
}
|
|
sendToXfyun(1, chunk.toString('base64'));
|
|
return;
|
|
}
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(data.toString());
|
|
} catch (_) {
|
|
fail('客户端消息格式错误');
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'start') {
|
|
app.fs.logger.info('[voice stream] start received');
|
|
if (xfyunSocket.readyState !== 1) {
|
|
xfyunSocket.addEventListener('open', beginStream, { once: true });
|
|
}
|
|
}
|
|
|
|
if (message.type === 'stop' && started && !ended && xfyunSocket.readyState === 1) {
|
|
app.fs.logger.info('[voice stream] stop received');
|
|
ended = true;
|
|
sendToXfyun(2, '');
|
|
}
|
|
});
|
|
|
|
clientSocket.on('close', () => {
|
|
clearTimeout(idleTimer);
|
|
app.fs.logger.info('[voice stream] client closed');
|
|
try { xfyunSocket.close(); } catch (_) {}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* [获取播放器列表]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.getPlayerList = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
|
|
// [获取查询参数]
|
|
const { count = 20, start = 0, name } = ctx.query;
|
|
|
|
// [验证参数范围]
|
|
const validatedCount = Math.min(Math.max(parseInt(count) || 20, 1), 100);
|
|
const validatedStart = Math.max(parseInt(start) || 0, 0);
|
|
|
|
// [生成请求头参数]
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
|
|
// [构建请求URL和参数]
|
|
const url = `${baseUrl}/v2/player/list`;
|
|
const queryParams = {
|
|
count: validatedCount,
|
|
start: validatedStart
|
|
};
|
|
if (name) {
|
|
queryParams.name = name;
|
|
}
|
|
|
|
// [发送请求到诺瓦开放平台]
|
|
const res = await superagent
|
|
.get(url)
|
|
.query(queryParams)
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
// [返回响应数据]
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('获取播放器列表失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '获取播放器列表失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [获取播放器详情]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.getPlayerDetail = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
const { playerId } = ctx.params;
|
|
|
|
if (!playerId) {
|
|
throw '缺少播放器ID参数';
|
|
}
|
|
|
|
// [生成请求头参数]
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
|
|
// [构建请求URL]
|
|
const url = `${baseUrl}/v2/player/${playerId}`;
|
|
|
|
// [发送请求到诺瓦开放平台]
|
|
const res = await superagent
|
|
.get(url)
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
// [返回响应数据]
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('获取播放器详情失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '获取播放器详情失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [发送播放器命令]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.sendPlayerCommand = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
const { playerId } = ctx.params;
|
|
const { command, params } = ctx.request.body;
|
|
|
|
if (!playerId) {
|
|
throw '缺少播放器ID参数';
|
|
}
|
|
if (!command) {
|
|
throw '缺少命令参数';
|
|
}
|
|
|
|
// [生成请求头参数]
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
|
|
// [构建请求URL]
|
|
const url = `${baseUrl}/v2/player/${playerId}/command`;
|
|
|
|
// [发送请求到诺瓦开放平台]
|
|
const res = await superagent
|
|
.post(url)
|
|
.send({
|
|
command,
|
|
params: params || {}
|
|
})
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
// [返回响应数据]
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('发送播放器命令失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '发送播放器命令失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [屏幕开启]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.openScreen = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
|
|
// [固定的播放器ID]
|
|
const playerIds = ["81ea331f7b0a456b9a57f6617634413f"];
|
|
|
|
// [生成请求头参数]
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
|
|
// [构建请求URL]
|
|
const url = `${baseUrl}/v2/player/real-time-control/screen-status`;
|
|
|
|
// [发送请求到诺瓦开放平台]
|
|
const res = await superagent
|
|
.post(url)
|
|
.send({
|
|
playerIds,
|
|
status: 'OPEN'
|
|
})
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
// [返回响应数据]
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('屏幕开启失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '屏幕开启失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [屏幕关闭]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.closeScreen = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
|
|
// [固定的播放器ID]
|
|
const playerIds = ["81ea331f7b0a456b9a57f6617634413f"];
|
|
|
|
// [生成请求头参数]
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
|
|
// [构建请求URL]
|
|
const url = `${baseUrl}/v2/player/real-time-control/screen-status`;
|
|
|
|
// [发送请求到诺瓦开放平台]
|
|
const res = await superagent
|
|
.post(url)
|
|
.send({
|
|
playerIds,
|
|
status: 'CLOSE'
|
|
})
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
// [返回响应数据]
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('屏幕关闭失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '屏幕关闭失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [新增播放器]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.addPlayers = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
const { sns } = ctx.request.body;
|
|
|
|
// [参数校验]
|
|
if (!sns || !Array.isArray(sns) || sns.length === 0) {
|
|
throw '缺少设备SN码集合参数';
|
|
}
|
|
|
|
// [生成请求头参数]
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
|
|
// [构建请求URL]
|
|
const url = `${baseUrl}/v2/player/iot-add`;
|
|
|
|
// [发送请求到诺瓦开放平台]
|
|
const res = await superagent
|
|
.post(url)
|
|
.send({
|
|
sns
|
|
})
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
// [返回响应数据]
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('新增播放器失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '新增播放器失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [下发常规节目]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.publishNormalProgram = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
const { pages } = ctx.request.body || {};
|
|
|
|
// [参数校验]
|
|
if (!pages || !Array.isArray(pages) || pages.length === 0) {
|
|
throw '缺少页面参数,格式应为 pages: [{ name, url }]';
|
|
}
|
|
const invalidPage = pages.find(item => !item || !item.name || !item.url);
|
|
if (invalidPage) {
|
|
throw 'pages 中每一项都必须包含 name 和 url';
|
|
}
|
|
|
|
// [固定播放器ID集合,优先使用配置]
|
|
const fixedPlayerIds = Array.isArray(novaCloud.fixedPlayerIds) && novaCloud.fixedPlayerIds.length > 0
|
|
? novaCloud.fixedPlayerIds
|
|
: ['81ea331f7b0a456b9a57f6617634413f'];
|
|
if (fixedPlayerIds.length > 100) {
|
|
throw '固定播放器ID集合最多支持100个';
|
|
}
|
|
|
|
// [根据前端传入的 name + url 自动构造 widgets]
|
|
const normalizedPages = await Promise.all(pages.map(async (page) => {
|
|
const type = getWidgetTypeByUrl(page.url);
|
|
const { size, md5 } = await getMediaMetadata(page.url);
|
|
return {
|
|
name: page.name,
|
|
widgets: [{
|
|
type,
|
|
md5,
|
|
size,
|
|
duration: type === 'VIDEO' ? "" : 10000,
|
|
url: page.url,
|
|
layout: {
|
|
x: '0%',
|
|
y: '0%',
|
|
width: '100%',
|
|
height: '100%'
|
|
}
|
|
}]
|
|
};
|
|
}));
|
|
|
|
const requestPayload = {
|
|
playerIds: fixedPlayerIds,
|
|
pages: normalizedPages
|
|
};
|
|
|
|
// [生成请求头参数]
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
|
|
// [构建请求URL]
|
|
const url = `${baseUrl}/v2/player/program/normal`;
|
|
|
|
// [发送请求到诺瓦开放平台]
|
|
const res = await superagent
|
|
.post(url)
|
|
.send(requestPayload)
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
// [返回响应数据]
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('下发常规节目失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '下发常规节目失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [下发单页紧急插播节目]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.publishEmergencyProgramPage = async (ctx, next) => {
|
|
try {
|
|
const { novaCloud } = ctx.app.fs.config;
|
|
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
|
|
throw '诺瓦云平台配置未正确设置';
|
|
}
|
|
|
|
const { appKey, appSecret, baseUrl } = novaCloud;
|
|
const { name, url } = ctx.request.body || {};
|
|
|
|
if (!name || !url) {
|
|
throw '缺少页面参数,必须包含 name 和 url';
|
|
}
|
|
|
|
const fixedPlayerIds = Array.isArray(novaCloud.fixedPlayerIds) && novaCloud.fixedPlayerIds.length > 0
|
|
? novaCloud.fixedPlayerIds
|
|
: ['81ea331f7b0a456b9a57f6617634413f'];
|
|
if (fixedPlayerIds.length > 100) {
|
|
throw '固定播放器ID集合最多支持100个';
|
|
}
|
|
|
|
const type = getWidgetTypeByUrl(url);
|
|
const { size, md5 } = await getMediaMetadata(url);
|
|
const fixedDuration = 2 * 60 * 60 * 1000;
|
|
|
|
const requestPayload = {
|
|
playerIds: fixedPlayerIds,
|
|
attribute: {
|
|
spotsType: 'IMMEDIATELY',
|
|
normalProgramStatus: 'NORMAL',
|
|
duration: fixedDuration
|
|
},
|
|
page: {
|
|
name,
|
|
widgets: [{
|
|
name,
|
|
type,
|
|
md5,
|
|
size,
|
|
duration: type === 'VIDEO' ? 0 : fixedDuration,
|
|
url,
|
|
layout: {
|
|
x: '0%',
|
|
y: '0%',
|
|
width: '100%',
|
|
height: '100%'
|
|
}
|
|
}]
|
|
}
|
|
};
|
|
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const curTime = Math.floor(Date.now() / 1000).toString();
|
|
const checkSum = generateCheckSum(appSecret, nonce, curTime);
|
|
const reqUrl = `${baseUrl}/v2/player/emergency-program/page`;
|
|
|
|
const res = await superagent
|
|
.post(reqUrl)
|
|
.send(requestPayload)
|
|
.set({
|
|
'AppKey': appKey,
|
|
'Nonce': nonce,
|
|
'CurTime': curTime,
|
|
'CheckSum': checkSum,
|
|
'Content-Type': 'application/json'
|
|
})
|
|
.timeout(30000);
|
|
|
|
ctx.body = res.body;
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.error('下发单页紧急插播节目失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '下发单页紧急插播节目失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [语音识别并调用 FastGPT]
|
|
* @param {Object} ctx - Koa上下文
|
|
* @param {Object} next - 下一个中间件
|
|
*/
|
|
module.exports.voiceChat = async (ctx, next) => {
|
|
const file = ctx.request.file;
|
|
try {
|
|
const totalStartedAt = Date.now();
|
|
const { xfyunSpeech, fastGpt } = ctx.app.fs.config;
|
|
if (!xfyunSpeech?.appId || !xfyunSpeech?.apiKey || !xfyunSpeech?.apiSecret || !xfyunSpeech?.hostUrl) {
|
|
throw '讯飞语音识别配置未正确设置';
|
|
}
|
|
if (!fastGpt?.apiUrl || !fastGpt?.voiceAppKey) {
|
|
throw 'FastGPT语音应用配置未正确设置';
|
|
}
|
|
if (!file) {
|
|
throw '缺少音频文件,字段名应为 file';
|
|
}
|
|
|
|
const audioBuffer = await fsPromises.readFile(file.path);
|
|
if (!audioBuffer.length) {
|
|
throw '音频文件不能为空';
|
|
}
|
|
|
|
const audioOptions = getSpeechAudioOptions(file, ctx.request.body);
|
|
const speechStartedAt = Date.now();
|
|
const recognizedText = await recognizeSpeechByXfyun(audioBuffer, xfyunSpeech, audioOptions);
|
|
const speechElapsedMs = Date.now() - speechStartedAt;
|
|
if (!recognizedText) {
|
|
throw '未识别到有效语音文本';
|
|
}
|
|
|
|
const fastGptStartedAt = Date.now();
|
|
const fastGptRes = await callVoiceFastGpt(fastGpt, recognizedText);
|
|
const fastGptElapsedMs = Date.now() - fastGptStartedAt;
|
|
|
|
ctx.status = 200;
|
|
ctx.body = {
|
|
text: recognizedText,
|
|
fastGpt: fastGptRes.body,
|
|
timings: {
|
|
speechMs: speechElapsedMs,
|
|
fastGptMs: fastGptElapsedMs,
|
|
totalMs: Date.now() - totalStartedAt
|
|
}
|
|
};
|
|
} catch (error) {
|
|
ctx.logger.error('语音识别并调用FastGPT失败:', error);
|
|
ctx.status = error.status || 500;
|
|
ctx.body = {
|
|
message: typeof error === 'string' ? error : (error.message || '语音识别并调用FastGPT失败'),
|
|
error: error.response?.body || error.message
|
|
};
|
|
} finally {
|
|
if (file?.path) {
|
|
try {
|
|
await fsPromises.unlink(file.path);
|
|
} catch (_) {
|
|
// ignore temp file cleanup error
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|