接入自建歌曲源服务器:源列表自建优先 + App 令牌 + 媒体地址允许清单 + 推送默认目标(V1.6.1/44,未发布)

源列表(自建源优先、官方源兜底)
- SongUrlResolver.DEFAULT_HOSTS 与 ktv_api.js 的 CONFIG.HOSTS/HOST 改为
  https://mdmuse.kasugano.cn + http://zk.ktv1.top:6677;
- 新增设置项 KtvStore.sourceHosts(一行一个可编辑)与纯 JVM SourceHosts(解析/归一化/
  自建源判定),App 与 JS 都以它为准(JS 走既有配置注入:KtvJsBridge 覆写 window.KtvApiConfig)。

App 令牌(自适应,不破坏官方源/老服务端)
- 新增纯 JVM AppToken/AppTokenSession(取/缓存/404-405 记忆不支持/401 清缓存重试一次)
  与胶水 AppTokenClient(同一条干净裸 Socket、8 秒超时、失败只写 MiliLog.local*);
- /music/do.php 与 /push_download 前先对当前源 GET /token,取到即带 X-App-Token
  (expires_in 提前 30 秒过期);官方源 404/405 记住后不再尝试;401 只重试一次;
- /i.php、/music/do.php 的参数与签名算法一个字未改(令牌只加一个请求头);
- JS 侧同步:android.appToken(host)/invalidateAppToken(host) + 401 立即重试一次。

媒体地址闸门(自建源适配)
- 新增设置项 KtvStore.allowedMediaHosts(默认 md.kasugano.cn + 192.168.100.232);
- AdVideoFilter.isTrustedSongUrl(url, musicno, allowedHosts) 只对命中主机放行"非域名"
  限制(裸 IP);黑名单 wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss 与
  ".ts 文件名必须等于编号"一律不变且优先级更高(命中清单也拒);
- 四处调用点同口径:KtvJsBridge.isTrustedDownloadUrl、SongApiClient.isLikelyDemoUrl、
  JS isDemoUrl、下载兜底。

点歌推送默认目标
- PushDownload.effectiveServerUrl:显式配置优先;留空且首源是自建源则默认用它;
  off/none/关闭 等显式关闭;pushToken 逻辑不变(服务端接受 X-App-Token 或 push_token)。

