dsh-tv-player 0.0.0-stage → 1.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-tv-player contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,144 @@
1
- # Temporary Holding Version
1
+ # dsh-tv-player
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **非官方、社区维护项目**:本插件与 DeepSeek Harness 官方无任何关联,仅作为个人学习/自用工具提供。
4
+
5
+ DSH 电视插件 —— 在 DeepSeek Harness 里直播 **8 个确定稳定的频道**(浙江 / 延边 / 香港 / 内蒙古 / 河北 / 东方卫视 + CCTV-9 纪录 / CCTV-12 社会与法),白底黑字的播放条与全宽电视画面,写代码时把电视放在聊天输入框上方,是 **Vibe coding 时的好伴侣**。
6
+
7
+ 参考 `dsh-music-player` 的双面结构:Host 端跑频道目录 + 同源直播流代理 + `tv_play` 模型工具,Web 端用 `<video>` + hls.js 播放。
8
+
9
+ ## 特性
10
+
11
+ - **8 个精选频道**:浙江 / 延边 / 香港 / 内蒙古 / 河北 / 东方卫视走各台官方 CDN、音轨为浏览器可解码的 AAC;CCTV-9 纪录 / CCTV-12 社会与法走公开中继源(含备用源);每个频道带多个候选流 URL,失效自动切换。
12
+ - **临时频道搜索**:频道面板标题栏 🔍 一键展开,自动列出公开 IPTV 源里可播放的频道(央视/卫视/凤凰);点结果加入底部「📌 临时频道」栏,可播放/移除。
13
+ - **白底黑字 + 中英双语**:播放条、电视画面区、频道面板统一白底黑字;所有按键提示与面板文字均为「中文 English」双语。
14
+ - **频道面板**:锚定在电视画面区内右侧(不遮挡输入框),含「★ 我的收藏」「卫视」「央视」分组、🔍 临时频道搜索与「📌 临时频道」栏。
15
+ - **收藏夹**:频道行尾 ⭐ 一键收藏/取消,持久化在 Host(`~/.dsh/tv-player-favs.json`)。
16
+ - **EPG 节目单**:频道列表显示「现在 · 节目名」(fanmingming e.xml,XMLTV 格式,落盘缓存 6 小时)。
17
+ - **定时(换台 / 关电视 / 关电脑)**:面板底部配置「每天/指定星期 · 时刻」的自动动作——切到某频道、停止播放、或关机(`shutdown /s /t 30`,30 秒内可 `shutdown /a` 取消);Host 端 Node 定时器自维护、脱离会话存活,持久化 `~/.dsh/tv-player-schedule.json`。
18
+ - **多源容灾**:同名频道累积多个候选流 URL,播放中某个源失效时自动切换下一源。
19
+ - **多标签页统一播放器**:同时开多个 DSH 标签页时只有一页出声(BroadcastChannel 协同 + 心跳失效检测)。
20
+ - **初始为关电视状态**:启动/刷新后不自动恢复频道、不显示画面;需要时点 ▶(随机播一个频道)或选台开播。
21
+ - **`tv_play` 模型工具**:在对话框说「播放浙江卫视」即可开播;支持 pause/resume/stop/mute/unmute/next/prev 动作。
22
+ - **同源流代理**:Host 端重写 m3u8 分片 URL 到同源 `/dsh-tv/segment`,规避上游无 CORS 的跨域问题;纯透传、不转码、不落盘。
23
+
24
+ ## 安装
25
+
26
+ ### 方式一:本地 tgz(自用 / 内部分发)
27
+
28
+ ```sh
29
+ # 1) 打包
30
+ npm pack # 产出 dsh-tv-player-<version>.tgz
31
+ ```
32
+
33
+ 然后编辑目标 profile 的 `package.json`(例如 `~/.dsh/profiles/<profile>/package.json`):
34
+
35
+ 1. 在 `dependencies` 里加:
36
+ ```json
37
+ "dsh-tv-player": "file:D:/绝对路径/dsh-tv-player-<version>.tgz"
38
+ ```
39
+ 2. **务必**在 `dsh.profile.bundles` 里加 `"dsh-tv-player"`(漏了它插件不会被加载,界面/路由都不会出现):
40
+ ```json
41
+ "dsh": { "profile": { "bundles": [ "...", "dsh-tv-player" ] } }
42
+ ```
43
+ 3. 安装并重启:
44
+ ```sh
45
+ pnpm install # 或 dsh plugin --profile <profile> add file:<tgz 路径>
46
+ # 然后重启 DSH
47
+ ```
48
+
49
+ ### 方式二:发布到 npm 后
50
+
51
+ ```sh
52
+ dsh plugin --profile <profile> add dsh-tv-player
53
+ ```
54
+
55
+ 安装后重启 DSH,打开 Web GUI:聊天输入框上方会出现「DSH 电视」播放条。
56
+
57
+ ## 使用
58
+
59
+ ### 对话开播
60
+
61
+ - 「**播放浙江卫视**」「**播放东方卫视**」→ agent 调 `tv_play` 匹配频道并开播。
62
+ - 「**暂停电视 / 继续 / 停止 / 静音 / 下一个频道**」→ agent 调 `tv_play` 的传输动作。
63
+ - 首次可能被浏览器拦截自动播放,在播放条点一次 ▶ 解锁即可。
64
+
65
+ ### 播放条
66
+
67
+ - ▶/⏸ 播放暂停、🔊 静音、音量滑块、⏮ ⏭ 上/下一个频道、⏹ 停止、👁/🙈 显示/隐藏电视画面、📺 频道列表。
68
+ - 无当前频道时点 ▶ 会**随机播放一个频道**。
69
+
70
+ ### 频道面板
71
+
72
+ - 播放条「📺」打开频道面板(锚定在电视画面区内右侧)。
73
+ - 点任意频道即播放;行尾 ⭐ 收藏/取消收藏;当前频道高亮并带「LIVE」徽标。
74
+ - 底部「⏰ 定时」添加三类规则:**换台 / 关电视 / 关电脑**。
75
+
76
+ ## 配置
77
+
78
+ `cordis.patch.yml` 里可覆盖(Schemastery 校验字段):
79
+
80
+ | 键 | 默认 | 说明 |
81
+ |---|---|---|
82
+ | `enabled` | `true` | 主开关,`false` 不挂载任何能力 |
83
+ | `sources` | `[]`(空) | 在线 m3u 目录源列表;默认关闭(精选 8 频道模式) |
84
+ | `epgUrl` | fanmingming e.xml | EPG(XMLTV)数据源 |
85
+ | `epgTtlMs` | `21600000` | EPG 落盘缓存 TTL(毫秒) |
86
+ | `catalogTtlMs` | `1800000` | 目录缓存 TTL(毫秒) |
87
+ | `timeoutMs` | `15000` | 单次上游请求超时 |
88
+ | `retries` | `1` | 目录/播放列表拉取重试次数 |
89
+ | `maxChannels` | `400` | 收录频道上限 |
90
+
91
+ ## 架构
92
+
93
+ ```
94
+ Host(lib/index.js + channels.js + proxy.js + config.js + epg.js)
95
+ ├─ 频道目录:内置 8 个精选频道(多候选 URL)→ 归类去重 → 缓存
96
+ ├─ /dsh-tv/playlist 拉上游 m3u8 并重写分片/子列表/密钥 URL 到同源
97
+ ├─ /dsh-tv/segment 分片逐字节透传(附 Referer/UA 防盗链)
98
+ ├─ /dsh-tv/vendor/hls.min.js 内嵌 hls.js(客户端 <script> 注入)
99
+ ├─ 定时器:换台 / 关电视(下发 stop)/ 关电脑(shutdown)
100
+ └─ tv_play 工具 → pendingIntent → 客户端轮询 /dsh-tv/intent 领取
101
+
102
+ Web(lib/client.js)
103
+ ├─ <video> + hls.js(Safari 回退原生 HLS)
104
+ ├─ 电视画面 + 播放条(conversation.input.dock,白底黑字)
105
+ └─ 频道面板(锚定电视画面区内右侧,含定时规则)
106
+ ```
107
+
108
+ 与音乐播放器一致:Host 端 `webServer.register({ kind:'prefix', path:'/dsh-tv', handler })` 挂路由,`ctx.tools.register` 挂工具;Web 端 `slots.inject` 注入 UI,轮询 `/dsh-tv/intent` 接收播放意图。
109
+
110
+ > **为什么需要同源代理**:IPTV 直播多为 HLS,上游通常不返回 CORS 头,浏览器 `<video>`+hls.js 直接跨域拉流会被同源策略拦截;Host 端把分片 URL 重写到同源路径后即可直接播放,同时给上游补 `Referer`/`User-Agent` 满足防盗链。
111
+
112
+ ## 合规声明
113
+
114
+ - 播放的是各电视台 / 运营商**公开直播流**(卫视走各台官方 CDN,CCTV-9/12 走公开中继源),目录为开放数据(社区维护);**不录制、不二次分发、不绕过付费墙 / DRM**,内容版权归电视台 / 版权方。
115
+ - 仅供**个人收看 / 学习研究**使用,请遵守各台站服务条款与**所在国家 / 地区法律**;本项目作者不承担由此产生的任何责任。
116
+ - **不保证频道持续可用**:直播源由第三方公开提供(含中继源),可能随时变更、失效、受地理限制或存在画质/稳定性差异,插件作者不对此负责。
117
+ - 「定时关电脑」会调用系统 `shutdown` 命令,属**明确授权的本地操作**,请谨慎使用。
118
+ - 本插件内含第三方组件 **hls.js**(Apache-2.0),详见 [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。
119
+
120
+ > 以上为合规提示,不构成法律意见。
121
+
122
+ ## 开发
123
+
124
+ ```sh
125
+ npm pack # 产出 dsh-tv-player-<version>.tgz
126
+ dsh plugin --profile <profile> add file:<tgz 路径> # 安装
127
+ ```
128
+
129
+ 修改 `lib/` 后**升版本号**重新打包安装(pnpm 对同版本 file 依赖不重取);仅客户端改动可刷新页面生效,Host 改动需重启 DSH。
130
+
131
+ ## 回滚(卸载)
132
+
133
+ ```sh
134
+ # 1) 从 profile 移除依赖
135
+ dsh plugin --profile <profile> remove dsh-tv-player
136
+ # 2) 编辑 profile 的 package.json,从 dsh.profile.bundles 里删掉 "dsh-tv-player"
137
+ # 3) 重启 DSH
138
+ ```
139
+
140
+ 或直接用面板:设置 → 插件管理 → 停用/移除 `dsh-tv-player`。改动仅限该 profile,卸载后即恢复原状。
141
+
142
+ ## License
143
+
144
+ [MIT](LICENSE)
@@ -0,0 +1,45 @@
1
+ # Third-Party Notices
2
+
3
+ 本插件(dsh-tv-player)分发时包含以下第三方组件。各组件的版权归其各自作者所有,许可协议如下。
4
+
5
+ ---
6
+
7
+ ## hls.js
8
+
9
+ - **名称**:hls.js
10
+ - **版本**:1.5.20(内置于 `lib/vendor/hls.min.js`)
11
+ - **许可**:Apache License 2.0
12
+ - **版权**:Copyright © the hls.js contributors
13
+ - **源码**:https://github.com/video-dev/hls.js
14
+
15
+ HLS 流播放功能依赖 hls.js。hls.js 依据 **Apache License 2.0** 授权分发。
16
+
17
+ ### Apache License 2.0(全文见 https://www.apache.org/licenses/LICENSE-2.0)
18
+
19
+ ```
20
+ Apache License
21
+ Version 2.0, January 2004
22
+ http://www.apache.org/licenses/
23
+
24
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
25
+
26
+ 1. Definitions. ...(完整条款请见上方链接)
27
+
28
+ Licensed under the Apache License, Version 2.0 (the "License");
29
+ you may not use this file except in compliance with the License.
30
+ You may obtain a copy of the License at
31
+
32
+ http://www.apache.org/licenses/LICENSE-2.0
33
+
34
+ Unless required by applicable law or agreed to in writing, software
35
+ distributed under the License is distributed on an "AS IS" BASIS,
36
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
37
+ See the License for the specific language governing permissions and
38
+ limitations under the License.
39
+ ```
40
+
41
+ > 说明:为便于阅读,上表为 Apache-2.0 许可的摘要性引用;完整、具有法律效力的许可文本以官方链接为准。本插件对 hls.js 未做任何修改,仅作为客户端依赖内置分发。
42
+
43
+ ---
44
+
45
+ 除上述第三方组件外,本插件的其余代码均为原创,依据 MIT 许可分发(见 `LICENSE`)。
@@ -0,0 +1,8 @@
1
+ # dsh-tv-player bundle patch: inserts the dual-face plugin row into the web
2
+ # profile roster. The node half (exports ".") runs in the host process (live
3
+ # stream channel catalog + same-origin HLS/segment proxy + the tv_play model
4
+ # tool), and the dsh.client declaration in package.json loads the browser half
5
+ # (exports "./client") in the web GUI.
6
+ - insert:
7
+ - id: tv-player
8
+ name: 'dsh-tv-player'
@@ -0,0 +1,348 @@
1
+ /**
2
+ * lib/channels.js — 直播频道目录模块(纯 Node,零第三方依赖)。
3
+ *
4
+ * 数据来源分两层:
5
+ * 1) 内置「兜底目录」CURATED_CHANNELS:打包时实测可用的精选频道(央视 / 凤凰 / 主流卫视),
6
+ * 保证在 GitHub 等目录源不可达时插件仍能开箱即用;
7
+ * 2) 「在线目录」SOURCES:社区维护、每日更新的 IPTV m3u 列表(fanmingming/live 与
8
+ * best-fan/iptv-sources 等,开放数据)。启动/首次调用时拉取并解析,得到的实时
9
+ * 直播 URL 优先于兜底目录(更「新鲜」,频道失效后会被每日更新纠正)。
10
+ *
11
+ * 合规:播放的是各电视台/运营商公开直播流,目录为开放数据;不录制、不二次分发、
12
+ * 不绕过付费墙/DRM,内容版权归电视台/版权方。仅个人收看/学习使用。
13
+ *
14
+ * m3u 解析:逐行读取 #EXTINF 与后续 URI 行,抽取 tvg-name/tvg-logo/group-title 与
15
+ * 显示名,按 group-title 归类(央视频道 / 卫视频道 / 凤凰 / 其他)。
16
+ */
17
+
18
+ // 吉林电信 tsfile 源(222.169.85.8:9901,1080p AAC;晚高峰可能拥塞,仅作备用)。
19
+ const JL = (code) => 'http://222.169.85.8:9901/tsfile/live/' + code + '_1.m3u8?key=txiptv&playlive=1&authid=0'
20
+
21
+ // 精选频道目录:6 个稳定卫视 + CCTV-9/12(快的源排前面作主源)。
22
+ // 每个频道带多个候选流 URL(`urls`),播放时某个源失效会自动切换下一源。
23
+ // group: cctv(央视) | satellite(卫视) | other(其他)
24
+ export const CURATED_CHANNELS = [
25
+ { id: 'sat-zj', name: '浙江卫视', group: 'satellite', urls: [
26
+ 'http://ali-xwl.cztv.com/live/channel011080Plxw.m3u8',
27
+ 'http://ali-xwl.cztv.com/live/channel01720Plxw.m3u8',
28
+ ] },
29
+ { id: 'sat-yb', name: '延边卫视', group: 'satellite', urls: [
30
+ 'https://srs.iyb983.cn/video/CYS/index.m3u8',
31
+ ] },
32
+ { id: 'sat-hk', name: '香港卫视', group: 'satellite', urls: [
33
+ 'https://hkstv.tv/api/m3u8-proxy?url=http%3A%2F%2Fwebcast.hkstv.tv%2Flivestream%2Fmutfysrq%2Fplaylist.m3u8',
34
+ ] },
35
+ { id: 'sat-nmg', name: '内蒙古卫视', group: 'satellite', urls: [
36
+ 'http://play1-qk.nmtv.cn/live/1769652018126032.m3u8',
37
+ ] },
38
+ { id: 'sat-hb', name: '河北卫视', group: 'satellite', urls: [
39
+ 'http://event.pull.hebtv.com/jishi/weishi_tingyun.m3u8',
40
+ ] },
41
+ { id: 'sat-df', name: '东方卫视', group: 'satellite', urls: [
42
+ 'http://bp-resource-dfl.bestv.cn/155/3/video.m3u8',
43
+ ] },
44
+ { id: 'cctv9', name: 'CCTV-9 纪录', group: 'cctv', urls: [
45
+ 'https://v4-e6b1c5baf4e240fe63adbf68dda23435.livehwc4.com/play.kankanlive.com/live/1698423397390920.m3u8?sub_m3u8=true&edge_slice=true&user_session_id=9418688afb7b99a56db1a3fb8f0fda52',
46
+ JL('0009'),
47
+ ] },
48
+ { id: 'cctv12', name: 'CCTV-12 社会与法', group: 'cctv', urls: [
49
+ 'http://74.91.26.218:82/live/cctv12hd.m3u8',
50
+ JL('0012'),
51
+ ] },
52
+ ]
53
+
54
+ // 最终目录白名单:只保留这些「确定稳定」的频道(过滤掉在线源里的其它频道)。
55
+ export const STABLE_WHITELIST = [
56
+ 'CCTV-9', 'CCTV-12',
57
+ '浙江卫视', '延边卫视', '香港卫视', '内蒙古卫视', '河北卫视', '东方卫视',
58
+ ]
59
+
60
+ // 在线目录源:默认关闭(只保留上面的 6 个精选卫视)。
61
+ // 如需扩充,可在播放面板底部「自定义 m3u 源」粘贴 IPTV 列表 URL,或在此数组加入。
62
+ export const SOURCES = []
63
+
64
+ // 「搜索频道」时使用的公开 IPTV 源(比精选目录更全,供按频道名查找可播放频道)。
65
+ export const SEARCH_SOURCES = [
66
+ 'https://cdn.jsdelivr.net/gh/best-fan/iptv-sources@master/cn_all_status.m3u8',
67
+ 'https://raw.githubusercontent.com/best-fan/iptv-sources/master/cn_all_status.m3u8',
68
+ ]
69
+
70
+ /** 按名称/关键词在一个频道列表里搜索(供「搜索频道」用)。 */
71
+ export function searchChannels(query, channels) {
72
+ const q = String(query || '').trim().toLowerCase()
73
+ if (q === '') return []
74
+ const qn = q.replace(/\s+/g, '')
75
+ return (channels || []).filter((c) => {
76
+ const name = String(c.name || '').toLowerCase()
77
+ const key = String(c.id || '').toLowerCase()
78
+ return name.includes(q) || name.replace(/\s+/g, '').includes(qn) || key.includes(qn)
79
+ }).slice(0, 20)
80
+ }
81
+
82
+ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 dsh-tv-player/0.1'
83
+
84
+ // 频道分组显示名(客户端与 Host 共用语义)。
85
+ export const GROUP_LABELS = {
86
+ cctv: '央视 CCTV',
87
+ phoenix: '凤凰卫视 Phoenix',
88
+ satellite: '卫视 Satellite',
89
+ other: '其他 Other',
90
+ }
91
+
92
+ /** 归一化频道显示名:去掉冗余后缀/分辨率标记,便于去重与匹配。 */
93
+ export function normalizeName(name) {
94
+ return String(name || '')
95
+ .replace(/\[(?:1080|720|576|480|540)[pP]?\]?/g, '')
96
+ .replace(/\((?:1080|720|576|480)p\)/gi, '')
97
+ .replace(/\s+/g, ' ')
98
+ .trim()
99
+ }
100
+
101
+ /** 把名称归类到 group。 */
102
+ export function classifyGroup(name, groupTitle) {
103
+ const g = String(groupTitle || '').trim()
104
+ const n = String(name || '')
105
+ if (/凤凰|phoenix|ifeng/i.test(n) || /凤凰|phoenix|港台|香港|台湾/i.test(g)) return 'phoenix'
106
+ if (/^CCTV[- ]?\d|央视|CGTN|中国教育/i.test(n) || /央视|中央/i.test(g)) return 'cctv'
107
+ if (/卫视/i.test(n) || /卫视|卫星/i.test(g)) return 'satellite'
108
+ return 'other'
109
+ }
110
+
111
+ /** 从名称里抽取画质档位(如「CCTV-1[1080]」「(720p)」「4K」)。 */
112
+ function extractQuality(name, tvgName) {
113
+ const s = String(name || '') + ' ' + String(tvgName || '')
114
+ if (/8k/i.test(s)) return '8K'
115
+ if (/4k/i.test(s)) return '4K'
116
+ const m = /\[(\d{3,4})[pP]?\]/.exec(s) || /\((\d{3,4})[pP]\)/.exec(s)
117
+ if (m) return m[1] + 'p'
118
+ return ''
119
+ }
120
+
121
+ /** 画质档位 → 数值分(越高越好,用于多源同名频道时保留高画质)。 */
122
+ export function qualityScore(q) {
123
+ if (/8k/i.test(String(q || ''))) return 432
124
+ if (/4k/i.test(String(q || ''))) return 216
125
+ const m = /(\d{3,4})p/.exec(String(q || ''))
126
+ return m ? parseInt(m[1], 10) / 10 : 0
127
+ }
128
+
129
+ /** 解析一段 m3u8 文本为归一化频道数组。 */
130
+ export function parseM3u(text) {
131
+ const out = []
132
+ let pending = null
133
+ for (const rawLine of String(text || '').split(/\r?\n/)) {
134
+ const line = rawLine.trim()
135
+ if (line.startsWith('#EXTINF')) {
136
+ const mName = /tvg-name="([^"]*)"/i.exec(line)
137
+ const mLogo = /tvg-logo="([^"]*)"/i.exec(line)
138
+ const mGroup = /group-title="([^"]*)"/i.exec(line)
139
+ const mTitle = /,(.*)$/.exec(line)
140
+ const rawName = (mTitle ? mTitle[1] : (mName ? mName[1] : '')).trim()
141
+ pending = {
142
+ name: rawName,
143
+ tvgName: mName ? mName[1] : '',
144
+ logo: mLogo ? mLogo[1] : '',
145
+ groupTitle: mGroup ? mGroup[1] : '',
146
+ }
147
+ continue
148
+ }
149
+ if (line === '' || line.startsWith('#')) continue
150
+ if (!pending) continue
151
+ if (!/^https?:\/\//i.test(line)) { pending = null; continue }
152
+ // 过滤 IPv6 字面量主机(http://[2409:...]):多数 IPv4-only 机器不可达,直接跳过。
153
+ if (/^https?:\/\/\[/i.test(line)) { pending = null; continue }
154
+ // 过滤 txiptv 的 tsfile/live 中继:这些源用 MP2/AC-3 音轨,浏览器 Chromium 解不了 → 有影无声。
155
+ if (/\/tsfile\/live\//i.test(line) || /key=txiptv/i.test(line)) { pending = null; continue }
156
+ const name = normalizeName(pending.name || pending.tvgName)
157
+ if (name === '') { pending = null; continue }
158
+ out.push({
159
+ name,
160
+ logo: pending.logo,
161
+ group: classifyGroup(name, pending.groupTitle),
162
+ url: line,
163
+ source: 'live',
164
+ quality: extractQuality(pending.name, pending.tvgName),
165
+ })
166
+ pending = null
167
+ }
168
+ return out
169
+ }
170
+
171
+ async function fetchWithTimeout(url, fetchImpl, timeoutMs, signal) {
172
+ const ctrl = new AbortController()
173
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs)
174
+ const onAbort = () => ctrl.abort()
175
+ if (signal) {
176
+ if (signal.aborted) ctrl.abort()
177
+ else signal.addEventListener('abort', onAbort, { once: true })
178
+ }
179
+ try {
180
+ const r = await fetchImpl(url, { headers: { 'User-Agent': UA }, signal: ctrl.signal, redirect: 'follow' })
181
+ if (!r.ok) throw new Error('catalog http ' + r.status)
182
+ return await r.text()
183
+ } finally {
184
+ clearTimeout(timer)
185
+ if (signal) signal.removeEventListener('abort', onAbort)
186
+ }
187
+ }
188
+
189
+ /** 频道稳定主键:去掉「卫视/电视/台/频道」等词组、标点与全部空格后的核心名,
190
+ * 并对央视抽掉类型后缀(综合/新闻/体育赛事…),使「CCTV-1」与「CCTV-1 综合」归并为同一键。
191
+ * 用作去重键、收藏键与「上次频道」持久化键——跨会话稳定(只要频道名不变)。 */
192
+ export function channelKey(name) {
193
+ let n = normalizeName(name).replace(/[·\s\-_台频道卫视电视]/g, '').toLowerCase()
194
+ // 「cctv数字(+)+中文后缀」→ 只保留「cctv数字(+)」;cctv4k/cctv8k 不受影响(k 非中文)。
195
+ n = n.replace(/^(cctv\d+\+?)[\u4e00-\u9fa5]+$/, '$1')
196
+ return n
197
+ }
198
+
199
+ /** EPG 频道匹配键:把频道名归一为与 EPG `<display-name>` 可对齐的核心标识。
200
+ * 比 channelKey 更激进地抽掉「综合/财经/卫视」等后缀,使「CCTV-1 综合」↔「CCTV1」、
201
+ * 「CCTV-5+ 体育赛事」↔「CCTV5+」、中文台/资讯台等能对上。 */
202
+ export function epgMatchKey(name) {
203
+ let n = String(name || '').trim().toLowerCase().replace(/\s+/g, '')
204
+ const m = /^cctv[-]?(\d+)(\+|plus)?/.exec(n)
205
+ if (m) return 'cctv' + m[1] + (m[2] ? '+' : '')
206
+ if (/cgtn/.test(n)) {
207
+ const lang = n.replace(/^cgtn/, '').replace(/语$/, '').replace(/西班牙/, '西')
208
+ return 'cgtn' + lang
209
+ }
210
+ if (/凤凰/.test(n)) return n.replace(/卫视|台/g, '')
211
+ if (/卫视/.test(n)) return n.replace(/卫视.*$/, '')
212
+ return n
213
+ }
214
+
215
+ /**
216
+ * 拉取在线目录并与兜底目录合并、去重。每个频道保留**多个候选流 URL**(`urls`,主 URL
217
+ * 取最高画质),供客户端在某个源失效时自动切换下一源(容灾)。
218
+ * @param {object} opts { sources, timeoutMs, fetchImpl, signal }
219
+ * @returns {Promise<{channels: object[], source: string, fetchedAt: number}>}
220
+ */
221
+ export async function fetchCatalog(opts = {}) {
222
+ const sources = Array.isArray(opts.sources) && opts.sources.length > 0 ? opts.sources : SOURCES
223
+ const timeoutMs = opts.timeoutMs || 15000
224
+ const fetchImpl = opts.fetchImpl || fetch
225
+ const signal = opts.signal
226
+ const whitelist = opts.whitelist !== false // 默认应用稳定频道白名单(搜索时传 false 关闭)
227
+
228
+ // 先放入精选目录(source: curated,多 URL 起步);跳过 IPv6 死链与旧 tsfile 中继,
229
+ // 但保留吉林电信源(222.169.85.8,实测 AAC 可播)。
230
+ const merged = new Map()
231
+ for (const c of CURATED_CHANNELS) {
232
+ const urls = (Array.isArray(c.urls) ? c.urls : [c.url])
233
+ .filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u))
234
+ .filter((u) => !/^https?:\/\/\[/i.test(u))
235
+ .filter((u) => /222\.169\.85\.8/.test(u) || (!/\/tsfile\/live\//i.test(u) && !/key=txiptv/i.test(u)))
236
+ if (urls.length === 0) continue
237
+ const key = channelKey(c.name)
238
+ merged.set(key, { ...c, id: key, source: 'curated', logo: '', quality: c.quality || '', urls, url: urls[0] })
239
+ }
240
+ let liveCount = 0
241
+ const usedSources = []
242
+
243
+ // 合并一个实时频道:累积候选 URL(去重、上限 8),主 URL 取最高画质。
244
+ const mergeLive = (c) => {
245
+ const key = channelKey(c.name)
246
+ const existing = merged.get(key)
247
+ if (!existing) {
248
+ merged.set(key, { ...c, id: key, source: 'live', urls: [c.url], url: c.url })
249
+ return true
250
+ }
251
+ let urls = Array.isArray(existing.urls) ? existing.urls.slice() : [existing.url]
252
+ if (!urls.includes(c.url)) urls.push(c.url)
253
+ if (urls.length > 8) urls = urls.slice(0, 8)
254
+ const better = qualityScore(c.quality) > qualityScore(existing.quality)
255
+ merged.set(key, {
256
+ ...existing,
257
+ ...c,
258
+ id: key,
259
+ source: 'live',
260
+ name: existing.name, // 保留首个(兜底)更友好的名称
261
+ urls,
262
+ url: better ? c.url : existing.url,
263
+ quality: better ? c.quality : existing.quality,
264
+ })
265
+ return false
266
+ }
267
+
268
+ for (const src of sources) {
269
+ try {
270
+ const text = await fetchWithTimeout(src, fetchImpl, timeoutMs, signal)
271
+ const parsed = parseM3u(text)
272
+ if (parsed.length === 0) continue
273
+ let added = 0
274
+ for (const c of parsed) {
275
+ // 只收录我们关心的三大类(央视/凤凰/卫视),跳过地方台/卡通等噪音。
276
+ if (c.group === 'other') continue
277
+ if (mergeLive(c)) added++
278
+ }
279
+ liveCount += added
280
+ usedSources.push(src)
281
+ if (liveCount >= 400) break // 收录上限,避免超大目录拖慢首次加载
282
+ } catch {
283
+ // 单个源失败静默跳过,用剩余源 + 兜底目录。
284
+ }
285
+ }
286
+
287
+ // 主 URL 排首位,其余候选跟在后面。
288
+ // 稳定源判定:官方/省级台 CDN(河北台、百视通、浙江台、延边、香港、内蒙古、央视官方)。
289
+ const stableUrl = (u) => /hebtv\.com|bestv\.cn|cztv\.com|iyb983\.cn|hkstv\.tv|nmtv\.cn|cctvnews\.cctv\.com/i.test(u)
290
+ const channels = [...merged.values()]
291
+ .map((c) => {
292
+ const urls = Array.isArray(c.urls) && c.urls.length > 0 ? c.urls : [c.url]
293
+ const all = [c.url, ...urls.filter((u) => u !== c.url)]
294
+ // 稳定源排前面(作主 URL),中继排后面(仅作容灾备份)。
295
+ const stable = all.filter(stableUrl)
296
+ const rest = all.filter((u) => !stableUrl(u))
297
+ return { ...c, urls: [...stable, ...rest], url: stable[0] || all[0] }
298
+ })
299
+ // 只保留「确定稳定」频道(白名单),过滤掉间歇性中继(搜索时关闭)。
300
+ .filter((c) => !whitelist || STABLE_WHITELIST.some((w) => String(c.name || '').includes(w)))
301
+ .sort((a, b) => {
302
+ // 卫视排前面(原 6 个卫视在数组最前),央视随后;组内保持数组顺序(稳定排序)。
303
+ const order = { satellite: 0, cctv: 1, phoenix: 2, other: 3 }
304
+ return (order[a.group] ?? 4) - (order[b.group] ?? 4)
305
+ })
306
+
307
+ return {
308
+ channels,
309
+ source: usedSources.length > 0 ? 'live+' + usedSources.length + ' 源' : '精选频道',
310
+ fetchedAt: Date.now(),
311
+ }
312
+ }
313
+
314
+ /** 按名称/关键词匹配频道:先精确命中,再子串命中。 */
315
+ export function matchChannel(channels, query) {
316
+ const q = String(query || '').trim().toLowerCase()
317
+ if (q === '') return null
318
+ // 关键词归一:把「凤凰」「CCTV13」「cctv 13」「央视13」等变体对齐。
319
+ const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, '').replace(/[-·—]/g, '')
320
+ const qn = norm(q)
321
+ // 中文频道常见别名(供工具按口语词命中)。
322
+ const aliases = (ch) => {
323
+ const n = ch.name
324
+ const set = [n, norm(n)]
325
+ const mCctv = /cctv[- ]?(\d+)/i.exec(n)
326
+ if (mCctv) set.push('cctv' + mCctv[1], '央视' + mCctv[1], '中央' + mCctv[1])
327
+ if (/凤凰/.test(n)) set.push('凤凰', '凤凰卫视')
328
+ if (/中文台/.test(n)) set.push('凤凰中文', '凤凰中文台')
329
+ if (/资讯台/.test(n)) set.push('凤凰资讯', '凤凰资讯台')
330
+ if (/香港台/.test(n)) set.push('凤凰香港', '凤凰香港台')
331
+ return set
332
+ }
333
+ let best = null
334
+ let bestScore = 0
335
+ for (const ch of channels) {
336
+ for (const a of aliases(ch)) {
337
+ const an = norm(a)
338
+ let score = 0
339
+ if (an === qn) score = 100
340
+ else if (an.includes(qn) || qn.includes(an)) score = 60
341
+ else if (a.toLowerCase().includes(q)) score = 40
342
+ if (score > bestScore) { bestScore = score; best = ch }
343
+ }
344
+ }
345
+ return bestScore >= 40 ? best : null
346
+ }
347
+
348
+ export default { CURATED_CHANNELS, SOURCES, SEARCH_SOURCES, STABLE_WHITELIST, GROUP_LABELS, normalizeName, classifyGroup, parseM3u, fetchCatalog, matchChannel, channelKey, epgMatchKey, qualityScore, searchChannels }