麦动KTV: 移除Gitee数据库(改官方Muse同步), 歌曲源切w.w345.my, 远程日志+广告屏蔽
- DatabaseBootstrapper: 删除 Gitee 分片/manifest/blob 下载逻辑, 曲库只走官方 MuseDbSync - build.gradle: 移除 GITEE_DATABASE_* 构建参数 - MainActivity: 重置数据库文案改为官方同步 - ktv_api.js: 歌曲源仅 w.w345.my, 缓存标记 7, 广告/错号资源拦截+重试 - 远程日志: 点歌记录 编号/标题/请求源/状态
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,213 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => document.querySelectorAll(selector);
|
||||
const uiState = {
|
||||
page: 1, query: "", searchMode: "singer", busy: false,
|
||||
hotPage: 1, hotPageSize: 10, hotBusy: false,
|
||||
visible: true, connected: false
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function setConnected(connected) {
|
||||
uiState.connected = connected;
|
||||
$("#offline").classList.toggle("hidden", connected);
|
||||
$("#connection").innerHTML = connected
|
||||
? "<span></span>已连接麦动 KTV"
|
||||
: "<span></span>正在连接麦动 KTV…";
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.ok === false) throw new Error(data.error || "操作失败");
|
||||
setConnected(true);
|
||||
return data;
|
||||
}
|
||||
|
||||
function toast(text) {
|
||||
const element = $("#toast");
|
||||
element.textContent = text;
|
||||
element.classList.add("show");
|
||||
window.setTimeout(() => element.classList.remove("show"), 1600);
|
||||
}
|
||||
|
||||
function songRow(song, queue = false, current = false, index = 0, ranking = false) {
|
||||
const number = current ? "♪" : String(index + 1).padStart(2, "0");
|
||||
const actions = queue && !current
|
||||
? '<button class="top">置顶</button><button class="delete" aria-label="删除">×</button>'
|
||||
: !queue ? '<button class="order">点歌</button>' : "";
|
||||
return `<div class="song${ranking && index < 3 ? " podium" : ""}" data-id="${escapeHtml(song.id)}">
|
||||
<div class="song-index">${number}</div>
|
||||
<div class="info">${current ? '<span class="playing">正在播放</span>' : ""}<b>${escapeHtml(song.title)}</b><p>${escapeHtml(song.singer)}${song.language ? " · " + escapeHtml(song.language) : ""}</p></div>
|
||||
${actions}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openPage(name) {
|
||||
$$("nav button,.page").forEach((element) => element.classList.remove("active"));
|
||||
$(`nav button[data-page="${name}"]`)?.classList.add("active");
|
||||
$("#" + name)?.classList.add("active");
|
||||
if (name === "queue") loadQueue();
|
||||
if (name === "search" && !$("#hotList").children.length) loadHot(1);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await api("/api/v1/state");
|
||||
const current = data.current || {};
|
||||
const count = data.queueSize || 0;
|
||||
$("#nowTitle").textContent = current.title || "暂无歌曲";
|
||||
$("#nowSinger").textContent = current.singer || "请先点一首喜欢的歌";
|
||||
$("#queueBadge b").textContent = count;
|
||||
$("#navCount").textContent = count;
|
||||
$("#navCount").classList.toggle("hidden", count === 0);
|
||||
$("#playButton span:last-child").textContent = data.playing ? "暂停" : "继续";
|
||||
$("#playButton").dataset.action = data.playing ? "pause" : "resume";
|
||||
$("#playButton .control-icon").textContent = data.playing ? "Ⅱ" : "▶";
|
||||
$("#musicVolume").value = data.musicVolume ?? 70;
|
||||
$("#micVolume").value = data.micVolume ?? 70;
|
||||
$("#musicValue").textContent = data.musicVolume ?? 70;
|
||||
$("#micValue").textContent = data.micVolume ?? 70;
|
||||
$("#original").classList.toggle("active", data.vocalMode === "original");
|
||||
$("#accompany").classList.toggle("active", data.vocalMode !== "original");
|
||||
$("#nextSong").textContent = data.next?.title || "队列为空";
|
||||
} catch (_) {
|
||||
setConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function playerAction(action) {
|
||||
try {
|
||||
await api("/api/v1/player/actions", { method: "POST", body: JSON.stringify({ action }) });
|
||||
await refresh();
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
|
||||
async function updateAudio(values) {
|
||||
try {
|
||||
await api("/api/v1/player/audio", { method: "PATCH", body: JSON.stringify(values) });
|
||||
await refresh();
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
|
||||
async function search(reset = true) {
|
||||
if (uiState.busy) return;
|
||||
if (reset) {
|
||||
uiState.page = 1;
|
||||
uiState.query = $("#query").value.trim();
|
||||
if (!uiState.query) {
|
||||
toast(`请输入${uiState.searchMode === "singer" ? "歌手" : "歌曲"}名称`);
|
||||
$("#query").focus();
|
||||
return;
|
||||
}
|
||||
$("#results").innerHTML = "";
|
||||
$("#searchResultBlock").classList.remove("hidden");
|
||||
$("#resultTitle").textContent = uiState.searchMode === "singer" ? "歌手搜索结果" : "歌曲搜索结果";
|
||||
$("#resultSummary").textContent = `正在搜索“${uiState.query}”`;
|
||||
}
|
||||
uiState.busy = true;
|
||||
try {
|
||||
const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${uiState.page}&pageSize=20`);
|
||||
const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (uiState.page - 1) * 20 + index)).join("");
|
||||
if (rows) $("#results").insertAdjacentHTML("beforeend", rows);
|
||||
else if (reset) $("#results").innerHTML = '<div class="empty">没有找到相关歌曲<br><small>请更换关键词后重试</small></div>';
|
||||
$("#resultSummary").textContent = `“${uiState.query}”${data.hasMore ? ",还有更多" : ""}`;
|
||||
$("#more").classList.toggle("hidden", !data.hasMore);
|
||||
uiState.page += 1;
|
||||
} catch (error) { toast(error.message); }
|
||||
finally { uiState.busy = false; }
|
||||
}
|
||||
|
||||
async function loadHot(page) {
|
||||
if (uiState.hotBusy || page < 1) return;
|
||||
uiState.hotBusy = true;
|
||||
$("#hotPrev").disabled = true;
|
||||
$("#hotNext").disabled = true;
|
||||
$("#hotSummary").textContent = "正在加载…";
|
||||
try {
|
||||
const data = await api(`/api/v1/hot?page=${page}&pageSize=${uiState.hotPageSize}`);
|
||||
uiState.hotPage = data.page || page;
|
||||
const songs = data.songs || [];
|
||||
$("#hotList").innerHTML = songs.map((song, index) =>
|
||||
songRow(song, false, false, (uiState.hotPage - 1) * uiState.hotPageSize + index, true)
|
||||
).join("") || '<div class="empty compact">暂无热歌排行</div>';
|
||||
$("#hotPage").textContent = `第 ${uiState.hotPage} 页`;
|
||||
$("#hotSummary").textContent = songs.length ? `第 ${(uiState.hotPage - 1) * uiState.hotPageSize + 1}-${(uiState.hotPage - 1) * uiState.hotPageSize + songs.length} 名` : "暂无数据";
|
||||
$("#hotPrev").disabled = !data.hasPrevious;
|
||||
$("#hotNext").disabled = !data.hasMore;
|
||||
if (page > 1) $("#hot").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} catch (error) {
|
||||
$("#hotList").innerHTML = '<div class="empty compact">热歌排行加载失败</div>';
|
||||
$("#hotSummary").textContent = "请点击翻页重试";
|
||||
$("#hotPrev").disabled = uiState.hotPage <= 1;
|
||||
$("#hotNext").disabled = false;
|
||||
toast(error.message);
|
||||
} finally { uiState.hotBusy = false; }
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
try {
|
||||
const data = await api("/api/v1/queue");
|
||||
const queue = data.queue || [];
|
||||
$("#queueCount").textContent = `${queue.length} 首`;
|
||||
$("#queueList").innerHTML = queue.map((song, index) => songRow(song, true, index === 0, index)).join("") || '<div class="empty">还没有点歌<br><small>去曲库选一首喜欢的歌吧</small></div>';
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button");
|
||||
if (!button) return;
|
||||
if (button.dataset.page) openPage(button.dataset.page);
|
||||
if (button.dataset.action) playerAction(button.dataset.action);
|
||||
if (button.dataset.vocal) updateAudio({ vocalMode: button.dataset.vocal });
|
||||
if (button.dataset.searchMode) {
|
||||
uiState.searchMode = button.dataset.searchMode;
|
||||
$$('[data-search-mode]').forEach((item) => item.classList.toggle("active", item === button));
|
||||
$("#query").placeholder = uiState.searchMode === "singer" ? "输入歌手名或拼音" : "输入歌曲名或拼音";
|
||||
$("#query").value = "";
|
||||
$("#results").innerHTML = "";
|
||||
$("#searchResultBlock").classList.add("hidden");
|
||||
$("#more").classList.add("hidden");
|
||||
$("#query").focus();
|
||||
}
|
||||
const row = button.closest(".song");
|
||||
if (row && button.classList.contains("order")) {
|
||||
try {
|
||||
await api("/api/v1/queue", { method: "POST", body: JSON.stringify({ songId: row.dataset.id }) });
|
||||
button.textContent = "已点"; button.disabled = true; toast("已加入歌单"); refresh();
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
if (row && button.classList.contains("top")) {
|
||||
try {
|
||||
await api("/api/v1/queue/" + encodeURIComponent(row.dataset.id), { method: "PATCH", body: "{}" });
|
||||
toast("已置顶为下一首"); loadQueue();
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
if (row && button.classList.contains("delete")) {
|
||||
try {
|
||||
await api("/api/v1/queue/" + encodeURIComponent(row.dataset.id), { method: "DELETE" });
|
||||
toast("已从歌单移除"); loadQueue(); refresh();
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
});
|
||||
|
||||
$("#searchForm").addEventListener("submit", (event) => { event.preventDefault(); search(); });
|
||||
$("#more").addEventListener("click", () => search(false));
|
||||
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
|
||||
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
|
||||
["music", "mic"].forEach((kind) => {
|
||||
$("#" + kind + "Volume").addEventListener("input", (event) => { $("#" + kind + "Value").textContent = event.target.value; });
|
||||
$("#" + kind + "Volume").addEventListener("change", (event) => updateAudio({ [kind + "Volume"]: Number(event.target.value) }));
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
|
||||
window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
|
||||
refresh();
|
||||
loadHot(1);
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#17052f">
|
||||
<title>麦动 KTV 手机点歌</title>
|
||||
<link rel="stylesheet" href="/mobile/app.css">
|
||||
<link rel="stylesheet" href="/mobile/logo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="aurora aurora-one"></div><div class="aurora aurora-two"></div><div class="mesh"></div>
|
||||
<div id="offline" class="offline hidden"><span></span>连接中断,正在重新连接…</div>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><img src="/res/drawable-nodpi/ktv_logo.png" alt="麦动 KTV"></div>
|
||||
<div><h1>麦动 <em>KTV</em></h1><p id="connection"><span></span>正在连接麦动 KTV…</p></div>
|
||||
</div>
|
||||
<button id="queueBadge" class="queue-badge" data-page="queue"><b>0</b><span>已点歌曲</span></button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="remote" class="page active">
|
||||
<article class="hero-card">
|
||||
<div class="hero-glow"></div><div class="album"><div class="disc"><span>♪</span></div></div>
|
||||
<div class="hero-copy"><div class="eyebrow"><span class="playing-dot"></span>正在播放</div><h2 id="nowTitle">暂无歌曲</h2><p id="nowSinger">请先点一首喜欢的歌</p><div class="sound-wave"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
</article>
|
||||
|
||||
<div class="controls">
|
||||
<button data-action="replay"><span class="control-icon">↻</span><span>重唱</span></button>
|
||||
<button id="playButton" class="primary" data-action="pause"><span class="control-icon">Ⅱ</span><span>暂停</span></button>
|
||||
<button data-action="next"><span class="control-icon">›|</span><span>切歌</span></button>
|
||||
</div>
|
||||
|
||||
<article class="glass-card audio-panel">
|
||||
<div class="panel-title"><div><span class="tiny-icon">♪</span><b>声音控制</b></div><small>即时生效</small></div>
|
||||
<div class="seg"><button id="accompany" data-vocal="accompany"><span>伴</span>伴唱</button><button id="original" data-vocal="original"><span>原</span>原唱</button></div>
|
||||
<label><span><i class="volume-icon">♪</i>音乐音量</span><b><em id="musicValue">70</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="70"></label>
|
||||
<label><span><i class="volume-icon mic">●</i>麦克风音量</span><b><em id="micValue">70</em>%</b><input id="micVolume" type="range" min="0" max="100" value="70"></label>
|
||||
</article>
|
||||
|
||||
<article class="next-card"><div class="next-number">02</div><div><small>下一首</small><b id="nextSong">队列为空</b></div><button data-page="queue">查看队列 <span>›</span></button></article>
|
||||
</section>
|
||||
|
||||
<section id="search" class="page">
|
||||
<div class="page-heading"><div><p>SONG LIBRARY</p><h2>想唱什么?</h2></div><span>海量本地曲库</span></div>
|
||||
<div class="search-mode" role="group" aria-label="搜索方式">
|
||||
<button type="button" data-search-mode="singer" class="active">歌手</button>
|
||||
<button type="button" data-search-mode="song">歌曲</button>
|
||||
</div>
|
||||
<form id="searchForm" class="search-box"><span class="search-icon"></span><input id="query" autocomplete="off" placeholder="输入歌手名或拼音"><button type="submit">搜索</button></form>
|
||||
<div id="searchResultBlock" class="search-result-block hidden">
|
||||
<div class="block-title"><h3 id="resultTitle">歌手搜索结果</h3><span id="resultSummary"></span></div>
|
||||
<div id="results" class="list"></div><button id="more" class="more hidden">加载更多歌曲</button>
|
||||
</div>
|
||||
<div id="hot" class="hot-block">
|
||||
<div class="block-title"><h3>热歌排行</h3><span id="hotSummary">正在加载…</span></div>
|
||||
<div id="hotList" class="list ranking-list"></div>
|
||||
<div class="rank-pager"><button id="hotPrev" type="button">上一页</button><b id="hotPage">第 1 页</b><button id="hotNext" type="button">下一页</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="queue" class="page">
|
||||
<div class="page-heading queue-heading"><div><p>PLAY QUEUE</p><h2>已点歌单</h2></div><span id="queueCount">0 首</span></div>
|
||||
<div class="queue-tip"><span>i</span>置顶歌曲将排在当前歌曲之后</div><div id="queueList" class="list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav><button data-page="remote" class="active"><i class="nav-remote"></i><span>遥控</span></button><button data-page="search"><i class="nav-search"></i><span>搜歌</span></button><button data-page="queue"><i class="nav-queue"></i><span>已点</span><b id="navCount" class="hidden">0</b></button></nav>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<script src="/mobile/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* KTV Song API - JS Hot Update Module
|
||||
* ====================================
|
||||
* 从 Gitee 热更新, App 无需发版即可修复 API 变更
|
||||
*
|
||||
* 用法:
|
||||
* const api = new KtvApi({proxy: 'http://127.0.0.1:7897'})
|
||||
* const url = await api.getSongUrl('7678785')
|
||||
* // → http://download.origjoy.com/E/ts/.../7678785.ts?sign=...
|
||||
*/
|
||||
|
||||
(function(global) {
|
||||
'use strict';
|
||||
|
||||
// KTV_BRIDGE_API: 7 (Android 缓存校验标记,请勿删除)
|
||||
|
||||
// ─── 配置 ──────────────────────────────────────────
|
||||
const CONFIG = {
|
||||
APP_ID: 'd4eeacc6cec3434fbc8c41608a3056a0',
|
||||
APP_KEY: '024210cba40d4385a93e6c2d3249bfb5',
|
||||
SDK_KEY: '19042303a8374f67ae3fe1e25c97936f',
|
||||
VN: '4.100.5.11121504',
|
||||
VER: '4',
|
||||
// 歌曲源: 仅 w.w345.my(e.ac19.cn 同后台的另一入口, 无 api.php 登记端点)。
|
||||
HOSTS: ['http://w.w345.my'],
|
||||
HOST: 'http://w.w345.my',
|
||||
MWS: 'https://mws.cherryonline.cn',
|
||||
RSA_PUBKEY: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB'
|
||||
};
|
||||
|
||||
// ─── 工具函数 ──────────────────────────────────────
|
||||
function randomHex(n) {
|
||||
var s = ''; while (n--) s += '0123456789abcdef'[Math.random()*16|0]; return s;
|
||||
}
|
||||
|
||||
// ─── MD5 ───────────────────────────────────────────
|
||||
function md5(input) {
|
||||
function add(a, b) {
|
||||
var low = (a & 0xffff) + (b & 0xffff);
|
||||
var high = (a >>> 16) + (b >>> 16) + (low >>> 16);
|
||||
return (high << 16) | (low & 0xffff);
|
||||
}
|
||||
function rol(value, shift) { return (value << shift) | (value >>> (32 - shift)); }
|
||||
function wordHex(value) {
|
||||
var out = '';
|
||||
for (var i = 0; i < 4; i++) out += ('0' + ((value >>> (i * 8)) & 255).toString(16)).slice(-2);
|
||||
return out;
|
||||
}
|
||||
|
||||
// MD5 works on UTF-8 bytes, then appends 0x80 and the original bit length.
|
||||
var utf8 = unescape(encodeURIComponent(input));
|
||||
var bytes = [];
|
||||
for (var i = 0; i < utf8.length; i++) bytes.push(utf8.charCodeAt(i));
|
||||
var bitLengthLow = (bytes.length << 3) >>> 0;
|
||||
var bitLengthHigh = (bytes.length >>> 29) >>> 0;
|
||||
bytes.push(0x80);
|
||||
while ((bytes.length % 64) !== 56) bytes.push(0);
|
||||
for (i = 0; i < 4; i++) bytes.push((bitLengthLow >>> (i * 8)) & 255);
|
||||
for (i = 0; i < 4; i++) bytes.push((bitLengthHigh >>> (i * 8)) & 255);
|
||||
|
||||
var shifts = [
|
||||
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
|
||||
5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20,
|
||||
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
|
||||
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21
|
||||
];
|
||||
var constants = [];
|
||||
for (i = 0; i < 64; i++) constants[i] = (Math.abs(Math.sin(i + 1)) * 4294967296) | 0;
|
||||
var a0 = 0x67452301, b0 = 0xefcdab89 | 0, c0 = 0x98badcfe | 0, d0 = 0x10325476;
|
||||
|
||||
for (var offset = 0; offset < bytes.length; offset += 64) {
|
||||
var words = [];
|
||||
for (i = 0; i < 16; i++) {
|
||||
var at = offset + i * 4;
|
||||
words[i] = bytes[at] | (bytes[at+1] << 8) | (bytes[at+2] << 16) | (bytes[at+3] << 24);
|
||||
}
|
||||
var a = a0, b = b0, c = c0, d = d0;
|
||||
for (i = 0; i < 64; i++) {
|
||||
var f, g;
|
||||
if (i < 16) { f = (b & c) | ((~b) & d); g = i; }
|
||||
else if (i < 32) { f = (d & b) | ((~d) & c); g = (5 * i + 1) % 16; }
|
||||
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
|
||||
else { f = c ^ (b | (~d)); g = (7 * i) % 16; }
|
||||
var oldD = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = add(b, rol(add(add(a, f), add(constants[i], words[g])), shifts[i]));
|
||||
a = oldD;
|
||||
}
|
||||
a0 = add(a0, a); b0 = add(b0, b); c0 = add(c0, c); d0 = add(d0, d);
|
||||
}
|
||||
return wordHex(a0) + wordHex(b0) + wordHex(c0) + wordHex(d0);
|
||||
}
|
||||
|
||||
// ─── RSA Encrypt ───────────────────────────────────
|
||||
async function rsaEncrypt(plaintext) {
|
||||
if (global.android && typeof global.android.rsaEncryptPkcs1 === 'function') {
|
||||
var encrypted = global.android.rsaEncryptPkcs1(CONFIG.RSA_PUBKEY, plaintext);
|
||||
if (!encrypted) throw new Error('native RSA encryption failed');
|
||||
return encrypted;
|
||||
}
|
||||
throw new Error('RSA/PKCS1 bridge is unavailable');
|
||||
}
|
||||
|
||||
// ─── HTTP ──────────────────────────────────────────
|
||||
function sleep(ms) {
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function nativeResponse(raw) {
|
||||
var value = JSON.parse(raw || '{}');
|
||||
if (value.error) throw new Error(value.error);
|
||||
return {body: value.body || '', status: Number(value.status || 0)};
|
||||
}
|
||||
|
||||
// 部分节点会在 JSON 前输出 PHP Deprecated HTML,从第一个 "{" 开始容错解析。
|
||||
function parseJsonBody(body) {
|
||||
body = String(body || '');
|
||||
var start = body.indexOf('{');
|
||||
if (start < 0) throw new Error('JSON object not found');
|
||||
return JSON.parse(body.slice(start));
|
||||
}
|
||||
|
||||
// 匹配 demo/广告/测试资源 URL 的多种模式:
|
||||
// 1) wb66 / demo 关键字 (服务端注入广告示例: https://wb66.cn/f/d/mJMSQ/719.ts)
|
||||
// 2) IPv4 地址 (如 http://154.222.26.244:656/722.ts)
|
||||
// 3) Cloudflare R2 demo bucket (pub-*.r2.dev/ss/ 通用测试文件)
|
||||
// 4) 任务4: ts 文件名编号与请求的 musicno 不一致 → 视为广告/错误资源
|
||||
// (例: 请求 7018878 却返回 /719.ts 或 /689.ts)
|
||||
function isDemoUrl(url, musicno) {
|
||||
var s = String(url || '');
|
||||
if (/(?:wb66|demo)/i.test(s)) return true;
|
||||
if (/^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?\//i.test(s)) return true;
|
||||
// R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源
|
||||
if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true;
|
||||
// ts 文件名是纯数字, 若与请求的 musicno 不一致则视为广告/测试资源。
|
||||
// 原版真源 URL 的 ts 文件名总是等于 musicno (如 /7120742.ts)。
|
||||
if (musicno) {
|
||||
var m = s.match(/\/(\d+)\.ts(?:\?|$)/);
|
||||
if (m && m[1] !== String(musicno)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function httpGet(url, timeout) {
|
||||
timeout = timeout || 8000;
|
||||
// 原 APK 的 native GET 抓包只带 Accept: */*,不伪造来源 IP。
|
||||
var headers = {'Accept': '*/*'};
|
||||
if (global.android && typeof global.android.httpGet === 'function') {
|
||||
return nativeResponse(global.android.httpGet(url, JSON.stringify(headers), timeout));
|
||||
}
|
||||
return new Promise(function(resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.timeout = timeout;
|
||||
xhr.onload = function() { resolve({body:xhr.responseText, status:xhr.status}); };
|
||||
xhr.onerror = function() { reject(new Error('network error')); };
|
||||
xhr.ontimeout = function() { reject(new Error('timeout')); };
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
async function httpPost(url, body, timeout) {
|
||||
timeout = timeout || 8000;
|
||||
var headers = {'Content-Type':'application/json', 'Accept':'*/*', 'User-Agent':'Dalvik/2.1.0'};
|
||||
if (global.android && typeof global.android.httpPost === 'function') {
|
||||
return nativeResponse(global.android.httpPost(url, body, JSON.stringify(headers), timeout));
|
||||
}
|
||||
return new Promise(function(resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
xhr.timeout = timeout;
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function() { resolve({body:xhr.responseText, status:xhr.status}); };
|
||||
xhr.onerror = function() { reject(new Error('network error')); };
|
||||
xhr.ontimeout = function() { reject(new Error('timeout')); };
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── KtvApi Class ──────────────────────────────────
|
||||
function KtvApi(options) {
|
||||
options = options || {};
|
||||
this.proxy = options.proxy || '';
|
||||
this.mac = options.mac || randomHex(16);
|
||||
this.sn = options.sn || randomHex(16);
|
||||
this.tokens = {};
|
||||
// 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。
|
||||
this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries))
|
||||
? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3;
|
||||
// 兼容旧远程脚本/调试代码读取单 token 的行为。
|
||||
this.token = '';
|
||||
this.debug = options.debug || false;
|
||||
}
|
||||
|
||||
KtvApi.prototype = {
|
||||
_log: function(msg) { if (this.debug) console.log('[KtvApi]', msg); },
|
||||
|
||||
regenerateDevice: function() {
|
||||
this.mac = randomHex(16);
|
||||
this.sn = randomHex(16);
|
||||
this.tokens = {};
|
||||
this.token = '';
|
||||
this._log('Device regenerated: ' + this.mac + '_' + this.sn);
|
||||
},
|
||||
|
||||
// 1. MWS Login
|
||||
mwsLogin: async function() {
|
||||
var self = this;
|
||||
var plaintext = JSON.stringify({device_id: self.mac});
|
||||
self._log('MWS login...');
|
||||
try {
|
||||
var encrypted = await rsaEncrypt(plaintext);
|
||||
var body = JSON.stringify({
|
||||
encrypted_data: encrypted, ip: '172.16.32.15', dns: '', router: '',
|
||||
subnet_mask: '255.255.255.0', channel: 'common', mode: 'ott'
|
||||
});
|
||||
var resp = await httpPost(CONFIG.MWS + '/mls-api/v1/login', body);
|
||||
if (resp.status === 200) {
|
||||
var data = parseJsonBody(resp.body);
|
||||
if (data.authorized) {
|
||||
self.mac = data.device_id || self.mac;
|
||||
self.sn = self.mac;
|
||||
self.tokens = {};
|
||||
self.token = '';
|
||||
self._log('MWS login OK: ' + self.mac);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self._log('MWS login FAIL: ' + resp.status);
|
||||
return false;
|
||||
} catch(e) {
|
||||
self._log('MWS login error: ' + e.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 2. Get Auth Token
|
||||
getToken: async function(host, forceRefresh) {
|
||||
var self = this;
|
||||
host = host || CONFIG.HOSTS[0];
|
||||
if (!forceRefresh && self.tokens[host]) {
|
||||
self.token = self.tokens[host];
|
||||
return self.token;
|
||||
}
|
||||
var ts = Math.floor(Date.now() / 1000);
|
||||
var params = 'appid=' + CONFIG.APP_ID +
|
||||
'&mac=' + self.mac + '_' + self.sn +
|
||||
'&sn=' + self.sn +
|
||||
'&time=' + ts +
|
||||
'&ver=' + CONFIG.VER + '&vn=' + CONFIG.VN;
|
||||
var sign = md5(params + CONFIG.APP_KEY);
|
||||
var url = host + '/i.php?' + params + '&sign=' + sign;
|
||||
self._log('Getting token: ' + host);
|
||||
try {
|
||||
var resp = await httpGet(url);
|
||||
if (resp.status === 200) {
|
||||
var data = parseJsonBody(resp.body);
|
||||
if (data.code === 200) {
|
||||
self.tokens[host] = data.token;
|
||||
self.token = data.token;
|
||||
self._log('Token OK: ' + host);
|
||||
return self.token;
|
||||
}
|
||||
}
|
||||
delete self.tokens[host];
|
||||
self._log('Token FAIL: ' + host + ', HTTP ' + resp.status);
|
||||
return null;
|
||||
} catch(e) {
|
||||
delete self.tokens[host];
|
||||
self._log('Token error: ' + host + ', ' + e.message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearToken: function(host) {
|
||||
delete this.tokens[host];
|
||||
this.token = '';
|
||||
},
|
||||
|
||||
_fetchSongUrl: async function(host, musicno, token, ls, resolution, h265) {
|
||||
var ts = Math.floor(Date.now() / 1000);
|
||||
// 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 保持一致。
|
||||
var params = 'appid=' + CONFIG.APP_ID +
|
||||
'&device=' + this.mac + '_' + this.sn +
|
||||
'&ish265=' + (h265 ? '1' : '0') +
|
||||
'&ls=' + ls +
|
||||
'&musicno=' + musicno +
|
||||
'&resolution=' + resolution +
|
||||
'&sn=' + this.sn +
|
||||
'&time=' + ts +
|
||||
'&token=' + token;
|
||||
var sign = md5(params + CONFIG.SDK_KEY);
|
||||
var resp = await httpGet(host + '/music/do.php?' + params + '&sign=' + sign);
|
||||
if (resp.status !== 200) {
|
||||
return {code: null, data: '', httpStatus: resp.status};
|
||||
}
|
||||
return parseJsonBody(resp.body);
|
||||
},
|
||||
|
||||
// 3. Get Song Download URL
|
||||
// 当前仅使用 w.w345.my(与 e.ac19.cn 同后台的另一入口, 只对 ls=1 返回真源)。
|
||||
// 结构保留 host/device 循环, 便于日后恢复多节点/备用源。
|
||||
getSongUrl: async function(musicno, resolution, h265, _preferredLs) {
|
||||
resolution = resolution || '720';
|
||||
h265 = !!h265;
|
||||
var preferredLs = String(_preferredLs == null ? '1' : _preferredLs);
|
||||
var self = this;
|
||||
for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) {
|
||||
if (deviceRound > 0) self.regenerateDevice();
|
||||
for (var hostIndex = 0; hostIndex < CONFIG.HOSTS.length; hostIndex++) {
|
||||
var host = CONFIG.HOSTS[hostIndex];
|
||||
self._log((hostIndex === 0 ? 'Primary' : 'Fallback') +
|
||||
' host, device round ' + (deviceRound + 1) + ': ' + host);
|
||||
|
||||
var token = await self.getToken(host, false);
|
||||
if (!token) { self._log('token-fail: ' + host); continue; }
|
||||
|
||||
// w.w345.my 与 e.ac19.cn 同后台, 只试 ls=1(其余 ls 值会被其风控直接拒绝,
|
||||
// 反复试会放大请求量)。若未来恢复多节点/备用源, 需按节点保留 ls 轮询。
|
||||
var lsValues = ['1'];
|
||||
|
||||
for (var lsi = 0; lsi < lsValues.length; lsi++) {
|
||||
var ls = lsValues[lsi];
|
||||
try {
|
||||
var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
|
||||
// 抓包证实: i.php 下发的 token 恒定不变(端到端同一值, 不随 time/请求变化),
|
||||
// do.php 返回 403"检测到异常行为"是风控限流, 与 token 过期无关。
|
||||
// 原版遇 403 不重取 token, 而是同 token 间隔几秒重试数次后停手(等风控窗口
|
||||
// 过去再成功)。因此这里: 403/失败/广告命中时同 token 短退避重试一次,
|
||||
// 不再清 token 重取 i.php(那会多打一倍请求, 反而放大限流)。
|
||||
// 任务4: do.php code=200 但返回广告/错号资源(如 wb66 719.ts)也算失败,
|
||||
// 退避重试一次, 给服务端机会返回与 musicno 一致的真源。
|
||||
var needRetry = data.code !== 200;
|
||||
if (!needRetry && data.code === 200 && isDemoUrl(data.data || '', musicno)) {
|
||||
self._log('广告/错号资源 (ls=' + ls + '): ' + String(data.data).slice(0, 90));
|
||||
needRetry = true;
|
||||
}
|
||||
if (needRetry) {
|
||||
self._log('do.php code=' + data.code + ' http=' + (data.httpStatus || 0) +
|
||||
' for ' + musicno + ' ls=' + ls + '; retry once with same token');
|
||||
await sleep(3000);
|
||||
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
|
||||
}
|
||||
|
||||
if (data.code === 200) {
|
||||
var songUrl = data.data || '';
|
||||
// 任务4: 广告/错误资源屏蔽——返回的文件编号与请求不一致即视为广告
|
||||
// (例: 请求 7018878 却返回 719.ts / wb66 demo 等), 一律丢弃。
|
||||
if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) {
|
||||
self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host);
|
||||
return songUrl;
|
||||
}
|
||||
if (isDemoUrl(songUrl, musicno))
|
||||
self._log('仍为广告/错号资源 (ls=' + ls + '): ' + String(songUrl).slice(0, 90));
|
||||
// 不是 demo 但也无效 (空/格式不对) → 继续试下一个 ls
|
||||
}
|
||||
} catch(e) {
|
||||
self._log('Failed ls=' + ls + ': ' + host + ', ' + e.message);
|
||||
}
|
||||
}
|
||||
self._log('All ls values exhausted for ' + host);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// 4. Full init sequence
|
||||
init: async function() {
|
||||
// token 获取属于每个主/备节点请求的一部分;这里不提前绑定单一节点。
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 导出 ──────────────────────────────────────────
|
||||
global.KtvApi = KtvApi;
|
||||
global.KtvApiConfig = CONFIG;
|
||||
global.KtvBridgeApiVersion = 7;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
|
||||
}
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -0,0 +1,2 @@
|
||||
.brand-mark{padding:0;overflow:hidden;background:#46126d;border:1px solid rgba(255,255,255,.18);box-shadow:0 8px 25px rgba(178,63,255,.28)}
|
||||
.brand-mark img{display:block;width:100%;height:100%;object-fit:cover}
|
||||
Reference in New Issue
Block a user