版本与测试
- versionName 1.6.1 / versionCode 44;KTV_BRIDGE_API 13 -> 14(JS 两处 + KtvJsBridge 两处);
- 设置页「数据设置」新增「歌曲源」「媒体地址允许清单」两项;
- 新增单测 AppTokenTest(11)/SourceHostsTest(7)/MediaHostAllowlistTest(8),共 65 例全过。
This commit is contained in:
zijunchiang
2026-10-14 02:46:23 +08:00
parent a20aa7b1e2
commit e00a1ace79
16 changed files with 1477 additions and 81 deletions
@@ -0,0 +1,129 @@
package com.local.ktv;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Test;
/**
* 歌曲源列表 / 媒体地址允许清单 / 推送默认目标的纯 JVM 单测。
*
* 覆盖需求点名的两组:
* ② 源列表解析(`sourceHosts` 为空时回落默认;含自建源优先);
* ④ 推送默认目标(`pushServerUrl` 为空且首源为自建源时使用它,否则尊重显式配置)。
*/
public class SourceHostsTest {
// ── ② 源列表解析 ──
/** 空(旧配置没有这个键 / 用户清空)→ 回落默认:**自建源第一、官方源兜底**。 */
@Test
public void emptySourceListFallsBackToDefaultWithSelfHostFirst() {
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(null));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(""));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(" \n \n"));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(null));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(Collections.<String>emptyList()));
assertEquals("https://mdmuse.kasugano.cn", SourceHosts.DEFAULT.get(0));
assertEquals("http://zk.ktv1.top:6677", SourceHosts.DEFAULT.get(1));
assertTrue("默认列表里自建源必须排第一", SourceHosts.isSelfHost(SourceHosts.DEFAULT.get(0)));
}
/** 设置页"一行一个":去空白/尾斜杠、按顺序去重、忽略非 http(s) 的错行。 */
@Test
public void parsesMultilineTextInOrder() {
List<String> hosts = SourceHosts.parse(
" https://mdmuse.kasugano.cn/ \n"
+ "\n"
+ "http://192.168.100.232:6677 \n"
+ "zk.ktv1.top:6677\n" // 没写协议 → 忽略
+ "https://mdmuse.kasugano.cn\n"); // 重复 → 去重
assertEquals(Arrays.asList(
"https://mdmuse.kasugano.cn",
"http://192.168.100.232:6677"), hosts);
// 逗号/分号也当分隔符(便于整条粘贴)
assertEquals(2, SourceHosts.parse("https://a.example.com, http://b.example.com").size());
assertEquals(2, SourceHosts.parse("https://a.example.com;http://b.example.com").size());
}
/** 自建源优先:自建源放第一 + 官方源兜底,两份实现(JS/原生)都读这个顺序。 */
@Test
public void selfHostFirstThenOfficialFallback() {
List<String> hosts = SourceHosts.parseList(Arrays.asList(
"https://mdmuse.kasugano.cn",
"http://zk.ktv1.top:6677"));
assertTrue(SourceHosts.isSelfHost(hosts.get(0)));
assertFalse(SourceHosts.isSelfHost(hosts.get(1)));
// 主机名判定与 scheme/端口无关(内网直连也是自建源)
assertTrue(SourceHosts.isSelfHost("http://mdmuse.kasugano.cn:6677"));
assertFalse(SourceHosts.isSelfHost(null));
assertFalse(SourceHosts.isSelfHost(""));
}
// ── 允许清单解析 ──
@Test
public void allowedMediaHostsDefaultAndNormalization() {
assertEquals(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS, SourceHosts.parseAllowed(null));
assertEquals(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS, SourceHosts.parseAllowed(" "));
assertTrue(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.contains("md.kasugano.cn"));
assertTrue(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.contains("192.168.100.232"));
// 整条粘贴 / 大写 / 端口 都要能归一化成主机名
assertEquals(Arrays.asList("md.kasugano.cn", "192.168.100.232"),
SourceHosts.parseAllowed("https://MD.Kasugano.cn:4433/media\n192.168.100.232:6677"));
assertEquals("md.kasugano.cn", SourceHosts.normalizeAllowedHost(" md.kasugano.cn "));
assertNull(SourceHosts.normalizeAllowedHost(" "));
}
// ── ④ 推送默认目标 ──
@Test
public void pushTargetAutoUsesFirstSelfHostSource() {
// 留空 + 首源是自建源 → 用它(自建源接入后的默认行为)
assertEquals("https://mdmuse.kasugano.cn",
PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT));
assertEquals("http://192.168.100.232:6677",
PushDownload.effectiveServerUrl(null, Collections.singletonList("http://192.168.100.232:6677")));
// 空配置也照样能拼出端点
assertEquals("https://mdmuse.kasugano.cn/push_download",
PushDownload.endpoint(PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT)));
}
@Test
public void pushTargetRespectsExplicitConfigAndOff() {
// 显式覆盖优先(现有用户配过地址的完全不受影响)
assertEquals("http://192.168.1.10:6677",
PushDownload.effectiveServerUrl(" http://192.168.1.10:6677 ", SourceHosts.DEFAULT));
assertEquals("http://192.168.1.10:6677/push_download",
PushDownload.endpoint(PushDownload.effectiveServerUrl("http://192.168.1.10:6677", SourceHosts.DEFAULT)));
// 显式关闭:留空已被"自动"占用,所以用 off/none/关闭 明确关掉
for (String off : new String[]{"off", "OFF", "none", "no", "0", "false", "关闭", "禁用"}) {
assertEquals("显式关闭必须为空: " + off, "",
PushDownload.effectiveServerUrl(off, SourceHosts.DEFAULT));
assertNull("关闭时不能拼出端点", PushDownload.endpoint(
PushDownload.effectiveServerUrl(off, SourceHosts.DEFAULT)));
}
}
@Test
public void pushTargetStaysOffWhenFirstSourceIsOfficial() {
List<String> officialFirst = new ArrayList<String>();
officialFirst.add("http://zk.ktv1.top:6677");
officialFirst.add("https://mdmuse.kasugano.cn");
assertEquals("首源不是自建源 → 不自动开推送", "",
PushDownload.effectiveServerUrl("", officialFirst));
assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("", officialFirst)));
assertEquals("没有任何源 → 关闭", "",
PushDownload.effectiveServerUrl("", Collections.<String>emptyList()));
// 配置非法(漏写协议)仍按关闭处理(沿用旧口径)
assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("192.168.1.10:6677", SourceHosts.DEFAULT)));
}
}