// ==UserScript==
// @name B站音频一键推送 TS3AudioBot
// @namespace https://github.com/HuxiaoRoar/TS3AudioBot-Pusher/
// @homepage https://github.com/HuxiaoRoar/TS3AudioBot-Pusher/
// @version 1.2.2
// @icon https://djy.luotianyi.blue/icon/lty-b.jpg
// @description 提取BV信息→支持分P/合集批量操作→直接播放 或 加入队列
// @author HuxiaoRoar
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/festival/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/553983/B%E7%AB%99%E9%9F%B3%E9%A2%91%E4%B8%80%E9%94%AE%E6%8E%A8%E9%80%81%20TS3AudioBot.user.js
// @updateURL https://update.greasyfork.icu/scripts/553983/B%E7%AB%99%E9%9F%B3%E9%A2%91%E4%B8%80%E9%94%AE%E6%8E%A8%E9%80%81%20TS3AudioBot.meta.js
// ==/UserScript==
(() => {
'use strict';
/* ========= 配置键名 ========= */
const CFG_HOST = 'ts3ab_host'; // http://ip:port
const CFG_BOT = 'ts3ab_bot'; // 默认 0
/* ---------- 注册菜单 ---------- */
GM_registerMenuCommand('⚙️ 设置', openConfigPanel);
/* ---------- 读取配置 ---------- */
function getCfg(key, def = '') {
return GM_getValue(key, def);
}
/* ---------- 配置面板 ---------- */
function openConfigPanel() {
const old = document.getElementById('tm_config_panel');
if (old) old.remove();
const panel = document.createElement('div');
panel.id = 'tm_config_panel';
Object.assign(panel.style, {
position: 'fixed', zIndex: 99999, top: '50%', left: '50%',
transform: 'translate(-50%, -50%)', width: '320px', padding: '20px',
background: '#fff', border: '1px solid #888', borderRadius: '12px',
fontSize: '14px', fontFamily: 'sans-serif', color: '#333'
});
panel.innerHTML = `
TS3AudioBot 配置
`;
document.body.appendChild(panel);
document.getElementById('cfg_host').value = getCfg(CFG_HOST, 'http://192.168.50.32:58913');
document.getElementById('cfg_bot').value = getCfg(CFG_BOT, '0');
document.getElementById('cfg_save').onclick = () => {
GM_setValue(CFG_HOST, document.getElementById('cfg_host').value.trim());
GM_setValue(CFG_BOT, document.getElementById('cfg_bot').value.trim());
panel.remove();
toast('✅ 已保存');
};
document.getElementById('cfg_cancel').onclick = () => panel.remove();
}
/* ---------- Toast 提示 ---------- */
function toast(msg) {
const t = document.createElement('div');
Object.assign(t.style, {
position: 'fixed', left: '50%', bottom: '30px', transform: 'translateX(-50%)',
background: 'rgba(0,0,0,0.75)', color: '#fff', padding: '8px 14px',
borderRadius: '4px', fontSize: '14px', zIndex: 99999
});
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 1500);
}
/* ---------- 主按钮容器 (第一行) ---------- */
const btnContainer = document.createElement('div');
Object.assign(btnContainer.style, {
position: 'fixed', top: '200px', right: '15px', zIndex: 9999,
display: 'flex' // 使用flex布局让按钮并排
});
/* ---------- 创建按钮 (第一行) ---------- */
const btnPlay = document.createElement('button');
btnPlay.textContent = '直接播放';
Object.assign(btnPlay.style, {
background: '#00aeec', color: '#fff', border: 'none',
borderTopLeftRadius: '6px', borderBottomLeftRadius: '6px',
padding: '8px 12px', cursor: 'pointer', fontSize: '14px'
});
const btnAdd = document.createElement('button');
btnAdd.textContent = '加入队列';
Object.assign(btnAdd.style, {
background: '#00aeec', color: '#fff', border: 'none',
borderLeft: '1px solid #fff', // 添加一个分隔线
borderTopRightRadius: '6px', borderBottomRightRadius: '6px',
padding: '8px 12px', cursor: 'pointer', fontSize: '14px'
});
btnContainer.appendChild(btnPlay);
btnContainer.appendChild(btnAdd);
document.body.appendChild(btnContainer);
/* ---------- 批量按钮容器 (第二行) ---------- */
const btnContainerBatch = document.createElement('div');
Object.assign(btnContainerBatch.style, {
position: 'fixed', top: '240px', right: '15px', zIndex: 9999,
display: 'none', // 默认隐藏
flexDirection: 'flex' // 使用flex布局让按钮并排
});
/* ---------- 创建按钮 (第二行) ---------- */
const btnBatchPlay = document.createElement('button');
btnBatchPlay.textContent = '批量播放';
Object.assign(btnBatchPlay.style, {
background: '#fb7299', color: '#fff', border: 'none',
borderTopLeftRadius: '6px', // 【修改点】
borderBottomLeftRadius: '6px', // 【修改点】
padding: '8px 12px', cursor: 'pointer', fontSize: '14px'
});
const btnBatchAdd = document.createElement('button');
btnBatchAdd.textContent = '批量添加';
Object.assign(btnBatchAdd.style, {
background: '#fb7299', color: '#fff', border: 'none',
borderLeft: '1px solid #fff', // 【修改点】添加一个分隔线
borderTopRightRadius: '6px', // 【修改点】
borderBottomRightRadius: '6px', // 【修改点】
padding: '8px 12px', cursor: 'pointer', fontSize: '14px'
});
btnContainerBatch.appendChild(btnBatchPlay);
btnContainerBatch.appendChild(btnBatchAdd);
document.body.appendChild(btnContainerBatch);
/* ---------- 按钮状态管理 ---------- */
const setAllButtonsState = (disabled) => {
const opacity = disabled ? 0.7 : 1;
// Row 1
btnPlay.disabled = disabled;
btnAdd.disabled = disabled;
btnPlay.style.opacity = opacity;
btnAdd.style.opacity = opacity;
btnPlay.textContent = disabled ? '处理中...' : '直接播放';
btnAdd.textContent = disabled ? '...' : '加入队列';
// Row 2
btnBatchPlay.disabled = disabled;
btnBatchAdd.disabled = disabled;
btnBatchPlay.style.opacity = opacity;
btnBatchAdd.style.opacity = opacity;
btnBatchPlay.textContent = disabled ? '处理中...' : '批量播放';
btnBatchAdd.textContent = disabled ? '...' : '批量添加';
};
/* ---------- 核心请求逻辑 (第一行:播放/添加 *单个*) ---------- */
const handleRequest = async (command) => {
const host = getCfg(CFG_HOST);
const bot = getCfg(CFG_BOT, '0');
if (!host) {
toast('❌ 请先点击 Tampermonkey 扩展图标 -> 本脚本 -> 设置, 来配置机器人地址');
openConfigPanel();
return;
}
// 1. 取 BV
const bvMatch = /(BV[a-zA-Z0-9]+)/.exec(location.href);
if (!bvMatch) { toast('❌ 无法提取 BV'); return; }
const bv = bvMatch[1];
// 禁用所有按钮
setAllButtonsState(true);
try {
// 2. 拿视频信息
const info = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bv}`)
.then(r => r.json());
if (info.code !== 0) throw new Error('获取B站视频信息失败');
// 3. 处理分P逻辑,生成最终发送内容
let payload = bv; // 默认发送纯bv号 (用于单P视频)
if (info.data.videos > 1) {
// 如果是多P视频
const urlParams = new URL(location.href).searchParams;
const pageParam = urlParams.get('p');
if (pageParam) {
// 如果URL中有p参数,例如 &p=2
payload = `${bv}-${pageParam}`;
} else {
// 如果URL中没有p参数,说明是第1P
payload = `${bv}-1`;
}
}
// 4. 构建请求URL
// command 可以是 'v' (播放) 或 'add' (添加)
const requestUrl = `${host}/api/bot/use/${bot}/(/b/${command}/${payload})`;
// console.log(`[TS3AudioBot Script] Sending request to: ${requestUrl}`); // 方便调试
// 5. 发送请求
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: requestUrl,
onload: r => (r.status >= 200 && r.status < 300 ? resolve(r) : reject(new Error(`请求失败: ${r.statusText}`))),
onerror: err => reject(err)
});
});
toast(`✅ 已发送: ${payload}`);
} catch (e) {
console.error('[TS3AudioBot Script]', e);
toast(`❌ 失败: ${e.message}`);
} finally {
// 恢复所有按钮
setAllButtonsState(false);
// 重新初始化UI以防状态不一致 (例如在分P切换时)
initializeUIForPage();
}
};
/* ---------- 核心请求逻辑 (第二行:播放/添加 *批量*) ---------- */
const handleBatchRequest = async (action) => {
const host = getCfg(CFG_HOST);
const bot = getCfg(CFG_BOT, '0');
if (!host) {
toast('❌ 请先点击 Tampermonkey 扩展图标 -> 本脚本 -> 设置, 来配置机器人地址');
openConfigPanel();
return;
}
// 1. 取 BV
const bvMatch = /(BV[a-zA-Z0-9]+)/.exec(location.href);
if (!bvMatch) { toast('❌ 无法提取 BV'); return; }
const bv = bvMatch[1];
// 禁用所有按钮
setAllButtonsState(true);
try {
// 2. 从DOM中获取状态 (已在 initializeUIForPage 中存入)
const isCollection = btnContainerBatch.dataset.isCollection === 'true';
const isMultiPart = btnContainerBatch.dataset.isMultiPart === 'true';
// 3. 根据逻辑生成 command 和 payload
let command, payload;
payload = bv; // 基础 payload
if (isCollection) {
// 合集优先
command = (action === 'play') ? 'vall' : 'addall';
} else if (isMultiPart) {
// 仅分P (非合集)
command = (action === 'play') ? 'v' : 'add';
payload = `${bv}-a`;
} else {
// 理论上不应该到这里,因为按钮是隐藏的
throw new Error('批量按钮逻辑错误');
}
// 4. 构建请求URL
const requestUrl = `${host}/api/bot/use/${bot}/(/b/${command}/${payload})`;
// console.log(`[TS3AudioBot Script] Sending BATCH request to: ${requestUrl}`); // 方便调试
// 5. 发送请求
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: requestUrl,
onload: r => (r.status >= 200 && r.status < 300 ? resolve(r) : reject(new Error(`请求失败: ${r.statusText}`))),
onerror: err => reject(err)
});
});
toast(`✅ 已发送批量请求: ${payload}`);
} catch (e) {
console.error('[TS3AudioBot Script]', e);
toast(`❌ 失败: ${e.message}`);
} finally {
// 恢复所有按钮
setAllButtonsState(false);
}
};
/* ---------- 点击事件绑定 ---------- */
// 左边按钮:直接播放
btnPlay.onclick = () => handleRequest('v');
// 右边按钮:加入队列
btnAdd.onclick = () => handleRequest('add');
// 批量播放
btnBatchPlay.onclick = () => handleBatchRequest('play');
// 批量添加
btnBatchAdd.onclick = () => handleBatchRequest('add');
/* ---------- 路由变化时重置按钮状态并检查UI ---------- */
const initializeUIForPage = async () => {
// 1. 重置所有按钮到初始状态
setAllButtonsState(false);
// 2. 默认隐藏批量按钮并清除状态
btnContainerBatch.style.display = 'none';
btnContainerBatch.dataset.isCollection = 'false';
btnContainerBatch.dataset.isMultiPart = 'false';
// 3. 获取BV
const bvMatch = /(BV[a-zA-Z0-9]+)/.exec(location.href);
if (!bvMatch) return; // 不是视频页
const bv = bvMatch[1];
// 4. 异步获取视频信息来决定是否显示批量按钮
try {
const info = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bv}`)
.then(r => r.json());
if (info.code !== 0) throw new Error('获取B站视频信息API失败');
const data = info.data;
// !!data.ugc_season 检查是否存在合集对象 (如 简介.json)
const isCollection = !!data.ugc_season;
// data.videos > 1 检查是否为分P (如 分p.json)
const isMultiPart = data.videos > 1;
// 5. 如果是合集 或 分P,则显示批量按钮
if (isCollection || isMultiPart) {
// console.log(`[TS3AudioBot Script] 合集: ${isCollection}, 分P: ${isMultiPart}`);
btnContainerBatch.dataset.isCollection = isCollection;
btnContainerBatch.dataset.isMultiPart = isMultiPart;
btnContainerBatch.style.display = 'flex';
}
} catch (e) {
console.error('[TS3AudioBot Script] 检查UI时获取信息失败:', e);
// 获取失败则不显示批量按钮
}
};
// 监听B站的单页应用路由变化
let oldHref = document.location.href;
const body = document.querySelector("body");
const observer = new MutationObserver(mutations => {
if (oldHref !== document.location.href) {
oldHref = document.location.href;
initializeUIForPage();
}
});
observer.observe(body, { childList: true, subtree: true });
// 首次加载时执行一次
initializeUIForPage();
})();