diff --git a/README.md b/README.md index 88c7db8..ad5fe4d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ macOS IPTV 播放器,基于 Tauri 2 / Rust / React 18 构建。 +公开仓库:[seldoms/iptv-mac](https://github.com/seldoms/iptv-mac) + ## 开发 要求: @@ -38,6 +40,13 @@ npm run check 该命令依次执行 TypeScript 检查、前端测试、Rust 测试、前端构建和 Tauri debug 构建。 +重点烟测: + +```bash +npm run smoke:alpha-playback-ui +npm run smoke:beta-continue +``` + ## 技术栈 - Tauri 2 @@ -62,12 +71,18 @@ tests/ 前端与共享逻辑测试 - TVBox/CatVod 配置导入 - 直播源聚合、测速、分类和树状展示 -- HLS/DASH 播放与失败换线 -- 点播浏览、搜索、详情和播放 -- 历史、收藏、缓存和设置 +- HLS/DASH/原生播放、本地媒体代理、失败诊断和自动换线 +- 点播浏览、跨站搜索、分层解析、详情和播放 +- 继续观看、历史、收藏、缓存和设置 - macOS 小窗与窗口状态管理 -产品路线见 [docs/PRODUCT_ROADMAP.md](docs/PRODUCT_ROADMAP.md)。 +## 文档 + +- [产品路线图](docs/PRODUCT_ROADMAP.md) +- [产品升级设计](docs/PRODUCT_UPGRADE_DESIGN.md) +- [本次更新说明](docs/UPDATE_NOTES_2026_06_16.md) +- [Alpha 2.1 播放闭环验证](docs/ALPHA_2_1_VERIFICATION.md) +- [Beta 1 继续观看验证](docs/BETA_1_CONTINUE_WATCHING_VERIFICATION.md) ## 参考项目 diff --git a/docs/ALPHA_2_1_VERIFICATION.md b/docs/ALPHA_2_1_VERIFICATION.md new file mode 100644 index 0000000..ddd9421 --- /dev/null +++ b/docs/ALPHA_2_1_VERIFICATION.md @@ -0,0 +1,58 @@ +# Alpha 2.1 Playback Loop Verification + +Date: 2026-06-16 + +## Scope + +This record tracks verification for the Alpha 2.1 playback-loop remediation from `PRODUCT_UPGRADE_DESIGN.md`. + +## Implemented And Verified + +| Requirement | Evidence | +| --- | --- | +| `site:superParse` Rust handler is implemented | `src-tauri/src/commands/site.rs` calls `super_parse::super_parse` with current config `parses` | +| `site:findAcrossSites` Rust handler is implemented | `src-tauri/src/commands/site.rs` searches supported HTTP API sites concurrently, dedupes, limits, and excludes current source | +| `local:getServerInfo` contract is aligned with frontend | `src-tauri/src/lib.rs` returns `LocalProxyInfo { url, token }`; `src/renderer/src/utils/media.ts` consumes `{ url, token }` | +| Local proxy supports Header-bearing HLS/live playback | `src-tauri/src/local_proxy.rs` supports token, Header query, HLS playlist rewrite, CORS, and `Range/If-Range` forwarding | +| Local proxy is optimized for WebView HLS startup | Proxy now reuses a shared Tokio runtime and reqwest client, streams media segments, exposes range headers, and rewrites playlist entries to short local IDs instead of full encoded URLs | +| Non-direct parse failure is not shown as generic playback failure | `src/renderer/src/pages/VodDetail/VodDetail.tsx` sets `stage=parse`, `errorKind=parse_failed`, and user-facing `解析失败` | +| Automatic source switching is visible | `VideoPlayer` diagnostics show switch state, failed source count, alternative source count, and next action | +| Alternative source success is not falsely marked broken | `VodDetail` now marks an alternative source broken only after detail/play data loading fails | +| Runtime playback metrics are captured locally | `src/renderer/src/utils/playbackMetrics.ts` records first-frame and failure samples in `localStorage`; diagnostics copy includes first-frame P50/P90 and failure count | +| Tauri WebView playback smoke harness exists | `scripts/alpha-playback-smoke.mjs` injects `__alphaPlaybackSmoke`, launches `tauri dev` with an isolated data dir, and reads first-frame/failure diagnostics from settings | + +## Automated Verification + +| Command | Result | +| --- | --- | +| `npm run typecheck` | Pass | +| `npm test` | Pass: 5 test files, 22 tests | +| `cargo test --manifest-path src-tauri/Cargo.toml` | Pass: 102 tests | +| `npm run build:web` | Pass, with existing chunk-size warning | +| `npm run build:check` | Pass: Tauri debug app built | +| `npm run check` | Pass | +| `git diff --check` | Pass | +| Web smoke via in-app Browser at `http://127.0.0.1:5174/` | Pass: onboarding and main route rendered, no console errors captured | +| `cargo run --manifest-path src-tauri/Cargo.toml --example rust_startup_flow` | Pass: local config/VOD/live startup flow | +| `cargo run --manifest-path src-tauri/Cargo.toml --example business_flow_probe` | Pass: 13 external configs loaded, 1 VOD play link found, 13 live play links found, 8 live samples alive | +| `npm run smoke:alpha-playback-ui` | Pass: default MP4 first frame 3329ms | +| `IPTV_ALPHA_PLAYBACK_SMOKE_RUNS=3 IPTV_ALPHA_PLAYBACK_SMOKE_MEDIA_URL=https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8 npm run smoke:alpha-playback-ui` | Pass: proxied HLS in Tauri WebView, 3/3 passed, first-frame P50 2640ms, P90 3742ms | +| `IPTV_ALPHA_PLAYBACK_SMOKE_EXPECT_FAILURE=1 IPTV_ALPHA_PLAYBACK_SMOKE_MEDIA_URL=https://example.invalid/not-found.m3u8 npm run smoke:alpha-playback-ui` | Pass: expected HLS manifest failure produced structured diagnostics | +| `IPTV_ALPHA_PLAYBACK_SMOKE_MAX_FIRST_FRAME_MS=20000 IPTV_ALPHA_PLAYBACK_SMOKE_MEDIA_URL=https://cdn.ryplay11.com/20260608/201356_6153a2e3/index.m3u8 npm run smoke:alpha-playback-ui` | Pass: real proxied business HLS first frame 4169ms | +| `cargo run --manifest-path src-tauri/Cargo.toml --example alpha_2_1_playback_probe` | Pass: 10 real VOD samples, 10 playback requests succeeded, request success rate 100%, request P50 2276ms, request P90 4200ms | + +## Remaining Alpha 2.1 Verification Notes + +The core playback loop is now automatically verified in Tauri WebView, including first-frame success and structured failure diagnostics. Remaining notes: + +| Acceptance item | Current evidence | Gap | +| --- | --- | --- | +| 10 real VOD samples playback request success rate >= 85% | `alpha_2_1_playback_probe` verified 10/10 real VOD requests from a known-good TVBox source | Met; broader bad-source tolerance remains a source-health/ranking problem | +| Click-to-first-frame P50 <= 3s and P90 <= 10s | Tauri WebView proxied HLS smoke verified P50 2640ms and P90 3742ms on stable public HLS; real business HLS sample passed at 4169ms | Met on stable HLS; real external providers remain CDN-variable and should be monitored with repeated samples | +| Failure diagnostics visible in real UI | Expected-failure Tauri smoke produced structured HLS manifest diagnostics; store tests and UI code paths verified | Met for automated diagnostic export; screenshots/manual UX polish can still be added before release | + +## Next Best Work + +1. Add source-health scoring so slow or flaky external providers do not dominate automatic sampling or ranking. +2. Persist rolling WebView first-frame samples by provider/CDN to distinguish app regressions from upstream volatility. +3. Add release screenshots for parse failure and automatic source switching states. diff --git a/docs/BETA_1_CONTINUE_WATCHING_VERIFICATION.md b/docs/BETA_1_CONTINUE_WATCHING_VERIFICATION.md new file mode 100644 index 0000000..086244c --- /dev/null +++ b/docs/BETA_1_CONTINUE_WATCHING_VERIFICATION.md @@ -0,0 +1,38 @@ +# Beta 1 Continue Watching Verification + +Date: 2026-06-16 + +## Scope + +This record tracks the first Beta 1 remediation slice: reliable continue-watching data and recovery from history. + +## Implemented + +| Requirement | Evidence | +| --- | --- | +| History schema can restore episode, source, duration, and position | `src-tauri/src/database.rs` schema version 3 adds episode/source/url/duration/position/completed fields | +| Existing databases migrate without data loss | Rust tests cover v1 -> v3 and v2 -> v3 migration paths | +| Playback progress is persisted during playback | `VideoPlayer` saves history every 15 seconds and on pause, ended, and unmount | +| Completed playback does not resume from the tail | `VideoPlayer` marks `completed` when position is at least 95% of duration and stores `positionSeconds = 0` | +| Detail page exposes resume action | `VodDetail` loads matching history and shows a continue button with episode and timestamp | +| Home page exposes continue watching | `Home` shows recent unfinished history items above the content grid | +| History page shows usable resume context | `History` shows episode/source and timestamp instead of only raw percentage | + +## Automated Verification + +| Command | Result | +| --- | --- | +| `npm run typecheck` | Pass | +| `npm test` | Pass: 5 test files, 22 tests | +| `cargo test --manifest-path src-tauri/Cargo.toml database -- --nocapture` | Pass: 11 database tests | +| `npm run check` | Pass: typecheck, Vitest, 102 Rust tests, web build, and Tauri debug build | +| `npm run smoke:beta-continue` | Pass: seeded v3 history, killed Tauri with SIGKILL, relaunched with same data dir, restored position 372s with 0s delta | + +## Remaining Verification + +| Acceptance item | Current evidence | Gap | +| --- | --- | --- | +| Force quit and reopen resumes within 20 seconds | `npm run smoke:beta-continue` verified 372s restored after SIGKILL and relaunch with 0s delta | Met | +| Same video on different sites does not collide | `history` still keys on `siteKey + vodId` | Met | +| Completed videos resume from the beginning | `completed` stores `positionSeconds = 0` when progress >= 95% | Met in code; should be covered by UI smoke | +| Home continue item opens correct detail and resume target | Home item navigates to detail; detail chooses saved source/episode | Met in code; should be covered by UI smoke | diff --git a/docs/LIVE_PERFORMANCE_REMEDIATION.md b/docs/LIVE_PERFORMANCE_REMEDIATION.md deleted file mode 100644 index c040558..0000000 --- a/docs/LIVE_PERFORMANCE_REMEDIATION.md +++ /dev/null @@ -1,40 +0,0 @@ -# 直播性能整改清单 - -更新时间:2026-06-14 - -## 流程 - -`配置获取 -> 直播源并行加载 -> 频道聚合 -> URL 去重测速 -> 频道去重选优 -> 国家/分类树 -> 数据库存储 -> 播放候选排序 -> 播放及失败换线` - -## 已完成 - -- [x] 数据获取:排除当前配置重复加载,配置以 4 路并发读取,直播源以 6 路并发加载。 -- [x] 数据整理:频道 URL 和来源分组改用 `Set` 去重,避免大列表中的重复线性扫描。 -- [x] 播放元数据:保留 `User-Agent`、`Referer`、`Origin`、自定义 Header、EPG、最优线路和延迟。 -- [x] 播放测速:相同 URL 只测试一次;20 个 worker 持续补位,不再等待整批最慢请求完成。 -- [x] 测速时限:全量刷新使用 3.5 秒超时且不做整批重试,避免坏源成倍拖慢刷新。 -- [x] 进度通知:测速进度按 120ms 节流,最终进度始终发送。 -- [x] 分类存储:保留原始分组、URL Header 和 EPG URL 后再分类入库。 -- [x] 树状展示:修复 flex 子项无法收缩导致的滚轮失效,增加滚动边界和延迟渲染提示。 -- [x] 树状搜索:搜索词同时过滤数据库频道树和传统频道列表。 -- [x] 页面刷新:直播页展示加载、测速、去重、分类、保存和完成状态。 -- [x] 页面渲染:直播页只订阅播放器所需字段,避免播放时间更新导致整页重渲染。 -- [x] 播放选线:频道等价项预先建立索引,不再在每次播放时遍历整棵树。 -- [x] 播放启动:非 HLS 且无 Header 的地址不再强制经过本地代理;HLS/Header 场景仍保留代理能力。 - -## 验证 - -- [x] TypeScript 类型检查通过。 -- [x] Vitest 全量测试通过,共 46 项。 -- [x] Tauri/Rust 后端和 renderer 生产构建通过。 -- [x] 新增 worker 持续补位测试。 -- [x] 新增直播 Header、EPG、最优线路和测速元数据保留测试。 -- [x] `git diff --check` 通过。 -- [ ] 桌面壳内真实源首帧时间和滚轮手感需结合用户当前网络与直播源做最终实机采样。 - -## 后续观测指标 - -- 刷新总耗时、配置加载耗时、唯一 URL 数量。 -- 播放请求开始到首帧时间。 -- 自动换线次数和全部线路失败率。 -- 频道树节点数量、展开后长任务数量和滚动帧率。 diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md index 132104b..50d1c45 100644 --- a/docs/PRODUCT_ROADMAP.md +++ b/docs/PRODUCT_ROADMAP.md @@ -61,17 +61,17 @@ TVBox/CatVod 兼容是导入能力,不作为用户理解产品的前提。 - IPC、窗口和本地服务安全加固。 - 统一 AppError(含 8 种错误码)和用户可见错误文案。 - 结构化日志,含 URL Token、Bearer、Cookie 脱敏。 -- 数据库 schema 版本化迁移(v1→v2),含 schema_version 表。 -- 46 项 Vitest 前端测试和 Rust 单元测试。 +- 数据库 schema 版本化迁移(v1→v3),含 schema_version 表。 +- 5 个 Vitest 文件 / 22 个前端测试,以及 102 个 Rust 测试。 - `npm run check` 一条命令检查:类型检查、前端测试、Rust 测试、构建。 ### 主要缺口 -- 播放过程缺少统一状态机和用户可见诊断。 -- 历史只在播放开始时写入,不能可靠恢复剧集、线路和时间点。 +- 播放状态机和用户可见诊断已接入,协议级错误分类和源健康反馈仍需扩展。 +- 继续观看主链路已落地,能恢复剧集、线路和时间点;收藏页来源状态、批量管理和源健康联动仍需完善。 - 源健康数据没有完整反馈到内容选择和用户界面。 - EPG 前后端参数契约尚未统一。 -- 没有自动化测试、数据库迁移框架和发布回归清单。 +- 已有自动化测试、Tauri WebView 播放烟测和强退恢复烟测;发布回归清单仍需补齐。 - 自动更新、崩溃恢复和日志脱敏尚未形成完整闭环。 ## 4. 里程碑总览 @@ -167,10 +167,10 @@ TVBox/CatVod 兼容是导入能力,不作为用户理解产品的前提。 - [x] 建立统一播放状态机:`idle → resolving → connecting → buffering → playing → recovering → failed`(类型定义和 store 层完成,VideoPlayer 部分接入)。 - [x] 播放器展示解析阶段、耗时、当前线路和可取消操作(状态覆盖层已实现)。 -- [ ] 统一 HLS、DASH、原生播放和嗅探错误分类(当前仅分级恢复,未按来源区分错误类型)。 +- [ ] 统一 HLS、DASH、原生播放和嗅探错误分类(HLS manifest/timeout/parse 已结构化,DASH/原生和 HTTP 状态仍需扩展)。 - [x] 自动换源展示候选数量和当前尝试(`sourceSwitchState` + `alternativeSources`)。 -- [ ] 新增播放诊断抽屉:错误原因、HTTP 状态、耗时和脱敏后的线路信息。 -- [ ] 增加手动重试、切换线路、复制诊断信息。 +- [x] 新增播放诊断抽屉:错误阶段、错误类型、耗时、首帧、脱敏线路和脱敏 Header。 +- [x] 增加手动重试、切换线路、复制诊断信息。 - [ ] 修复同 URL 重播、切集、切源和精简模式之间的状态一致性。 - [x] 在本机记录首帧时间 (`playbackFirstFrameAt`) 和播放失败时间 (`playbackLastErrorAt`)。 @@ -199,9 +199,9 @@ type PlaybackPhase = ### 验收标准 - [x] 从点击剧集到成功或失败始终有可见状态(覆盖层 + 状态机字段已具备)。 -- [ ] 播放诊断抽屉待实现,当前进度、码率、链路速度等信息仅在 VideoPlayer 内部展示。 +- [x] 播放诊断抽屉可见,支持复制诊断、重试当前和切换线路。 - [x] 自动换源具备取消机制和并发保护。 -- [ ] 错误提示尚未完全区分源失效、超时、解析失败、跨域和格式不支持。 +- [ ] 错误提示已区分解析失败、HLS manifest 失败和超时;跨域、格式不支持、DASH/原生错误仍需补齐。 - [ ] 关闭详情页、切集和退出应用时残留嗅探窗口待验证。 ## 8. Sprint 3:继续观看与个人内容 @@ -212,14 +212,14 @@ type PlaybackPhase = ### 开发任务 -- [ ] 扩展历史模型,增加 episodeId、episodeName、sourceIndex、urlIdentifier、duration 字段(当前仅 siteKey/vodId/vodName/progress)。 -- [ ] 播放时每 15 秒节流保存进度,暂停、切集和退出时立即保存。 -- [ ] 播放完成后标记已看完,不再从片尾位置恢复。 -- [ ] 进入详情页时提示"从上次位置继续"。 -- [ ] 首页增加继续观看,按更新时间排序。 +- [x] 扩展历史模型,增加 episodeId、episodeName、episodeIndex、sourceIndex、sourceName、urlIdentifier、duration、positionSeconds、completed 字段。 +- [x] 播放时每 15 秒节流保存进度,暂停、完播和播放器卸载时补写。 +- [x] 播放完成后标记已看完,不再从片尾位置恢复。 +- [x] 进入详情页时提示"从上次位置继续"。 +- [x] 首页增加继续观看,按更新时间排序。 - [ ] 收藏页面显示来源状态和可用性,并可搜索替代源。 - [ ] 历史和收藏支持单项删除、批量管理和清空确认(单项删除和清空已实现,批量管理待实现)。 -- [x] 对现有数据库执行无损迁移(schema version v2 已应用)。 +- [x] 对现有数据库执行无损迁移(schema version v3 已应用,覆盖 v1/v2 迁移测试)。 ### 主要代码落点 @@ -232,10 +232,10 @@ type PlaybackPhase = ### 验收标准 -- [ ] 强制退出应用后重新打开,播放位置误差不超过 20 秒。 -- [ ] 同一视频在不同站点下不会互相覆盖历史。 -- [ ] 已播放超过 95% 的内容默认从头开始。 -- [ ] 首页继续观看可直接恢复正确剧集和线路。 +- [x] 强制退出应用后重新打开,播放位置误差不超过 20 秒(`npm run smoke:beta-continue` 验证 SIGKILL 后 372s 恢复,误差 0s)。 +- [x] 同一视频在不同站点下不会互相覆盖历史(仍沿用 `siteKey + vodId` 唯一键)。 +- [x] 已播放超过 95% 的内容默认从头开始。 +- [x] 首页继续观看可直接恢复正确剧集和线路。 ## 9. Sprint 4:源健康度与智能选源 @@ -354,16 +354,16 @@ type PlaybackPhase = ## 14. 下一步执行顺序 -从 Sprint 0 开始,首批实现顺序如下: +Alpha 2.1 和 Beta 1 主链路已经落地,下一批实现顺序如下: -1. 建立 Vitest 和 `npm run check`。 -2. 为配置解析、直播解析和数据库 CRUD 补测试。 -3. 建立数据库 schema version 与迁移器。 -4. 引入统一错误模型和日志脱敏。 -5. 更新 README 与开发说明。 +1. 建立源健康评分,记录站点/线路成功率、首帧耗时、最近错误和连续失败次数。 +2. 扩展 DASH、原生媒体、CORS/Header 和格式不支持的协议级错误分类与用户文案。 +3. 补齐 Sprint 1 剩余导入能力:M3U/TXT URL、本地文件导入和导入风险提示。 +4. 完成发布回归清单:签名、公证、依赖审计、长时间播放、异常退出和数据库损坏恢复。 +5. 补充公开发布素材:失败诊断、自动换源、继续观看和直播布局截图。 -完成这五项后进入 Sprint 1,不并行开发 DLNA、插件或同步能力。 +暂不并行开发 DLNA、插件市场、跨设备同步或 AI 推荐,直到播放成功率、继续观看和源健康指标稳定达标。 ---- -最后更新:2026-06-14 — 逐项对照代码实现,所有 checkbox 与当前代码状态对齐。Sprint 0-1 基本完成,Sprint 2 状态机已落地但诊断抽屉为核心缺口,Sprint 3-6 待启动。 +最后更新:2026-06-16 — Alpha 2.1 播放闭环与 Beta 1 继续观看主链路已自动验证;下一阶段聚焦源健康、协议错误分类和 macOS 发布准备。 diff --git a/docs/PRODUCT_UPGRADE_DESIGN.md b/docs/PRODUCT_UPGRADE_DESIGN.md new file mode 100644 index 0000000..3058946 --- /dev/null +++ b/docs/PRODUCT_UPGRADE_DESIGN.md @@ -0,0 +1,425 @@ +# IPTV Mac 产品升级设计 + +更新时间:2026-06-16 + +## 0. Alpha 2.1 当前实施状态 + +本轮已完成 Alpha 2.1 播放闭环的核心整改,并已通过自动化与 Tauri WebView 烟测验证: + +- `site:superParse` Rust handler 已接通,会读取当前配置 `parses` 并调用 `super_parse::super_parse`。 +- `site:findAcrossSites` Rust handler 已接通,会并发搜索可搜索站点、限时、排除当前源、去重并返回备选源。 +- `local:getServerInfo` 已返回真实本地代理 `{ url, token }`;本地代理支持 Header、HLS playlist 重写、CORS、`Range/If-Range` 透传。 +- 播放失败状态新增结构化诊断:`stage / errorKind / protocol / sourceId / attempt / sourceCount / nextAction`。 +- 非直链解析失败会显示“解析失败”,并进入自动换源流程。 +- 自动换源状态会显示当前切换动作、已失败源数、备选源数和下一步操作。 +- 播放器会在本地记录首帧和失败样本,诊断复制内容包含首帧 P50/P90 与失败样本数。 + +当前验收证据: + +- 10 个真实点播样本的播放请求成功率已由 `alpha_2_1_playback_probe` 验证:10/10 成功,请求 P50 2276ms、P90 4200ms。 +- Tauri WebView 代理 HLS 烟测 3/3 成功,首帧 P50 2640ms、P90 3742ms;真实业务 HLS 样本首帧 4169ms。 +- 失败烟测已覆盖 HLS manifest 失败,并能产出结构化诊断。 +- 剩余风险主要来自外部源和 CDN 波动,下一阶段应通过 `source_health` 做持续学习和排序。 + +## 1. 升级结论 + +IPTV Mac 下一阶段不应定位为“功能更多的 TVBox 桌面壳”,而应升级为: + +> 面向 macOS 用户的个人网络电视聚合播放器,核心差异是播放可靠、失败可解释、源质量会自我学习、用户能稳定回到上次观看现场。 + +现有路线图中的北极星指标仍然成立: + +- 每周每位活跃用户的成功观看次数。 +- 点播成功观看:连续播放超过 5 分钟。 +- 直播成功观看:连续播放超过 10 分钟。 + +本次代码核查后的关键判断是:当前产品已经具备配置导入、首页/搜索/直播、播放器、诊断抽屉、历史收藏和直播刷新雏形,Alpha 2.1 已把播放闭环中最关键的解析、跨站换源、本地代理和诊断链路接通。下一阶段应先把真实播放结果沉淀为源健康和继续观看数据,而不是继续扩展大型能力。 + +## 2. 当前能力基线 + +### 已具备 + +- Tauri 2 / Rust / React 18 桌面应用骨架。 +- TVBox/CatVod JSON 配置导入、预检、切换、重命名、删除。 +- HTTP API 类型站点浏览、分类、筛选、详情和并行搜索。 +- HLS、DASH、原生视频播放。 +- 播放状态覆盖层、首帧耗时、码率和链路速度展示。 +- 播放诊断抽屉、复制诊断、重试当前、切换线路。 +- 直播源解析、频道聚合、去重测速、频道树、刷新进度。 +- 基础 EPG 当前/下一节目展示。 +- 历史、收藏、本地缓存和精简窗口。 +- SQLite schema migration v1 -> v2。 +- 设置文件损坏备份和原子写入。 +- 前端单元测试与 TypeScript 检查通过。 + +### 关键缺口 + +| 缺口 | 影响 | 代码依据 | +| --- | --- | --- | +| `site:superParse` 已接通并通过真实样本 probe | 非直链点播已能进入解析流程;真实源质量仍会影响长期成功率 | `src-tauri/src/commands/site.rs`, `src-tauri/src/super_parse.rs` | +| `site:findAcrossSites` 已接通 | 前端跨站同名资源换源可用,健康度排序待 Beta 2 `source_health` 支撑 | `src-tauri/src/commands/site.rs` | +| 本地媒体代理已接通并优化 | 带 Header 的 HLS/直播可通过本地代理,已支持短链 playlist 重写、Range 透传与媒体流式转发 | `src-tauri/src/lib.rs`, `src-tauri/src/local_proxy.rs`, `src/renderer/src/utils/media.ts` | +| 历史模型过薄 | 不能准确恢复剧集、线路和时间点 | `src-tauri/src/database.rs`, `src/shared/types.ts` | +| 源健康没有沉淀 | 失败线路会反复被尝试,搜索/直播排序无法学习 | `live_channels` 仅保存刷新快照 | +| 设置页部分选项未接入行为 | 用户以为配置生效,实际只是 UI | `src/renderer/src/pages/Settings/Settings.tsx` | +| 直播 EPG 与频道参数契约仍需统一 | EPG 日程和频道匹配不够稳定 | `src/renderer/src/stores/useLiveStore.ts`, `src-tauri/src/epg.rs` | +| macOS 发布闭环不足 | 普通用户长期使用信任不足 | 自动更新、签名、公证、崩溃恢复待补 | + +## 3. 目标用户与使用场景 + +### Persona A:日常观看用户 + +- 目标:打开应用后快速看电视或继续上次影片。 +- 不关心:Spider、解析器、站点 key、源协议。 +- 在意:点了能播,卡了能自动恢复,退出再进还能接着看。 + +### Persona B:源治理型用户 + +- 目标:导入多个配置,维护更稳定的直播和点播来源。 +- 不关心:复杂调试命令。 +- 在意:知道哪些源可靠,哪些源失败,能批量清理和重测。 + +### Persona C:macOS 长期使用用户 + +- 目标:像普通播放器一样安装、更新、最小化、小窗、键盘控制。 +- 不关心:开发环境和控制台日志。 +- 在意:更新可靠、崩溃不丢数据、隐私边界清楚。 + +## 4. 用户旅程地图 + +| 阶段 | 用户动作 | 当前体验 | 情绪风险 | 核心指标 | 升级机会 | +| --- | --- | --- | --- | --- | --- | +| 首次启动 | 打开应用、导入配置 | 有 onboarding 和预检 | 不知道该填什么源 | 配置导入成功率 | 支持 JSON/M3U/TXT/本地文件,给出导入后下一步 | +| 找内容 | 首页浏览、分类、搜索 | 首页和并行搜索可用 | 搜到但不知道哪个能播 | 搜索到播放成功率 | 结果按健康度、最近成功、清晰度排序 | +| 点播播放 | 点剧集、等待解析 | 状态层、SuperParse、本地代理和首帧记录已接通 | 外部源波动导致等待或失败 | 点击到首帧 P50/P90 | 用源健康持续排序并降低坏源等待 | +| 播放失败 | 等待自动恢复或手动切线 | 诊断抽屉、结构化错误和手动恢复操作已具备 | 仍需更自然地区分少数协议错误 | 自动换源成功率 | 结合源健康给出更可信的推荐操作 | +| 继续观看 | 返回历史或首页 | 历史仅百分比 | 回不到正确集数/线路 | 历史恢复成功率 | 精确保存 episode/source/url/duration/position | +| 看直播 | 选频道、切台、看节目单 | 频道树、搜索、当前/下一节目可用 | 频道太多、切台慢 | 直播首帧时间、常看频道复播率 | 最近/常看/收藏频道、预加载、完整 EPG | +| 长期维护 | 刷新源、设置、升级 | 刷新和设置页已有 | 不知道哪些源拖慢体验 | 源成功率、退避命中率 | 源健康页、失败退避、手动重测 | +| 产品信任 | 安装、更新、崩溃恢复 | DMG/app 配置有雏形 | 不敢长期依赖 | 崩溃后数据可恢复率 | 自动更新、崩溃恢复、隐私和来源说明 | + +## 5. Opportunity Solution Tree + +### Desired Outcome + +提高每周每位活跃用户的成功观看次数。 + +### Opportunity 1:用户点击内容后无法稳定进入播放 + +**问题** + +- 点播非直链需要解析;Rust 侧 `site:superParse` 已接通,并已通过 10 个真实点播样本请求验证。 +- 前端有跨站同名换源队列;Rust 侧 `site:findAcrossSites` 已接通,后续需要结合源健康排序。 +- 本地媒体代理已返回真实 `url/token`,带 Header 的 HLS/直播可以走代理,并已通过 Tauri WebView HLS 首帧烟测。 + +**解决方案** + +1. 保持 `site:superParse` handler 与真实源样本回归测试。 +2. 保持 `site:findAcrossSites` handler 的并发、去重、限时和排除当前源能力。 +3. 继续沉淀本地代理首帧与失败样本,作为源健康评分输入。 + +**POC** + +- 10 个真实配置点播样本播放请求已达到 100% 成功;稳定公共 HLS 的 Tauri WebView 首帧 P50 2640ms、P90 3742ms。 +- 后续成功标准转为持续监控:真实外部源回归不低于 85%,失败时可复制脱敏诊断。 + +### Opportunity 2:用户不知道失败原因,也不知道下一步 + +**问题** + +- 诊断抽屉已具备阶段、错误类型、耗时、脱敏线路、复制、重试和切线,仍需把少数协议错误翻译成更稳定的普通用户解释。 +- 错误没有沉淀为源健康数据。 +- 自动换源的原因、尝试次数、下一步动作不够明确。 + +**解决方案** + +1. 将播放错误结构化:`stage / protocol / httpStatus / errorKind / elapsedMs / sourceId`。 +2. 诊断抽屉分为“普通解释”和“高级详情”。 +3. 失败后给出明确操作:重试当前、跳过线路、换站点、复制诊断。 +4. 每次失败和首帧成功写入 `source_health`。 + +**POC** + +- 对 HLS manifest 失败、加载超时、媒体格式不支持、解析失败分别展示不同文案。 +- 成功标准:用户在失败状态下 1 次点击可尝试下一条候选线路。 + +### Opportunity 3:用户无法可靠继续观看 + +**问题** + +- 历史表目前只有 `siteKey/vodId/vodName/source/progress`。 +- 播放时没有持续保存位置。 +- 强退、切集、切线后恢复不可靠。 + +**解决方案** + +1. schema v3 扩展历史:`episodeId`、`episodeName`、`sourceIndex`、`urlIdentifier`、`duration`、`positionSeconds`、`completed`。 +2. 播放中 15 秒节流保存,暂停、切集、退出前立即保存。 +3. 详情页展示“继续第 N 集 12:34”。 +4. 首页增加“继续观看”横区。 + +**POC** + +- 成功标准:强制退出应用后重新打开,恢复误差 <= 20 秒;超过 95% 自动标记已看完。 + +### Opportunity 4:直播源多但质量不可见 + +**问题** + +- 直播刷新已保存可用频道和测速快照,但没有结合用户真实播放结果。 +- 失败线路没有退避期。 +- 用户不知道为什么某条线路被选中。 + +**解决方案** + +1. 新增 `source_health`:成功率、首帧 P50、最近错误、连续失败、退避截止时间。 +2. 选线评分综合:刷新测速、真实首帧、近期失败、Header 支持、格式偏好。 +3. 设置页展示配置和频道健康等级。 +4. 失败线路进入退避期,避免每次重复等待。 + +**POC** + +- 成功标准:连续失败线路在退避期内不作为首选;频道详情能解释“为什么选这条线”。 + +### Opportunity 5:直播体验还不像电视 + +**问题** + +- 已有频道树和 Cmd+上下切台,但最近频道、常看频道、数字选台、完整 EPG 未成闭环。 + +**解决方案** + +1. 最近频道和常看频道。 +2. 频道收藏。 +3. 数字选台和键盘焦点导航。 +4. 完整节目单日程、节目提醒。 +5. 预加载相邻频道元数据,缩短切台等待。 + +**POC** + +- 成功标准:两次操作内回到最近频道;键盘可完成搜索、选台、切台、返回播放区。 + +## 6. 优先级 + +| 优先级 | 主题 | 原因 | 不做的代价 | +| --- | --- | --- | --- | +| P0 | 播放闭环修复 | 直接影响北极星指标 | 用户点击后失败,后续体验无意义 | +| P1 | 继续观看和个人内容 | 提升复访和长期使用 | 应用停留在一次性播放器 | +| P2 | 源健康与智能选源 | 降低坏源反复消耗 | 播放成功率无法持续改善 | +| P3 | 直播电视体验 | 提升直播高频场景 | 直播页只是频道列表,不像电视 | +| P4 | macOS 产品化 | 建立普通用户信任 | 难以正式发布和长期维护 | + +## 7. 版本路线图 + +### Alpha 2.1:播放闭环修复 + +**用户结果** + +用户点击一集或一个频道后,应用能尽快进入播放;失败时能解释并继续尝试。 + +**主要交付** + +- 实现 `site:superParse` Rust handler。 +- 实现 `site:findAcrossSites` Rust handler。 +- 对齐本地代理契约:要么真正返回 `url/token`,要么移除前端代理依赖并说明限制。 +- 播放失败原因结构化。 +- 诊断抽屉补充普通解释和高级详情。 +- 修正文档中与代码不一致的“分层解析与跨站自动换源已具备”表述。 + +**验收标准** + +- 10 个真实点播样本中,播放请求成功率 >= 85%。 +- 点击到首帧 P50 <= 3 秒,P90 <= 10 秒。 +- 非直链失败时显示解析失败,不泛化为“播放失败”。 +- 自动换源尝试次数、当前线路和下一步操作可见。 + +### Beta 1:继续观看与个人内容 + +**用户结果** + +用户退出、切集、切线、强退后,都能回到正确内容、剧集、线路和时间点。 + +**主要交付** + +- 数据库 schema v3:扩展历史字段。 +- 播放进度节流保存。 +- 首页“继续观看”。 +- 详情页“从上次位置继续”。 +- 历史页批量管理、清空确认和单项恢复入口。 +- 收藏页显示来源可用性。 + +**验收标准** + +- 强退恢复误差 <= 20 秒。 +- 同名影片在不同站点不会互相覆盖历史。 +- 播放超过 95% 后默认从头开始。 +- 首页继续观看可直接恢复正确剧集和线路。 + +### Beta 2:源健康与智能选源 + +**用户结果** + +应用会减少重复尝试坏源,并优先选择最近真实可播的线路。 + +**主要交付** + +- `source_health` 表。 +- 播放成功/失败事件写入。 +- 选线评分器。 +- 失败退避。 +- 设置页源健康概览、手动重测、禁用源。 +- 搜索结果和直播频道按健康度排序。 + +**验收标准** + +- 连续失败线路在退避期不再首选。 +- 用户可看到选中线路的原因。 +- 源健康更新不会明显影响正在播放的视频。 + +### RC 1:直播与节目单体验 + +**用户结果** + +直播页像电视:快切、常看、节目单、键盘可用。 + +**主要交付** + +- 最近频道、常看频道、收藏频道。 +- 数字选台。 +- 键盘焦点导航。 +- 完整 EPG 日程。 +- 频道提醒。 +- 相邻频道预加载。 + +**验收标准** + +- 最近频道两次操作内可播放。 +- 键盘完成搜索、选台、切台、返回播放区。 +- 无 EPG 时自然降级。 + +### 1.0:macOS 产品化发布 + +**用户结果** + +普通 macOS 用户可以稳定安装、更新和长期使用。 + +**主要交付** + +- 自动更新:下载进度、稍后安装、失败回滚。 +- 崩溃恢复:数据库损坏、设置损坏、上次播放恢复。 +- 日志导出和预览,默认脱敏。 +- 菜单栏、系统媒体键、精简窗口完善。 +- 图标、签名、公证、DMG。 +- 隐私说明、内容来源说明、开源许可证清单。 +- 发布回归清单。 + +**验收标准** + +- 干净 macOS 环境完成安装、首次启动、导入、播放、更新。 +- 异常退出不破坏配置、历史和收藏。 +- 连续播放 4 小时无持续性内存增长。 +- 正式包通过签名、公证和依赖审计。 + +## 8. Alpha 2.1 PRD 级拆解 + +### Epic 1:接通 SuperParse + +**假设** + +如果 Rust 后端接通 `super_parse::super_parse`,非直链点播的播放成功率会显著提高。当前 handler 已接通,后续重点是持续用真实样本和 fixtures 防止回归。 + +**范围** + +- 从当前配置读取 `parses`。 +- 支持直链、playerContent 直出、JSON parse、playUrl 拼接。 +- 返回 `url/header/from`。 +- 失败返回结构化错误。 + +**不包含** + +- JS/JAR/Python Spider 全兼容。 +- Chromium 嗅探。 + +### Epic 2:跨站同名搜索换源 + +**假设** + +如果播放失败后能在其他 HTTP API 站点找到同名内容,自动换源成功率会提升,因为当前前端已经有候选队列和切换 UI。 + +**范围** + +- 遍历可搜索站点,排除当前站点和当前 vodId。 +- 并发限流,默认 8 秒超时。 +- 结果先按名称匹配和站点名称排序;站点健康度、资源 remarks 排序在 Beta 2 接入 `source_health` 后补齐。 +- 返回最多 20 个候选。 + +**不包含** + +- 相似标题智能匹配。 +- AI 内容识别。 + +### Epic 3:本地代理契约 + +**假设** + +如果 `getPlayableMediaUrl` 能拿到有效本地代理地址和 token,带 Header 的 HLS/直播播放成功率会提高,因为当前前端已经在需要 Header 或 HLS 时尝试代理。 + +**范围** + +- 明确 `local:getServerInfo` 返回 `{ url, token }`。 +- 实现 `/stream` 代理,支持 header 转发和 HLS playlist rewrite。 +- 诊断中展示代理状态。 + +**风险** + +- 本地服务需要安全边界,token 必须随机、仅本机可访问、日志脱敏。 + +### Epic 4:诊断抽屉产品化 + +**假设** + +如果用户能理解失败原因并看到下一步动作,失败后的留存会提升。 + +**范围** + +- “普通解释”:源不可用、网络超时、格式不支持、解析失败、跨域/Header 失败。 +- “高级详情”:阶段、耗时、协议、HTTP 状态、脱敏 URL、脱敏 Header。 +- 操作:重试当前、切换线路、复制诊断。 + +## 9. 指标与埋点边界 + +默认所有指标只存本机,不上传远程。若未来引入远程遥测,必须单独征得用户同意。 + +### 本机指标 + +- 配置导入成功率。 +- 点击到首帧 P50/P90。 +- 播放请求成功率。 +- 自动换源成功率。 +- 30 分钟无致命中断率。 +- 历史恢复成功率。 +- 源级成功率、失败原因、退避状态。 + +### 推荐本地表 + +- `playback_events` +- `source_health` +- `history` v3 扩展字段 + +## 10. 暂缓事项 + +以下能力继续暂缓,除非播放成功率、恢复能力和发布质量达到目标: + +- JS/JAR/Python Spider 全兼容。 +- 云端账户和跨设备同步。 +- DLNA 完整投放与控制。 +- 插件市场。 +- AI 推荐或内容理解。 +- 弹幕生态和在线字幕搜索。 + +## 11. 下一步执行建议 + +1. 进入 Beta 1:扩展历史 schema v3,补齐 episode/source/url/duration/position,并做强退恢复验证。 +2. 并行准备 Beta 2 的 `source_health` 数据结构,把 Alpha 2.1 的首帧和失败样本接入评分。 +3. 建立 `playback-fixtures`:直链、JSON parse、playUrl 拼接、带 Header HLS、失败源,降低外部源波动对回归的影响。 +4. 发布前补齐诊断抽屉和自动换源状态截图,作为人工验收材料。 diff --git a/docs/UPDATE_NOTES_2026_06_16.md b/docs/UPDATE_NOTES_2026_06_16.md new file mode 100644 index 0000000..db0ce8f --- /dev/null +++ b/docs/UPDATE_NOTES_2026_06_16.md @@ -0,0 +1,51 @@ +# IPTV Mac Upgrade Notes - 2026-06-16 + +## Overview + +This update moves IPTV Mac from a basic TVBox-style desktop shell toward a more reliable macOS IPTV player. The work focuses on two product goals: + +- Alpha 2.1: make playback reliable, explain failures, and recover through parsing, local proxying, and source switching. +- Beta 1: make continue-watching reliable across episodes, sources, app restarts, and force exits. + +## Product Strategy + +- Added a product upgrade design covering positioning, customer journeys, opportunity-solution tree, priorities, and roadmap. +- Reframed the product around successful viewing sessions, visible recovery, local-only diagnostics, source health, and reliable continuation. +- Updated the roadmap to reflect the current implementation state and next priorities. +- Made the GitHub repository public and folded stale local remediation notes into the maintained roadmap and verification records. + +## Playback Reliability + +- Implemented `site:superParse` in Rust so non-direct VOD playback can use configured parse rules. +- Implemented `site:findAcrossSites` in Rust for cross-site same-title fallback candidates. +- Added a token-protected local media proxy with HLS playlist rewriting, header forwarding, CORS, range support, shared runtime/client reuse, media streaming, and bounded proxy item storage. +- Improved `VideoPlayer` protocol handling so HLS, DASH, and native playback paths are treated more appropriately. +- Added structured playback diagnostics with stage, error kind, protocol, source id, attempts, and next action. +- Added diagnostic copy, retry-current, switch-source, first-frame metrics, and failure samples. +- Added a Tauri WebView smoke harness for first-frame success and structured failure diagnostics. + +## Continue Watching + +- Migrated history schema to v3 with episode id/name/index, source index/name, URL identifier, duration, position, and completed state. +- Persisted playback progress every 15 seconds and on pause, end, player unmount, and source/episode switches. +- Marked videos watched past 95% as completed and reset resume position to the beginning. +- Added a detail-page continue button that restores the saved source, episode, and timestamp. +- Added a home-page continue-watching section. +- Improved the history page to show episode/source/timestamp context instead of only a raw percentage. +- Added an automated force-restart smoke test that seeds continue-watching history, kills Tauri with `SIGKILL`, relaunches with the same data directory, and verifies the resume target. + +## Validation + +- `npm run check` passes: TypeScript, Vitest, Rust tests, web build, and Tauri debug build. +- Vitest passes: 5 test files, 22 tests. +- Rust tests pass: 102 tests. +- `npm run smoke:alpha-playback-ui` validates Tauri WebView playback paths. +- `npm run smoke:beta-continue` validates continue-watching persistence across force restart. +- `git diff --check` passes. + +## Known Follow-ups + +- Add source-health scoring so repeated real playback successes/failures affect search, fallback, and live source ranking. +- Expand protocol-specific user-facing error copy for DASH, native media, CORS/header issues, and unsupported formats. +- Add release screenshots for parse failure, automatic source switching, diagnostics, and continue-watching states. +- Continue improving macOS release readiness: signing, notarization, automatic updates, crash recovery, and long-play memory checks. diff --git a/out/renderer/src/renderer/src/App.js b/out/renderer/src/renderer/src/App.js index 6d92020..04ef94b 100644 --- a/out/renderer/src/renderer/src/App.js +++ b/out/renderer/src/renderer/src/App.js @@ -1,5 +1,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'; import Sidebar from './components/Sidebar/Sidebar'; import MiniPlayer from './components/MiniPlayer/MiniPlayer'; @@ -11,8 +11,10 @@ import History from './pages/History/History'; import Keep from './pages/Keep/Keep'; import Settings from './pages/Settings/Settings'; import Onboarding from './pages/Onboarding/Onboarding'; +import AlphaPlaybackSmoke from './components/AlphaPlaybackSmoke/AlphaPlaybackSmoke'; +import BetaContinueSmoke from './components/BetaContinueSmoke/BetaContinueSmoke'; import { useConfigStore } from './stores/useConfigStore'; -import { configApi } from './utils/ipc'; +import { configApi, settingsApi } from './utils/ipc'; /** 检测是否为精简模式 */ function isMiniMode() { const params = new URLSearchParams(window.location.search); @@ -23,6 +25,8 @@ export default function App() { const navigate = useNavigate(); const location = useLocation(); const didAutoLoad = useRef(false); + const [smokeConfig, setSmokeConfig] = useState(null); + const [betaContinueSmokeConfig, setBetaContinueSmokeConfig] = useState(null); // 精简模式:只渲染 MiniPlayer if (isMiniMode()) { return _jsx(MiniPlayer, {}); @@ -53,5 +57,28 @@ export default function App() { }; autoLoad(); }, [loadConfig, location.pathname, navigate]); + useEffect(() => { + let cancelled = false; + const loadSmokeConfig = async () => { + const value = await settingsApi.get('__alphaPlaybackSmoke'); + if (!cancelled && value?.enabled) { + setSmokeConfig(value); + } + const betaValue = await settingsApi.get('__betaContinueSmoke'); + if (!cancelled && betaValue?.enabled) { + setBetaContinueSmokeConfig(betaValue); + } + }; + void loadSmokeConfig(); + return () => { + cancelled = true; + }; + }, []); + if (smokeConfig?.enabled) { + return _jsx(AlphaPlaybackSmoke, { config: smokeConfig }); + } + if (betaContinueSmokeConfig?.enabled) { + return _jsx(BetaContinueSmoke, { config: betaContinueSmokeConfig }); + } return (_jsxs("div", { className: "flex h-screen w-screen overflow-hidden bg-bg-primary", children: [_jsx(Sidebar, {}), _jsxs("main", { className: "flex-1 flex flex-col overflow-hidden", children: [_jsx("div", { className: "shrink-0 h-8 flex items-center px-4 bg-bg-secondary border-b border-[#2a2a2a]", style: { WebkitAppRegion: 'drag' }, children: _jsx("span", { className: "text-[10px] text-text-muted select-none", children: "IPTV" }) }), _jsx("div", { className: "flex-1 overflow-hidden", children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Home, {}) }), _jsx(Route, { path: "/vod/:siteKey/:vodId", element: _jsx(VodDetail, {}) }), _jsx(Route, { path: "/live", element: _jsx(Live, {}) }), _jsx(Route, { path: "/search", element: _jsx(Search, {}) }), _jsx(Route, { path: "/history", element: _jsx(History, {}) }), _jsx(Route, { path: "/keep", element: _jsx(Keep, {}) }), _jsx(Route, { path: "/settings", element: _jsx(Settings, {}) }), _jsx(Route, { path: "/onboarding", element: _jsx(Onboarding, {}) })] }) })] })] })); } diff --git a/out/renderer/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.d.ts b/out/renderer/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.d.ts new file mode 100644 index 0000000..47bfad4 --- /dev/null +++ b/out/renderer/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.d.ts @@ -0,0 +1,8 @@ +export interface AlphaPlaybackSmokeConfig { + enabled: boolean; + mediaUrl?: string; + timeoutMs?: number; +} +export default function AlphaPlaybackSmoke({ config }: { + config: AlphaPlaybackSmokeConfig; +}): import("react").JSX.Element; diff --git a/out/renderer/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.js b/out/renderer/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.js new file mode 100644 index 0000000..768e668 --- /dev/null +++ b/out/renderer/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.js @@ -0,0 +1,78 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useRef, useState } from 'react'; +import VideoPlayer from '@/components/VideoPlayer/VideoPlayer'; +import { usePlayerStore } from '@/stores/usePlayerStore'; +import { settingsApi } from '@/utils/ipc'; +import { getPlayableMediaUrl } from '@/utils/media'; +import { clearPlaybackMetrics, getPlaybackMetricSummary } from '@/utils/playbackMetrics'; +const RESULT_KEY = '__alphaPlaybackSmokeResult'; +const DEFAULT_MEDIA_URL = 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4'; +const DEFAULT_TIMEOUT_MS = 20000; +export default function AlphaPlaybackSmoke({ config }) { + const wroteResultRef = useRef(false); + const [lastResult, setLastResult] = useState(null); + const { playbackPhase, playbackMessage, playbackError, playbackStartedAt, playbackFirstFrameAt, playbackDiagnostic, setVod, setVolume, reset } = usePlayerStore(); + const mediaUrl = config.mediaUrl || DEFAULT_MEDIA_URL; + const timeoutMs = config.timeoutMs || DEFAULT_TIMEOUT_MS; + useEffect(() => { + let cancelled = false; + clearPlaybackMetrics(); + reset(); + setVolume(0); + const startPlayback = async () => { + const playableUrl = await getPlayableMediaUrl(mediaUrl); + if (cancelled) + return; + setVod({ + vod_id: 'alpha-smoke', + vod_name: 'Alpha 2.1 Playback Smoke', + vod_pic: '', + vod_remarks: '自动首帧验证' + }, [{ name: '首帧验证', url: playableUrl }], 0, playableUrl, undefined, 'alpha-smoke'); + }; + void startPlayback(); + return () => { + cancelled = true; + }; + }, [mediaUrl, reset, setVod, setVolume]); + useEffect(() => { + if (wroteResultRef.current) + return; + const timer = window.setTimeout(() => { + if (!wroteResultRef.current) { + void writeResult(false, `首帧超时: ${timeoutMs}ms`); + } + }, timeoutMs); + return () => window.clearTimeout(timer); + }, [timeoutMs]); + useEffect(() => { + if (wroteResultRef.current) + return; + if (playbackFirstFrameAt > 0) { + const elapsedMs = playbackStartedAt ? Math.max(0, playbackFirstFrameAt - playbackStartedAt) : undefined; + void writeResult(true, '首帧播放成功', elapsedMs); + } + else if (playbackPhase === 'failed') { + void writeResult(false, playbackError || playbackMessage || '播放失败'); + } + }, [playbackError, playbackFirstFrameAt, playbackMessage, playbackPhase, playbackStartedAt]); + async function writeResult(ok, message, elapsedMs) { + wroteResultRef.current = true; + const result = { + ok, + at: new Date().toISOString(), + elapsedMs, + phase: usePlayerStore.getState().playbackPhase, + message, + error: usePlayerStore.getState().playbackError || undefined, + diagnostic: usePlayerStore.getState().playbackDiagnostic || undefined, + metrics: getPlaybackMetricSummary(), + debug: window.__alphaPlaybackDebug, + mediaUrl, + userAgent: navigator.userAgent + }; + setLastResult(result); + await settingsApi.set(RESULT_KEY, result); + } + return (_jsxs("div", { className: "h-screen w-screen bg-black text-white", children: [_jsx(VideoPlayer, {}), _jsxs("div", { className: "fixed left-4 top-4 z-50 max-w-lg rounded-md border border-white/20 bg-black/80 p-3 text-xs", children: [_jsx("div", { className: "font-medium", children: "Alpha 2.1 Playback Smoke" }), _jsxs("div", { className: "mt-1 text-white/70", children: ["phase=", playbackPhase] }), _jsxs("div", { className: "mt-1 text-white/70", children: ["message=", playbackMessage || '-'] }), _jsxs("div", { className: "mt-1 text-white/70", children: ["firstFrame=", playbackFirstFrameAt ? 'yes' : 'no'] }), lastResult && (_jsxs("div", { className: lastResult.ok ? 'mt-2 text-green-300' : 'mt-2 text-red-300', children: [lastResult.ok ? 'PASS' : 'FAIL', " ", lastResult.elapsedMs ? `${lastResult.elapsedMs}ms` : ''] }))] })] })); +} diff --git a/out/renderer/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.d.ts b/out/renderer/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.d.ts new file mode 100644 index 0000000..9b193aa --- /dev/null +++ b/out/renderer/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.d.ts @@ -0,0 +1,9 @@ +export interface BetaContinueSmokeConfig { + enabled: boolean; + phase: 'seed' | 'validate'; + positionSeconds?: number; + toleranceSeconds?: number; +} +export default function BetaContinueSmoke({ config }: { + config: BetaContinueSmokeConfig; +}): import("react").JSX.Element; diff --git a/out/renderer/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.js b/out/renderer/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.js new file mode 100644 index 0000000..1f43e19 --- /dev/null +++ b/out/renderer/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.js @@ -0,0 +1,100 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useRef, useState } from 'react'; +import { historyApi, settingsApi } from '@/utils/ipc'; +const RESULT_KEY = '__betaContinueSmokeResult'; +const SMOKE_SITE_KEY = 'beta-smoke-site'; +const SMOKE_VOD_ID = 'beta-smoke-vod'; +const DEFAULT_POSITION_SECONDS = 372; +const DEFAULT_TOLERANCE_SECONDS = 20; +function isSmokeHistory(item) { + return item.siteKey === SMOKE_SITE_KEY && item.vodId === SMOKE_VOD_ID; +} +export default function BetaContinueSmoke({ config }) { + const wroteRef = useRef(false); + const [result, setResult] = useState(null); + const expectedPositionSeconds = config.positionSeconds || DEFAULT_POSITION_SECONDS; + const toleranceSeconds = config.toleranceSeconds || DEFAULT_TOLERANCE_SECONDS; + useEffect(() => { + if (wroteRef.current) + return; + wroteRef.current = true; + const run = async () => { + if (config.phase === 'seed') { + await historyApi.add({ + siteKey: SMOKE_SITE_KEY, + vodId: SMOKE_VOD_ID, + vodName: 'Beta Continue Watching Smoke', + vodPic: '', + vodRemarks: '自动恢复验证', + source: '高清线路 · 第 3 集', + progress: 31, + episodeId: '2', + episodeName: '第 3 集', + episodeIndex: 2, + sourceIndex: 1, + sourceName: '高清线路', + urlIdentifier: 'smoke.example/video/index.m3u8', + duration: 1200, + positionSeconds: expectedPositionSeconds, + completed: false + }); + await writeResult({ + ok: true, + phase: 'seed', + message: 'seeded continue-watching history', + expectedPositionSeconds, + actualPositionSeconds: expectedPositionSeconds, + toleranceSeconds + }); + return; + } + const history = (await historyApi.list()).find(isSmokeHistory); + if (!history) { + await writeResult({ + ok: false, + phase: 'validate', + message: 'history item missing after force restart', + expectedPositionSeconds, + toleranceSeconds + }); + return; + } + const actualPositionSeconds = history.positionSeconds || 0; + const positionDelta = Math.abs(actualPositionSeconds - expectedPositionSeconds); + const ok = positionDelta <= toleranceSeconds && + history.episodeIndex === 2 && + history.sourceIndex === 1 && + history.sourceName === '高清线路' && + history.completed === false; + await writeResult({ + ok, + phase: 'validate', + message: ok + ? 'continue-watching history survived force restart' + : `resume mismatch: delta=${positionDelta}s`, + expectedPositionSeconds, + actualPositionSeconds, + toleranceSeconds, + history + }); + }; + void run().catch((error) => { + void writeResult({ + ok: false, + phase: config.phase, + message: error instanceof Error ? error.message : String(error), + expectedPositionSeconds, + toleranceSeconds + }); + }); + }, [config.phase, expectedPositionSeconds, toleranceSeconds]); + async function writeResult(next) { + const result = { + ...next, + at: new Date().toISOString() + }; + setResult(result); + await settingsApi.set(RESULT_KEY, result); + } + return (_jsx("div", { className: "flex h-screen w-screen items-center justify-center bg-black text-white", children: _jsxs("div", { className: "max-w-lg rounded-md border border-white/20 bg-white/10 p-4 text-sm", children: [_jsx("div", { className: "font-medium", children: "Beta 1 Continue Watching Smoke" }), _jsxs("div", { className: "mt-2 text-white/70", children: ["phase=", config.phase] }), _jsxs("div", { className: "mt-1 text-white/70", children: ["expected=", expectedPositionSeconds, "s"] }), result && (_jsxs("div", { className: result.ok ? 'mt-3 text-green-300' : 'mt-3 text-red-300', children: [result.ok ? 'PASS' : 'FAIL', " ", result.message] }))] }) })); +} diff --git a/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.d.ts b/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.d.ts index c512faf..8bd58e1 100644 --- a/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.d.ts +++ b/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.d.ts @@ -1 +1,10 @@ +declare global { + interface Window { + __alphaPlaybackDebug?: { + protocol?: string; + url?: string; + events: Array>; + }; + } +} export default function VideoPlayer(): import("react").JSX.Element; diff --git a/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.js b/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.js index c2ea2f8..85187f2 100644 --- a/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.js +++ b/out/renderer/src/renderer/src/components/VideoPlayer/VideoPlayer.js @@ -6,11 +6,14 @@ import { usePlayerStore } from '@/stores/usePlayerStore'; import DanmakuLayer from '@/components/DanmakuLayer/DanmakuLayer'; import SubtitleLayer from '@/components/SubtitleLayer/SubtitleLayer'; import { Play, Pause, Volume2, VolumeX, Maximize, Minimize, SkipBack, SkipForward, MessageSquare, PictureInPicture2, Loader2, ClipboardList, Copy, X, RotateCw, Shuffle } from 'lucide-react'; -import { windowApi } from '@/utils/ipc'; +import { historyApi, windowApi } from '@/utils/ipc'; import { redactHeaders, redactText } from '@/utils/redact'; +import { getPlaybackMetricSummary, recordPlaybackMetric } from '@/utils/playbackMetrics'; const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; const MAX_HLS_NETWORK_RECOVERY_ATTEMPTS = 3; const MAX_HLS_MEDIA_RECOVERY_ATTEMPTS = 2; +const MAX_HLS_DEBUG_EVENTS = 20; +const HISTORY_SAVE_INTERVAL_MS = 15_000; const emptyStreamStats = { bitrateKbps: null, linkSpeedKbps: null, @@ -23,6 +26,46 @@ function formatThroughput(kbps) { return `${(kbps / 1000).toFixed(kbps >= 10000 ? 0 : 1)} Mbps`; return `${Math.round(kbps)} Kbps`; } +function inferProtocol(url) { + if (!url) + return 'unknown'; + const candidates = [url]; + try { + const nestedUrl = new URL(url).searchParams.get('url'); + if (nestedUrl) + candidates.unshift(nestedUrl); + } + catch { + // Keep the original string fallback for non-URL values. + } + if (candidates.some((candidate) => /\.m3u8(?:$|[?&#])/i.test(candidate))) + return 'hls'; + if (candidates.some((candidate) => /\.mpd(?:$|[?&#])/i.test(candidate))) + return 'dash'; + if (candidates.some((candidate) => /\.(mp4|m4v|webm|mov|flv|ts)(?:$|[?&#])/i.test(candidate))) + return 'native'; + return 'unknown'; +} +function appendHlsDebugEvent(event) { + if (typeof window === 'undefined') + return; + const debug = window.__alphaPlaybackDebug || { events: [] }; + debug.events = [...debug.events, { at: Date.now(), ...event }].slice(-MAX_HLS_DEBUG_EVENTS); + window.__alphaPlaybackDebug = debug; +} +function formatUrlIdentifier(url) { + if (!url) + return ''; + try { + const parsed = new URL(url); + const nested = parsed.searchParams.get('url'); + const target = nested ? new URL(nested) : parsed; + return `${target.hostname}${target.pathname}`.slice(0, 240); + } + catch { + return url.split('?')[0].slice(0, 240); + } +} export default function VideoPlayer() { const videoRef = useRef(null); const containerRef = useRef(null); @@ -43,15 +86,22 @@ export default function VideoPlayer() { const lastFragLoadedAtRef = useRef(0); /** stalled 防抖定时器 - 持续停滞超过阈值才显示覆盖层 */ const stalledTimerRef = useRef(0); + const pendingSeekRef = useRef(0); + const lastHistorySaveAtRef = useRef(0); // 用 ref 缓存 store 中的 siteKey/vodId,避免 reportPlayFailure 引用变化导致 effect 重跑 const currentSiteKeyRef = useRef(''); const currentVodIdRef = useRef(''); - const { isPlaying, currentUrl, playKey, currentTime, duration, speed, volume, isDanmakuOn, isFullscreen, episodes, currentEpisodeIndex, playHeader, currentSiteKey, currentVod, playbackPhase, playbackMessage, playbackError, playbackStartedAt, playbackFirstFrameAt, playbackLastErrorAt, sourceSwitchState, sourceSwitchMessage, autoSwitchSource, setIsPlaying, setCurrentTime, setDuration, setSpeed, setVolume, toggleDanmaku, toggleFullscreen, nextEpisode, prevEpisode, setPlaybackPhase, markPlaybackFirstFrame, setAutoSwitchSource } = usePlayerStore(); + const { isPlaying, currentUrl, playKey, currentTime, duration, speed, volume, isDanmakuOn, isFullscreen, episodes, currentEpisodeIndex, currentSourceIndex, playHeader, currentSiteKey, currentVod, playbackPhase, playbackMessage, playbackError, playbackStartedAt, playbackFirstFrameAt, playbackLastErrorAt, playbackDiagnostic, alternativeSources, brokenSources, sourceSwitchState, sourceSwitchMessage, autoSwitchSource, setIsPlaying, setCurrentTime, setDuration, setSpeed, setVolume, toggleDanmaku, toggleFullscreen, nextEpisode, prevEpisode, setPlaybackPhase, markPlaybackFirstFrame, setAutoSwitchSource } = usePlayerStore(); // 同步 siteKey/vodId 到 ref(变化时不影响 effect) useEffect(() => { currentSiteKeyRef.current = currentSiteKey || ''; currentVodIdRef.current = currentVod?.vod_id || ''; }, [currentSiteKey, currentVod?.vod_id]); + useEffect(() => { + const target = usePlayerStore.getState().currentTime; + pendingSeekRef.current = target > 3 ? target : 0; + lastHistorySaveAtRef.current = 0; + }, [currentUrl, playKey]); const [showControls, setShowControls] = useState(true); const [showSpeedMenu, setShowSpeedMenu] = useState(false); const [showDiagnostics, setShowDiagnostics] = useState(false); @@ -67,9 +117,48 @@ export default function VideoPlayer() { failureReportedRef.current = true; const siteKey = currentSiteKeyRef.current; const vodId = currentVodIdRef.current; + const store = usePlayerStore.getState(); + const elapsedMs = store.playbackStartedAt ? Math.max(0, Date.now() - store.playbackStartedAt) : undefined; + const protocol = inferProtocol(store.currentUrl); + const lowerReason = reason.toLowerCase(); + const stage = reason.includes('清单') || reason.includes('MANIFEST') || lowerReason.includes('manifest') + ? 'manifest' + : reason.includes('超时') || lowerReason.includes('timeout') + ? 'connect' + : reason.includes('媒体') || reason.includes('视频') + ? 'media' + : reason.includes('网络') || lowerReason.includes('network') + ? 'network' + : protocol === 'hls' + ? 'manifest' + : 'unknown'; + const errorKind = reason.includes('超时') || lowerReason.includes('timeout') + ? 'timeout' + : protocol === 'hls' || lowerReason.includes('hls') + ? 'hls' + : reason.includes('媒体') || reason.includes('视频') + ? 'media' + : 'unknown'; console.error('[VideoPlayer] 播放失败:', reason, 'siteKey:', siteKey, 'vodId:', vodId); - usePlayerStore.getState().setPlaybackError(reason); - const detail = { siteKey, vodId, reason, autoSwitch: true }; + store.setPlaybackError(reason, { + stage, + errorKind, + protocol, + elapsedMs, + sourceId: siteKey && vodId ? `${siteKey}::${vodId}` : undefined, + nextAction: store.autoSwitchSource ? '自动尝试下一条可用线路' : '可手动重试或切换线路' + }); + recordPlaybackMetric({ + type: 'failure', + at: Date.now(), + elapsedMs, + phase: store.playbackPhase, + stage, + errorKind, + protocol, + sourceId: siteKey && vodId ? `${siteKey}::${vodId}` : undefined + }); + const detail = { siteKey, vodId, reason, autoSwitch: true, diagnostic: usePlayerStore.getState().playbackDiagnostic }; window.dispatchEvent(new CustomEvent(siteKey || vodId ? 'vod:playFailed' : 'live:playFailed', { detail })); }); // 清除加载超时的工具函数 @@ -95,9 +184,80 @@ export default function VideoPlayer() { const video = videoRef.current; console.log('[VideoPlayer] 首帧播放成功:', `time=${video?.currentTime.toFixed(2) || '0.00'}`, `size=${video?.videoWidth || 0}x${video?.videoHeight || 0}`); markPlaybackFirstFrame(); + const store = usePlayerStore.getState(); + const elapsedMs = store.playbackStartedAt ? Math.max(0, Date.now() - store.playbackStartedAt) : undefined; + recordPlaybackMetric({ + type: 'first_frame', + at: Date.now(), + elapsedMs, + phase: store.playbackPhase, + protocol: inferProtocol(store.currentUrl), + sourceId: store.currentSiteKey && store.currentVod ? `${store.currentSiteKey}::${store.currentVod.vod_id}` : undefined + }); clearLoadTimeout(); clearStalledTimer(); }, [clearLoadTimeout, clearStalledTimer, markPlaybackFirstFrame]); + const applyPendingSeek = useCallback(() => { + const video = videoRef.current; + const target = pendingSeekRef.current; + if (!video || target <= 3) + return; + if (!Number.isFinite(video.duration) || video.duration <= target + 2) + return; + video.currentTime = Math.min(target, Math.max(0, video.duration - 2)); + pendingSeekRef.current = 0; + }, []); + const savePlaybackHistory = useCallback((force = false, completedOverride) => { + const video = videoRef.current; + const store = usePlayerStore.getState(); + const vod = store.currentVod; + if (!vod || !store.currentSiteKey || !store.currentUrl || store.currentUrl === '__resolving__') + return; + const now = Date.now(); + if (!force && now - lastHistorySaveAtRef.current < HISTORY_SAVE_INTERVAL_MS) + return; + const rawDuration = video?.duration || store.duration || 0; + const durationSeconds = Number.isFinite(rawDuration) ? Math.max(0, Math.round(rawDuration)) : 0; + const rawPosition = video?.currentTime ?? store.currentTime; + const positionSeconds = Number.isFinite(rawPosition) ? Math.max(0, Math.round(rawPosition)) : 0; + if (!force && positionSeconds < 5) + return; + const completed = completedOverride ?? (durationSeconds > 0 && positionSeconds / durationSeconds >= 0.95); + const savedPosition = completed ? 0 : positionSeconds; + const progress = durationSeconds > 0 + ? Math.max(0, Math.min(100, Math.round((positionSeconds / durationSeconds) * 100))) + : 0; + const episode = store.episodes[store.currentEpisodeIndex]; + const sourceName = store.currentSourceName || `线路 ${store.currentSourceIndex + 1}`; + const episodeName = episode?.name || `第 ${store.currentEpisodeIndex + 1} 集`; + lastHistorySaveAtRef.current = now; + historyApi.add({ + siteKey: store.currentSiteKey, + vodId: vod.vod_id, + vodName: vod.vod_name, + vodPic: vod.vod_pic, + vodRemarks: vod.vod_remarks, + source: `${sourceName} · ${episodeName}`, + progress: completed ? 100 : progress, + episodeId: String(store.currentEpisodeIndex), + episodeName, + episodeIndex: store.currentEpisodeIndex, + sourceIndex: store.currentSourceIndex, + sourceName, + urlIdentifier: formatUrlIdentifier(store.currentUrl), + duration: durationSeconds, + positionSeconds: savedPosition, + completed + }).catch(() => { }); + }, []); + useEffect(() => { + const handleFlushHistory = () => savePlaybackHistory(true); + window.addEventListener('player:flushHistory', handleFlushHistory); + return () => window.removeEventListener('player:flushHistory', handleFlushHistory); + }, [savePlaybackHistory]); + useEffect(() => { + return () => savePlaybackHistory(true); + }, [savePlaybackHistory]); // 进入精简模式 const handleEnterMiniMode = useCallback(async () => { if (!currentUrl) @@ -113,6 +273,12 @@ export default function VideoPlayer() { const video = videoRef.current; if (!video || !currentUrl) return; + const currentProtocol = inferProtocol(currentUrl); + window.__alphaPlaybackDebug = { + protocol: currentProtocol, + url: currentUrl, + events: [] + }; // 清理旧实例 hlsRef.current?.destroy(); dashRef.current?.reset(); @@ -120,6 +286,9 @@ export default function VideoPlayer() { dashRef.current = null; video.pause(); video.removeAttribute('src'); + video.muted = volume <= 0; + video.volume = volume; + video.playbackRate = speed; video.load(); // 重置失败标记和播放标记 failureReportedRef.current = false; @@ -140,19 +309,24 @@ export default function VideoPlayer() { } }, 25000); let hlsRecoveryTimer = 0; - if (currentUrl.includes('.mpd')) { + if (currentProtocol === 'dash') { // DASH const player = dashjs.MediaPlayer().create(); player.initialize(video, currentUrl, true); dashRef.current = player; } - else if (Hls.isSupported()) { + else if (currentProtocol === 'hls' && Hls.isSupported()) { let networkRecoveryAttempts = 0; let mediaRecoveryAttempts = 0; // 优先使用 HLS.js const hls = new Hls({ // 桌面 WebView 的 CSP 不允许 blob worker;主线程解析可避免创建失败导致黑屏。 enableWorker: false, + testBandwidth: false, + startFragPrefetch: true, + startLevel: 0, + maxBufferLength: 10, + maxMaxBufferLength: 30, xhrSetup: (xhr, _url) => { if (playHeader) { for (const [key, value] of Object.entries(playHeader)) { @@ -163,12 +337,46 @@ export default function VideoPlayer() { }); hls.loadSource(currentUrl); hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_LOADED, (_event, data) => { + appendHlsDebugEvent({ + event: 'manifest_loaded', + levels: data.levels?.length, + audioTracks: data.audioTracks?.length, + subtitles: data.subtitles?.length + }); + }); hls.on(Hls.Events.MANIFEST_PARSED, () => { console.log('[VideoPlayer] HLS manifest 解析成功'); + appendHlsDebugEvent({ event: 'manifest_parsed', levels: hls.levels.length }); setPlaybackPhase('buffering', '清单已加载,正在缓冲...'); video.play().catch(() => { }); }); + hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => { + appendHlsDebugEvent({ + event: 'level_loaded', + level: data.level, + fragments: data.details?.fragments?.length, + live: data.details?.live, + targetduration: data.details?.targetduration + }); + }); + hls.on(Hls.Events.FRAG_LOADING, (_event, data) => { + appendHlsDebugEvent({ + event: 'frag_loading', + sn: data.frag?.sn, + level: data.frag?.level, + type: data.frag?.type, + duration: data.frag?.duration + }); + }); hls.on(Hls.Events.FRAG_LOADED, (_event, data) => { + appendHlsDebugEvent({ + event: 'frag_loaded', + sn: data.frag?.sn, + level: data.frag?.level, + type: data.frag?.type, + payloadBytes: data.payload?.byteLength || 0 + }); networkRecoveryAttempts = 0; const now = performance.now(); const payloadBytes = data.payload?.byteLength || 0; @@ -192,13 +400,21 @@ export default function VideoPlayer() { }); hls.on(Hls.Events.ERROR, (_event, data) => { console.error('[VideoPlayer] HLS错误:', data.type, data.details, data.fatal); + appendHlsDebugEvent({ + event: 'hls_error', + type: data.type, + details: data.details, + fatal: data.fatal, + responseCode: data.response?.code, + responseText: data.response?.text, + reason: data.error?.message + }); if (failureReportedRef.current) return; if (!data.fatal && video.currentTime <= 0 && (data.details === Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT || - data.details === Hls.ErrorDetails.LEVEL_LOAD_ERROR || - data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR)) { + data.details === Hls.ErrorDetails.LEVEL_LOAD_ERROR)) { hls.stopLoad(); reportPlayFailureRef.current(`HLS首帧加载失败: ${data.details}`); return; @@ -248,7 +464,7 @@ export default function VideoPlayer() { }); hlsRef.current = hls; } - else if (video.canPlayType('application/vnd.apple.mpegurl')) { + else if (currentProtocol === 'hls' && video.canPlayType('application/vnd.apple.mpegurl')) { // Safari 原生 HLS video.src = currentUrl; video.play().catch(() => { }); @@ -344,6 +560,7 @@ export default function VideoPlayer() { if (!video) return; video.volume = volume; + video.muted = volume <= 0; video.playbackRate = speed; }, [volume, speed]); // 视频事件 @@ -355,12 +572,26 @@ export default function VideoPlayer() { setCurrentTime(video.currentTime); if (video.currentTime > 0) markPlaybackStarted(); + savePlaybackHistory(false); + }; + const onDurationChange = () => { + setDuration(video.duration || 0); + applyPendingSeek(); }; - const onDurationChange = () => setDuration(video.duration || 0); const onPlay = () => setIsPlaying(true); - const onPause = () => setIsPlaying(false); + const onPause = () => { + setIsPlaying(false); + savePlaybackHistory(true); + }; + const onLoadedMetadata = () => applyPendingSeek(); + const onLoadedData = () => { + applyPendingSeek(); + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + markPlaybackStarted(); + } + }; const onPlaying = () => { - if (video.currentTime > 0) { + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA || video.currentTime > 0) { markPlaybackStarted(); // 已开始播放时隐藏 stalled/buffering 覆盖层 clearStalledTimer(); @@ -370,6 +601,7 @@ export default function VideoPlayer() { } }; const onEnded = () => { + savePlaybackHistory(true, true); if (currentEpisodeIndex < episodes.length - 1) nextEpisode(); else @@ -379,6 +611,8 @@ export default function VideoPlayer() { video.addEventListener('durationchange', onDurationChange); video.addEventListener('play', onPlay); video.addEventListener('pause', onPause); + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('loadeddata', onLoadedData); video.addEventListener('playing', onPlaying); video.addEventListener('ended', onEnded); return () => { @@ -386,10 +620,22 @@ export default function VideoPlayer() { video.removeEventListener('durationchange', onDurationChange); video.removeEventListener('play', onPlay); video.removeEventListener('pause', onPause); + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('loadeddata', onLoadedData); video.removeEventListener('playing', onPlaying); video.removeEventListener('ended', onEnded); }; - }, [currentEpisodeIndex, episodes.length, markPlaybackStarted, nextEpisode, setDuration, setCurrentTime, setIsPlaying]); + }, [ + applyPendingSeek, + currentEpisodeIndex, + episodes.length, + markPlaybackStarted, + nextEpisode, + savePlaybackHistory, + setDuration, + setCurrentTime, + setIsPlaying + ]); // 控制栏自动隐藏 const resetHideTimer = useCallback(() => { setShowControls(true); @@ -518,19 +764,30 @@ export default function VideoPlayer() { const bitrateText = formatThroughput(streamStats.bitrateKbps); const linkSpeedText = formatThroughput(streamStats.linkSpeedKbps); const buildDiagnosticsText = () => { + const metricSummary = getPlaybackMetricSummary(); const lines = [ 'IPTV Mac 播放诊断', `阶段: ${playbackPhase}`, `状态: ${playbackMessage || '-'}`, `错误: ${playbackError || '-'}`, + `错误阶段: ${playbackDiagnostic?.stage || '-'}`, + `错误类型: ${playbackDiagnostic?.errorKind || '-'}`, + `协议: ${playbackDiagnostic?.protocol || inferProtocol(currentUrl)}`, `站点: ${currentSiteKey || '-'}`, `影片: ${currentVod?.vod_name || '-'}`, `集数: ${episodes[currentEpisodeIndex]?.name || currentEpisodeIndex + 1 || '-'}`, - `线路索引: ${currentEpisodeIndex + 1}/${episodes.length || 0}`, + `线路索引: ${currentSourceIndex + 1}`, + `已失败源: ${brokenSources.size}`, + `备选源: ${alternativeSources.length}`, `首帧耗时: ${firstFrameMs == null ? '-' : `${firstFrameMs}ms`}`, `当前耗时: ${elapsedMs}ms`, `失败时间: ${playbackLastErrorAt ? new Date(playbackLastErrorAt).toISOString() : '-'}`, `换源状态: ${sourceSwitchState}${sourceSwitchMessage ? ` - ${sourceSwitchMessage}` : ''}`, + `下一步: ${playbackDiagnostic?.nextAction || (autoSwitchSource ? '自动换源开启' : '等待手动操作')}`, + `首帧样本数: ${metricSummary.totalFirstFrameSamples}`, + `首帧P50: ${metricSummary.p50FirstFrameMs == null ? '-' : `${metricSummary.p50FirstFrameMs}ms`}`, + `首帧P90: ${metricSummary.p90FirstFrameMs == null ? '-' : `${metricSummary.p90FirstFrameMs}ms`}`, + `失败样本数: ${metricSummary.totalFailures}`, `URL: ${redactedUrl || '-'}`, `Headers: ${Object.keys(redactedHeaders).length ? JSON.stringify(redactedHeaders) : '-'}` ]; @@ -566,7 +823,7 @@ export default function VideoPlayer() { const handleNextSource = () => { window.dispatchEvent(new CustomEvent('player:nextSource')); }; - return (_jsxs("div", { ref: containerRef, className: "relative w-full h-full bg-black group", onMouseMove: resetHideTimer, onMouseLeave: () => isPlaying && setShowControls(false), onDoubleClick: handleToggleFullscreen, children: [_jsx("video", { ref: videoRef, className: "w-full h-full object-contain", playsInline: true }), currentUrl && (_jsxs("div", { className: "pointer-events-none absolute right-3 top-3 z-20 min-w-[132px] rounded-lg border border-white/10 bg-black/55 px-3 py-2 text-[11px] leading-4 text-white/80 shadow-lg backdrop-blur", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "text-white/45", children: "\u7801\u7387" }), _jsx("span", { className: "font-mono text-white", children: bitrateText })] }), _jsxs("div", { className: "mt-0.5 flex items-center justify-between gap-3", children: [_jsx("span", { className: "text-white/45", children: "\u901F\u5EA6" }), _jsx("span", { className: "font-mono text-white", children: linkSpeedText })] })] })), showPlaybackOverlay && (_jsxs("div", { className: "absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/80 px-6 text-center", children: [playbackPhase === 'failed' ? (_jsx("div", { className: "flex h-10 w-10 items-center justify-center rounded-full border border-red-400/40 text-red-400", children: "!" })) : (_jsx(Loader2, { className: "w-8 h-8 animate-spin text-accent" })), _jsx("span", { className: "text-sm text-white/80", children: overlayText }), playbackPhase === 'failed' && (_jsx("span", { className: "max-w-md text-xs leading-5 text-white/50", children: "\u5E94\u7528\u4F1A\u5C1D\u8BD5\u81EA\u52A8\u6362\u6E90\uFF1B\u4E5F\u53EF\u4EE5\u5207\u6362\u7EBF\u8DEF\u6216\u91CD\u8BD5\u5F53\u524D\u96C6\u3002" }))] })), isDanmakuOn && _jsx(DanmakuLayer, {}), _jsx(SubtitleLayer, {}), _jsxs("div", { className: `absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-10 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'}`, children: [_jsx("div", { className: "mb-2 cursor-pointer group/progress", onClick: handleProgressClick, children: _jsx("div", { className: "h-1 group-hover/progress:h-2 bg-white/20 rounded-full transition-all relative", children: _jsx("div", { className: "h-full bg-accent rounded-full relative", style: { width: `${progress}%` }, children: _jsx("div", { className: "absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-accent rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity" }) }) }) }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("button", { onClick: prevEpisode, className: "p-1 text-white/80 hover:text-white", children: _jsx(SkipBack, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => setIsPlaying(!isPlaying), className: "p-1 text-white hover:text-accent", children: isPlaying ? _jsx(Pause, { className: "w-5 h-5" }) : _jsx(Play, { className: "w-5 h-5" }) }), _jsx("button", { onClick: nextEpisode, className: "p-1 text-white/80 hover:text-white", children: _jsx(SkipForward, { className: "w-4 h-4" }) }), _jsxs("span", { className: "text-xs text-white/70 ml-2", children: [formatTime(currentTime), " / ", formatTime(duration)] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("button", { onClick: () => setVolume(volume > 0 ? 0 : 1), className: "p-1 text-white/80 hover:text-white", children: volume > 0 ? _jsx(Volume2, { className: "w-4 h-4" }) : _jsx(VolumeX, { className: "w-4 h-4" }) }), _jsx("input", { type: "range", min: 0, max: 1, step: 0.05, value: volume, onChange: (e) => setVolume(Number(e.target.value)), className: "w-16 h-1 accent-accent" }), _jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowSpeedMenu(!showSpeedMenu), className: "px-2 py-0.5 text-xs text-white/80 hover:text-white rounded border border-white/20", children: [speed, "x"] }), showSpeedMenu && (_jsx("div", { className: "absolute bottom-full right-0 mb-2 bg-bg-secondary rounded-lg shadow-xl border border-[#2a2a2a] py-1 min-w-[80px]", children: SPEEDS.map((s) => (_jsxs("button", { onClick: () => { setSpeed(s); setShowSpeedMenu(false); }, className: `w-full px-3 py-1.5 text-xs text-left hover:bg-bg-hover ${speed === s ? 'text-accent' : 'text-text-secondary'}`, children: [s, "x"] }, s))) }))] }), _jsx("button", { onClick: toggleDanmaku, className: `p-1 ${isDanmakuOn ? 'text-accent' : 'text-white/40 hover:text-white/70'}`, children: _jsx(MessageSquare, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => setShowDiagnostics(true), className: `p-1 ${playbackPhase === 'failed' ? 'text-red-400' : 'text-white/80 hover:text-white'}`, title: "\u64AD\u653E\u8BCA\u65AD", children: _jsx(ClipboardList, { className: "w-4 h-4" }) }), _jsx("button", { onClick: handleEnterMiniMode, className: "p-1 text-white/80 hover:text-white", title: "\u7CBE\u7B80\u6A21\u5F0F", children: _jsx(PictureInPicture2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: handleToggleFullscreen, className: "p-1 text-white/80 hover:text-white", children: isActualFullscreen ? _jsx(Minimize, { className: "w-4 h-4" }) : _jsx(Maximize, { className: "w-4 h-4" }) })] })] })] }), showDiagnostics && (_jsxs("div", { className: "absolute inset-y-0 right-0 z-40 w-full max-w-sm border-l border-white/10 bg-[#111]/95 shadow-2xl backdrop-blur", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-white/10 px-4 py-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ClipboardList, { className: "w-4 h-4 text-accent" }), _jsx("h3", { className: "text-sm font-medium text-white", children: "\u64AD\u653E\u8BCA\u65AD" })] }), _jsx("button", { onClick: () => setShowDiagnostics(false), className: "p-1 text-white/60 hover:text-white", title: "\u5173\u95ED", children: _jsx(X, { className: "w-4 h-4" }) })] }), _jsxs("div", { className: "space-y-4 overflow-y-auto px-4 py-4 text-xs text-white/70 scrollbar-dark h-[calc(100%-49px)]", children: [_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(DiagnosticMetric, { label: "\u9636\u6BB5", value: playbackPhase }), _jsx(DiagnosticMetric, { label: "\u9996\u5E27\u8017\u65F6", value: firstFrameMs == null ? '-' : `${firstFrameMs}ms` }), _jsx(DiagnosticMetric, { label: "\u5F53\u524D\u8017\u65F6", value: `${elapsedMs}ms` }), _jsx(DiagnosticMetric, { label: "\u97F3\u91CF/\u500D\u901F", value: `${Math.round(volume * 100)}% / ${speed}x` })] }), _jsxs(DiagnosticSection, { title: "\u72B6\u6001", children: [_jsx("p", { children: playbackMessage || '-' }), playbackError && _jsx("p", { className: "mt-2 text-red-300", children: playbackError }), _jsxs("label", { className: "mt-3 flex items-center justify-between gap-3 border-t border-white/10 pt-3", children: [_jsx("span", { className: "text-white/60", children: "\u81EA\u52A8\u6362\u6E90" }), _jsx("input", { type: "checkbox", checked: autoSwitchSource, onChange: (event) => setAutoSwitchSource(event.target.checked), className: "h-4 w-4 accent-accent" })] })] }), _jsxs(DiagnosticSection, { title: "\u5185\u5BB9", children: [_jsx(DiagnosticRow, { label: "\u7AD9\u70B9", value: currentSiteKey || '-' }), _jsx(DiagnosticRow, { label: "\u5F71\u7247", value: currentVod?.vod_name || '-' }), _jsx(DiagnosticRow, { label: "\u96C6\u6570", value: episodes[currentEpisodeIndex]?.name || String(currentEpisodeIndex + 1) }), _jsx(DiagnosticRow, { label: "\u6362\u6E90", value: `${sourceSwitchState}${sourceSwitchMessage ? ` - ${sourceSwitchMessage}` : ''}` })] }), _jsx(DiagnosticSection, { title: "\u7EBF\u8DEF", children: _jsx("p", { className: "break-all font-mono text-[11px] leading-5 text-white/60", children: redactedUrl || '-' }) }), _jsx(DiagnosticSection, { title: "\u8BF7\u6C42\u5934", children: _jsx("pre", { className: "whitespace-pre-wrap break-all font-mono text-[11px] leading-5 text-white/60", children: Object.keys(redactedHeaders).length ? JSON.stringify(redactedHeaders, null, 2) : '-' }) }), _jsxs("button", { onClick: handleCopyDiagnostics, className: "flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-medium text-bg-primary hover:bg-accent-hover", children: [_jsx(Copy, { className: "w-4 h-4" }), copyState === 'copied' ? '已复制' : copyState === 'failed' ? '复制失败' : '复制诊断信息'] }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsxs("button", { onClick: handleRetryPlayback, className: "flex items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm font-medium text-white/80 hover:bg-white/10", children: [_jsx(RotateCw, { className: "w-4 h-4" }), "\u91CD\u8BD5\u5F53\u524D"] }), _jsxs("button", { onClick: handleNextSource, className: "flex items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm font-medium text-white/80 hover:bg-white/10", children: [_jsx(Shuffle, { className: "w-4 h-4" }), "\u5207\u6362\u7EBF\u8DEF"] })] })] })] }))] })); + return (_jsxs("div", { ref: containerRef, className: "relative w-full h-full bg-black group", onMouseMove: resetHideTimer, onMouseLeave: () => isPlaying && setShowControls(false), onDoubleClick: handleToggleFullscreen, children: [_jsx("video", { ref: videoRef, className: "w-full h-full object-contain", playsInline: true, muted: volume <= 0 }), currentUrl && (_jsxs("div", { className: "pointer-events-none absolute right-3 top-3 z-20 min-w-[132px] rounded-lg border border-white/10 bg-black/55 px-3 py-2 text-[11px] leading-4 text-white/80 shadow-lg backdrop-blur", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "text-white/45", children: "\u7801\u7387" }), _jsx("span", { className: "font-mono text-white", children: bitrateText })] }), _jsxs("div", { className: "mt-0.5 flex items-center justify-between gap-3", children: [_jsx("span", { className: "text-white/45", children: "\u901F\u5EA6" }), _jsx("span", { className: "font-mono text-white", children: linkSpeedText })] })] })), showPlaybackOverlay && (_jsxs("div", { className: "absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/80 px-6 text-center", children: [playbackPhase === 'failed' ? (_jsx("div", { className: "flex h-10 w-10 items-center justify-center rounded-full border border-red-400/40 text-red-400", children: "!" })) : (_jsx(Loader2, { className: "w-8 h-8 animate-spin text-accent" })), _jsx("span", { className: "text-sm text-white/80", children: overlayText }), playbackPhase === 'failed' && (_jsx("span", { className: "max-w-md text-xs leading-5 text-white/50", children: "\u5E94\u7528\u4F1A\u5C1D\u8BD5\u81EA\u52A8\u6362\u6E90\uFF1B\u4E5F\u53EF\u4EE5\u5207\u6362\u7EBF\u8DEF\u6216\u91CD\u8BD5\u5F53\u524D\u96C6\u3002" }))] })), isDanmakuOn && _jsx(DanmakuLayer, {}), _jsx(SubtitleLayer, {}), _jsxs("div", { className: `absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-10 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'}`, children: [_jsx("div", { className: "mb-2 cursor-pointer group/progress", onClick: handleProgressClick, children: _jsx("div", { className: "h-1 group-hover/progress:h-2 bg-white/20 rounded-full transition-all relative", children: _jsx("div", { className: "h-full bg-accent rounded-full relative", style: { width: `${progress}%` }, children: _jsx("div", { className: "absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-accent rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity" }) }) }) }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("button", { onClick: prevEpisode, className: "p-1 text-white/80 hover:text-white", children: _jsx(SkipBack, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => setIsPlaying(!isPlaying), className: "p-1 text-white hover:text-accent", children: isPlaying ? _jsx(Pause, { className: "w-5 h-5" }) : _jsx(Play, { className: "w-5 h-5" }) }), _jsx("button", { onClick: nextEpisode, className: "p-1 text-white/80 hover:text-white", children: _jsx(SkipForward, { className: "w-4 h-4" }) }), _jsxs("span", { className: "text-xs text-white/70 ml-2", children: [formatTime(currentTime), " / ", formatTime(duration)] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("button", { onClick: () => setVolume(volume > 0 ? 0 : 1), className: "p-1 text-white/80 hover:text-white", children: volume > 0 ? _jsx(Volume2, { className: "w-4 h-4" }) : _jsx(VolumeX, { className: "w-4 h-4" }) }), _jsx("input", { type: "range", min: 0, max: 1, step: 0.05, value: volume, onChange: (e) => setVolume(Number(e.target.value)), className: "w-16 h-1 accent-accent" }), _jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowSpeedMenu(!showSpeedMenu), className: "px-2 py-0.5 text-xs text-white/80 hover:text-white rounded border border-white/20", children: [speed, "x"] }), showSpeedMenu && (_jsx("div", { className: "absolute bottom-full right-0 mb-2 bg-bg-secondary rounded-lg shadow-xl border border-[#2a2a2a] py-1 min-w-[80px]", children: SPEEDS.map((s) => (_jsxs("button", { onClick: () => { setSpeed(s); setShowSpeedMenu(false); }, className: `w-full px-3 py-1.5 text-xs text-left hover:bg-bg-hover ${speed === s ? 'text-accent' : 'text-text-secondary'}`, children: [s, "x"] }, s))) }))] }), _jsx("button", { onClick: toggleDanmaku, className: `p-1 ${isDanmakuOn ? 'text-accent' : 'text-white/40 hover:text-white/70'}`, children: _jsx(MessageSquare, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => setShowDiagnostics(true), className: `p-1 ${playbackPhase === 'failed' ? 'text-red-400' : 'text-white/80 hover:text-white'}`, title: "\u64AD\u653E\u8BCA\u65AD", children: _jsx(ClipboardList, { className: "w-4 h-4" }) }), _jsx("button", { onClick: handleEnterMiniMode, className: "p-1 text-white/80 hover:text-white", title: "\u7CBE\u7B80\u6A21\u5F0F", children: _jsx(PictureInPicture2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: handleToggleFullscreen, className: "p-1 text-white/80 hover:text-white", children: isActualFullscreen ? _jsx(Minimize, { className: "w-4 h-4" }) : _jsx(Maximize, { className: "w-4 h-4" }) })] })] })] }), showDiagnostics && (_jsxs("div", { className: "absolute inset-y-0 right-0 z-40 w-full max-w-sm border-l border-white/10 bg-[#111]/95 shadow-2xl backdrop-blur", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-white/10 px-4 py-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ClipboardList, { className: "w-4 h-4 text-accent" }), _jsx("h3", { className: "text-sm font-medium text-white", children: "\u64AD\u653E\u8BCA\u65AD" })] }), _jsx("button", { onClick: () => setShowDiagnostics(false), className: "p-1 text-white/60 hover:text-white", title: "\u5173\u95ED", children: _jsx(X, { className: "w-4 h-4" }) })] }), _jsxs("div", { className: "space-y-4 overflow-y-auto px-4 py-4 text-xs text-white/70 scrollbar-dark h-[calc(100%-49px)]", children: [_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(DiagnosticMetric, { label: "\u9636\u6BB5", value: playbackPhase }), _jsx(DiagnosticMetric, { label: "\u9996\u5E27\u8017\u65F6", value: firstFrameMs == null ? '-' : `${firstFrameMs}ms` }), _jsx(DiagnosticMetric, { label: "\u5F53\u524D\u8017\u65F6", value: `${elapsedMs}ms` }), _jsx(DiagnosticMetric, { label: "\u97F3\u91CF/\u500D\u901F", value: `${Math.round(volume * 100)}% / ${speed}x` })] }), _jsxs(DiagnosticSection, { title: "\u72B6\u6001", children: [_jsx("p", { children: playbackMessage || '-' }), playbackError && _jsx("p", { className: "mt-2 text-red-300", children: playbackError }), playbackDiagnostic && (_jsxs("div", { className: "mt-3 border-t border-white/10 pt-3", children: [_jsx(DiagnosticRow, { label: "\u9519\u8BEF\u9636\u6BB5", value: playbackDiagnostic.stage }), _jsx(DiagnosticRow, { label: "\u9519\u8BEF\u7C7B\u578B", value: playbackDiagnostic.errorKind }), _jsx(DiagnosticRow, { label: "\u4E0B\u4E00\u6B65", value: playbackDiagnostic.nextAction || '-' })] })), _jsxs("label", { className: "mt-3 flex items-center justify-between gap-3 border-t border-white/10 pt-3", children: [_jsx("span", { className: "text-white/60", children: "\u81EA\u52A8\u6362\u6E90" }), _jsx("input", { type: "checkbox", checked: autoSwitchSource, onChange: (event) => setAutoSwitchSource(event.target.checked), className: "h-4 w-4 accent-accent" })] })] }), _jsxs(DiagnosticSection, { title: "\u5185\u5BB9", children: [_jsx(DiagnosticRow, { label: "\u7AD9\u70B9", value: currentSiteKey || '-' }), _jsx(DiagnosticRow, { label: "\u5F71\u7247", value: currentVod?.vod_name || '-' }), _jsx(DiagnosticRow, { label: "\u96C6\u6570", value: episodes[currentEpisodeIndex]?.name || String(currentEpisodeIndex + 1) }), _jsx(DiagnosticRow, { label: "\u7EBF\u8DEF", value: String(currentSourceIndex + 1) }), _jsx(DiagnosticRow, { label: "\u6362\u6E90", value: `${sourceSwitchState}${sourceSwitchMessage ? ` - ${sourceSwitchMessage}` : ''}` }), _jsx(DiagnosticRow, { label: "\u5DF2\u5931\u8D25/\u5907\u9009", value: `${brokenSources.size}/${alternativeSources.length}` })] }), _jsx(DiagnosticSection, { title: "\u7EBF\u8DEF", children: _jsx("p", { className: "break-all font-mono text-[11px] leading-5 text-white/60", children: redactedUrl || '-' }) }), _jsx(DiagnosticSection, { title: "\u8BF7\u6C42\u5934", children: _jsx("pre", { className: "whitespace-pre-wrap break-all font-mono text-[11px] leading-5 text-white/60", children: Object.keys(redactedHeaders).length ? JSON.stringify(redactedHeaders, null, 2) : '-' }) }), _jsxs("button", { onClick: handleCopyDiagnostics, className: "flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-medium text-bg-primary hover:bg-accent-hover", children: [_jsx(Copy, { className: "w-4 h-4" }), copyState === 'copied' ? '已复制' : copyState === 'failed' ? '复制失败' : '复制诊断信息'] }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsxs("button", { onClick: handleRetryPlayback, className: "flex items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm font-medium text-white/80 hover:bg-white/10", children: [_jsx(RotateCw, { className: "w-4 h-4" }), "\u91CD\u8BD5\u5F53\u524D"] }), _jsxs("button", { onClick: handleNextSource, className: "flex items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm font-medium text-white/80 hover:bg-white/10", children: [_jsx(Shuffle, { className: "w-4 h-4" }), "\u5207\u6362\u7EBF\u8DEF"] })] })] })] }))] })); } function DiagnosticMetric({ label, value }) { return (_jsxs("div", { className: "rounded-lg border border-white/10 bg-white/5 px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-white/40", children: label }), _jsx("p", { className: "mt-1 truncate text-sm text-white", children: value })] })); diff --git a/out/renderer/src/renderer/src/pages/History/History.js b/out/renderer/src/renderer/src/pages/History/History.js index b5d7b50..868c95b 100644 --- a/out/renderer/src/renderer/src/pages/History/History.js +++ b/out/renderer/src/renderer/src/pages/History/History.js @@ -46,10 +46,27 @@ export default function History() { return `${Math.floor(diff / 3600000)}小时前`; return d.toLocaleDateString('zh-CN'); }; + const formatDuration = (seconds = 0) => { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const h = Math.floor(safeSeconds / 3600); + const m = Math.floor((safeSeconds % 3600) / 60); + const s = safeSeconds % 60; + if (h > 0) + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; + }; + const progressText = (item) => { + if (item.completed) + return '已看完'; + if ((item.positionSeconds || 0) > 0) { + return `${formatDuration(item.positionSeconds)}${item.duration ? ` / ${formatDuration(item.duration)}` : ''}`; + } + return `${Math.max(0, Math.min(100, item.progress || 0)).toFixed(0)}%`; + }; return (_jsxs("div", { className: "h-full flex flex-col", children: [_jsxs("div", { className: "shrink-0 flex items-center justify-between px-6 py-4 border-b border-[#2a2a2a]", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "w-5 h-5 text-accent" }), _jsx("h2", { className: "text-lg font-medium text-text-primary", children: "\u89C2\u770B\u5386\u53F2" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["(", list.length, ")"] })] }), list.length > 0 && (_jsxs("button", { onClick: handleClearAll, className: "flex items-center gap-1.5 px-3 py-1.5 text-xs text-text-muted hover:text-red-400 rounded-md hover:bg-bg-hover transition-colors", children: [_jsx(Trash2, { className: "w-3.5 h-3.5" }), " \u6E05\u7A7A\u5168\u90E8"] }))] }), _jsx("div", { className: "flex-1 overflow-y-auto scrollbar-dark p-6", children: isLoading ? (_jsx("div", { className: "flex items-center justify-center h-32 text-text-muted text-sm", children: "\u52A0\u8F7D\u4E2D..." })) : list.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32 text-text-muted text-sm", children: "\u6682\u65E0\u89C2\u770B\u8BB0\u5F55" })) : (_jsx("div", { className: "space-y-3", children: list.map((item) => (_jsxs("div", { className: "flex items-center gap-4 p-3 rounded-lg bg-bg-secondary hover:bg-bg-hover cursor-pointer transition-colors group", onClick: () => handleClick(item), children: [_jsx("div", { className: "shrink-0 w-16 h-22 rounded overflow-hidden bg-bg-tertiary", children: _jsx("img", { src: item.vodPic, alt: item.vodName, className: "w-full h-full object-cover", onError: (e) => { ; e.target.style.display = 'none'; - } }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "text-sm font-medium text-text-primary truncate group-hover:text-accent transition-colors", children: item.vodName }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: item.source || '未知播放源' }), _jsxs("div", { className: "flex items-center gap-2 mt-2", children: [_jsx("div", { className: "flex-1 h-1 bg-bg-tertiary rounded-full overflow-hidden", children: _jsx("div", { className: "h-full bg-accent rounded-full transition-all", style: { width: `${Math.max(0, Math.min(100, item.progress || 0))}%` } }) }), _jsxs("span", { className: "text-[10px] text-text-muted shrink-0", children: [Math.max(0, Math.min(100, item.progress || 0)).toFixed(0), "%"] })] })] }), _jsxs("div", { className: "shrink-0 flex flex-col items-end gap-2", children: [_jsx("span", { className: "text-[10px] text-text-muted", children: formatDate((item.updateTime || 0) * 1000) }), _jsx("button", { onClick: (e) => { + } }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "text-sm font-medium text-text-primary truncate group-hover:text-accent transition-colors", children: item.vodName }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: item.episodeName || item.source || '未知播放源' }), item.sourceName && (_jsx("p", { className: "text-[11px] text-text-muted/80 mt-1 truncate", children: item.sourceName })), _jsxs("div", { className: "flex items-center gap-2 mt-2", children: [_jsx("div", { className: "flex-1 h-1 bg-bg-tertiary rounded-full overflow-hidden", children: _jsx("div", { className: "h-full bg-accent rounded-full transition-all", style: { width: `${Math.max(0, Math.min(100, item.progress || 0))}%` } }) }), _jsx("span", { className: "text-[10px] text-text-muted shrink-0", children: progressText(item) })] })] }), _jsxs("div", { className: "shrink-0 flex flex-col items-end gap-2", children: [_jsx("span", { className: "text-[10px] text-text-muted", children: formatDate((item.updateTime || 0) * 1000) }), _jsx("button", { onClick: (e) => { e.stopPropagation(); handleDelete(item.siteKey, item.vodId); }, className: "p-1 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] })] }, `${item.siteKey}:${item.vodId}`))) })) })] })); diff --git a/out/renderer/src/renderer/src/pages/Home/Home.js b/out/renderer/src/renderer/src/pages/Home/Home.js index 13f722f..fdf8d85 100644 --- a/out/renderer/src/renderer/src/pages/Home/Home.js +++ b/out/renderer/src/renderer/src/pages/Home/Home.js @@ -1,10 +1,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useEffect, useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Search, ChevronDown, Loader2, ArrowLeft } from 'lucide-react'; +import { Search, ChevronDown, Loader2, ArrowLeft, Clock } from 'lucide-react'; import { useConfigStore } from '@/stores/useConfigStore'; +import { historyApi } from '@/utils/ipc'; import VodCard from '@/components/VodCard/VodCard'; import EmptyState from '@/components/EmptyState/EmptyState'; +function formatResumeTime(seconds = 0) { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const h = Math.floor(safeSeconds / 3600); + const m = Math.floor((safeSeconds % 3600) / 60); + const s = safeSeconds % 60; + if (h > 0) + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} export default function Home() { const navigate = useNavigate(); const { currentConfig, sites, currentSiteKey, categories, filters, homeVideos, categoryVideos, currentPage, hasMore, isLoading, error, switchSite, fetchCategoryContent } = useConfigStore(); @@ -14,6 +24,7 @@ export default function Home() { const scrollContainerRef = useRef(null); const [showSiteSheet, setShowSiteSheet] = useState(false); const siteSheetRef = useRef(null); + const [continueItems, setContinueItems] = useState([]); // 只显示 HTTP API 类型的站点(type 0/1/4) const visibleSites = sites.filter((s) => s.type === 0 || s.type === 1 || s.type === 4); // 获取当前分类的可用筛选器 @@ -25,6 +36,20 @@ export default function Home() { setShowSiteSheet(false); scrollContainerRef.current?.scrollTo({ top: 0 }); }, [currentSiteKey, sites]); + useEffect(() => { + if (!currentConfig) { + setContinueItems([]); + return; + } + historyApi.list() + .then((items) => { + setContinueItems(items + .filter((item) => !item.completed && (item.positionSeconds || item.progress || 0) > 0) + .sort((a, b) => (b.updateTime || 0) - (a.updateTime || 0)) + .slice(0, 8)); + }) + .catch(() => setContinueItems([])); + }, [currentConfig]); // 分类切换 useEffect(() => { if (activeCategory && activeCategory !== '首页') { @@ -87,5 +112,5 @@ export default function Home() { ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-secondary'}`, children: cat.type_name }, cat.type_id))), activeFilters.length > 0 && activeCategory && (_jsxs("button", { onClick: () => setShowFilterPanel(!showFilterPanel), className: `shrink-0 flex items-center gap-1 px-2 py-1 text-xs rounded-md transition-colors ${showFilterPanel ? 'text-accent' : 'text-text-muted hover:text-text-secondary'}`, children: ["\u7B5B\u9009 ", _jsx(ChevronDown, { className: `w-3 h-3 transition-transform ${showFilterPanel ? 'rotate-180' : ''}` })] }))] })), showFilterPanel && activeFilters.length > 0 && (_jsx("div", { className: "px-4 py-2 border-t border-[#2a2a2a] space-y-2", children: activeFilters.map((filter) => (_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsxs("span", { className: "text-xs text-text-muted shrink-0 w-12", children: [filter.name, ":"] }), filter.value.map((v) => (_jsx("button", { onClick: () => handleFilterChange(filter.key, v.v), className: `px-2 py-0.5 text-xs rounded transition-colors ${selectedFilters[filter.key] === v.v ? 'bg-accent/20 text-accent' - : 'text-text-muted hover:text-text-secondary'}`, children: v.n }, v.v)))] }, filter.key))) }))] }), error && !isLoading && (_jsx("div", { className: "shrink-0 px-4 py-2 bg-red-500/10 text-red-400 text-xs text-center", children: error })), _jsxs("div", { ref: scrollContainerRef, onScroll: handleScroll, className: "flex-1 overflow-y-auto scrollbar-dark p-4", children: [isLoading && displayVideos.length === 0 ? (_jsx("div", { className: "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-4", children: Array.from({ length: 12 }).map((_, i) => (_jsx(VodCard, { vod: { vod_id: '', vod_name: '', vod_pic: '', vod_remarks: '' }, onClick: () => { }, loading: true }, i))) })) : displayVideos.length > 0 ? (_jsx("div", { className: "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-4", children: displayVideos.map((vod) => (_jsx(VodCard, { vod: vod, onClick: handleVodClick }, vod.vod_id))) })) : (_jsx("div", { className: "flex items-center justify-center h-64 text-text-muted text-sm", children: isLoading ? '加载中...' : '暂无内容,请尝试切换站点或配置源' })), isLoading && displayVideos.length > 0 && (_jsx("div", { className: "flex justify-center py-4", children: _jsx(Loader2, { className: "w-5 h-5 text-accent animate-spin" }) }))] })] })); + : 'text-text-muted hover:text-text-secondary'}`, children: v.n }, v.v)))] }, filter.key))) }))] }), error && !isLoading && (_jsx("div", { className: "shrink-0 px-4 py-2 bg-red-500/10 text-red-400 text-xs text-center", children: error })), _jsxs("div", { ref: scrollContainerRef, onScroll: handleScroll, className: "flex-1 overflow-y-auto scrollbar-dark p-4", children: [!activeCategory && continueItems.length > 0 && (_jsxs("section", { className: "mb-5", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsx(Clock, { className: "h-4 w-4 text-accent" }), _jsx("h2", { className: "text-sm font-medium text-text-primary", children: "\u7EE7\u7EED\u89C2\u770B" })] }), _jsx("div", { className: "grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-4", children: continueItems.map((item) => (_jsxs("button", { onClick: () => navigate(`/vod/${item.siteKey}/${item.vodId}`), className: "flex min-w-0 items-center gap-3 rounded-lg bg-bg-secondary p-2 text-left transition-colors hover:bg-bg-hover", children: [_jsx("div", { className: "h-16 w-11 shrink-0 overflow-hidden rounded bg-bg-tertiary", children: _jsx("img", { src: item.vodPic, alt: item.vodName, className: "h-full w-full object-cover", onError: (event) => { event.target.style.display = 'none'; } }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium text-text-primary", children: item.vodName }), _jsx("p", { className: "mt-1 truncate text-xs text-text-muted", children: item.episodeName || item.source || '上次观看' }), _jsxs("div", { className: "mt-2 flex items-center gap-2", children: [_jsx("div", { className: "h-1 flex-1 overflow-hidden rounded-full bg-bg-tertiary", children: _jsx("div", { className: "h-full rounded-full bg-accent", style: { width: `${Math.max(0, Math.min(100, item.progress || 0))}%` } }) }), _jsx("span", { className: "shrink-0 text-[10px] text-text-muted", children: (item.positionSeconds || 0) > 0 ? formatResumeTime(item.positionSeconds) : `${item.progress || 0}%` })] })] })] }, `${item.siteKey}:${item.vodId}`))) })] })), isLoading && displayVideos.length === 0 ? (_jsx("div", { className: "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-4", children: Array.from({ length: 12 }).map((_, i) => (_jsx(VodCard, { vod: { vod_id: '', vod_name: '', vod_pic: '', vod_remarks: '' }, onClick: () => { }, loading: true }, i))) })) : displayVideos.length > 0 ? (_jsx("div", { className: "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-4", children: displayVideos.map((vod) => (_jsx(VodCard, { vod: vod, onClick: handleVodClick }, vod.vod_id))) })) : (_jsx("div", { className: "flex items-center justify-center h-64 text-text-muted text-sm", children: isLoading ? '加载中...' : '暂无内容,请尝试切换站点或配置源' })), isLoading && displayVideos.length > 0 && (_jsx("div", { className: "flex justify-center py-4", children: _jsx(Loader2, { className: "w-5 h-5 text-accent animate-spin" }) }))] })] })); } diff --git a/out/renderer/src/renderer/src/pages/Live/Live.js b/out/renderer/src/renderer/src/pages/Live/Live.js index 9f93593..50f828a 100644 --- a/out/renderer/src/renderer/src/pages/Live/Live.js +++ b/out/renderer/src/renderer/src/pages/Live/Live.js @@ -35,6 +35,7 @@ export default function Live() { const play = usePlayerStore((state) => state.play); const autoSwitchSource = usePlayerStore((state) => state.autoSwitchSource); const setPlaybackError = usePlayerStore((state) => state.setPlaybackError); + const setSourceSwitchState = usePlayerStore((state) => state.setSourceSwitchState); const currentConfig = useConfigStore((state) => state.currentConfig); const liveConfig = useConfigStore((state) => state.liveConfig); const livesConfig = liveConfig || currentConfig; @@ -233,13 +234,24 @@ export default function Live() { const headersQueue = channelHeadersQueueRef.current; if (nextIndex < queue.length) { channelUrlIndexRef.current = nextIndex; + setSourceSwitchState('switching', `正在切换直播线路 ${nextIndex + 1}/${queue.length}`); console.warn(`[Live] 当前线路失败,切换备用线路 ${nextIndex + 1}/${queue.length}:`, currentChannel.name); void playLiveUrl(queue[nextIndex], headersQueue[nextIndex]); + window.setTimeout(() => setSourceSwitchState('idle', ''), 2500); return; } console.error('[Live] 当前频道所有线路均不可用:', currentChannel.name); - setPlaybackError('当前频道所有线路均不可用'); - }, [currentChannel, playLiveUrl, setPlaybackError]); + setSourceSwitchState('idle', '当前频道所有线路均不可用'); + setPlaybackError('当前频道所有线路均不可用', { + stage: 'connect', + errorKind: 'unknown', + protocol: 'unknown', + sourceId: currentChannel.name, + attempt: queue.length, + sourceCount: queue.length, + nextAction: '可重试当前频道或选择其他频道' + }); + }, [currentChannel, playLiveUrl, setPlaybackError, setSourceSwitchState]); useEffect(() => { const handlePlayFailed = () => { if (autoSwitchSource) diff --git a/out/renderer/src/renderer/src/pages/Onboarding/Onboarding.js b/out/renderer/src/renderer/src/pages/Onboarding/Onboarding.js index 302ea74..bff7bfe 100644 --- a/out/renderer/src/renderer/src/pages/Onboarding/Onboarding.js +++ b/out/renderer/src/renderer/src/pages/Onboarding/Onboarding.js @@ -5,6 +5,25 @@ import { AlertCircle, Check, Link, Loader2, Radio, Search } from 'lucide-react'; import { configApi } from '@/utils/ipc'; import { useConfigStore } from '@/stores/useConfigStore'; import AppLogo from '@/components/AppLogo/AppLogo'; +function compatibilityBadgeClass(compatibility) { + if (compatibility === 'ready') + return 'text-green-400'; + if (compatibility === 'live') + return 'text-sky-300'; + if (compatibility === 'unsupported') + return 'text-yellow-300'; + return 'text-red-300'; +} +function hasUsableVodSites(inspection) { + if (inspection.probeInspectedSiteCount > 0) + return inspection.probePassedSiteCount > 0; + return inspection.visibleSiteCount > 0; +} +function probeMetric(inspection) { + if (inspection.probeInspectedSiteCount === 0) + return '未抽样'; + return `${inspection.probePassedSiteCount}/${inspection.probeInspectedSiteCount}`; +} export default function Onboarding() { const navigate = useNavigate(); const { loadConfig } = useConfigStore(); @@ -48,6 +67,10 @@ export default function Onboarding() { const currentInspection = inspection?.url === normalizedUrl ? inspection : await inspect(); if (!currentInspection) return; + if (!currentInspection.canImport) { + showMessage('error', '该配置暂不兼容:没有可用的 HTTP API 点播站点或直播源'); + return; + } const result = await configApi.load(normalizedUrl); if (!result.success) { showMessage('error', result.error || '配置导入失败'); @@ -55,7 +78,7 @@ export default function Onboarding() { } await loadConfig(normalizedUrl); showMessage('success', '配置导入成功'); - if (currentInspection.visibleSiteCount > 0) { + if (hasUsableVodSites(currentInspection)) { navigate('/', { replace: true }); } else if (currentInspection.liveCount > 0) { @@ -79,9 +102,9 @@ export default function Onboarding() { }, onKeyDown: (event) => { if (event.key === 'Enter') void importConfig(); - }, placeholder: "https://example.com/config.json", className: "min-w-0 flex-1 rounded-lg bg-bg-tertiary px-3 py-2 text-sm text-text-primary outline-none placeholder:text-text-muted focus:ring-1 focus:ring-accent", disabled: isInspecting || isImporting, autoFocus: true }), _jsxs("button", { onClick: inspect, disabled: !normalizedUrl || isInspecting || isImporting, className: "inline-flex items-center gap-1.5 rounded-lg border border-[#2a2a2a] px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-bg-hover disabled:opacity-50", children: [isInspecting ? _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : _jsx(Search, { className: "h-4 w-4" }), "\u9884\u68C0"] }), _jsxs("button", { onClick: importConfig, disabled: !normalizedUrl || isInspecting || isImporting, className: "inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-bg-primary transition-colors hover:bg-accent-hover disabled:opacity-50", children: [isImporting ? _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : _jsx(Check, { className: "h-4 w-4" }), "\u5BFC\u5165"] })] }), message && (_jsxs("div", { className: `mt-3 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${message.type === 'success' + }, placeholder: "https://example.com/config.json", className: "min-w-0 flex-1 rounded-lg bg-bg-tertiary px-3 py-2 text-sm text-text-primary outline-none placeholder:text-text-muted focus:ring-1 focus:ring-accent", disabled: isInspecting || isImporting, autoFocus: true }), _jsxs("button", { onClick: inspect, disabled: !normalizedUrl || isInspecting || isImporting, className: "inline-flex items-center gap-1.5 rounded-lg border border-[#2a2a2a] px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-bg-hover disabled:opacity-50", children: [isInspecting ? _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : _jsx(Search, { className: "h-4 w-4" }), "\u9884\u68C0"] }), _jsxs("button", { onClick: importConfig, disabled: !normalizedUrl || isInspecting || isImporting || (inspection?.url === normalizedUrl && !inspection.canImport), className: "inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-bg-primary transition-colors hover:bg-accent-hover disabled:opacity-50", children: [isImporting ? _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : _jsx(Check, { className: "h-4 w-4" }), "\u5BFC\u5165"] })] }), message && (_jsxs("div", { className: `mt-3 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${message.type === 'success' ? 'border-green-500/20 bg-green-500/10 text-green-400' - : 'border-red-500/20 bg-red-500/10 text-red-400'}`, children: [message.type === 'success' ? _jsx(Check, { className: "h-4 w-4 shrink-0" }) : _jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }), _jsx("span", { children: message.text })] })), inspection && (_jsxs("div", { className: "mt-4 rounded-lg border border-[#2a2a2a] bg-bg-primary p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm font-medium text-text-primary", children: inspection.name }), _jsx("p", { className: "mt-0.5 truncate text-xs text-text-muted", children: inspection.url })] }), _jsx("span", { className: "shrink-0 text-xs text-green-400", children: "\u53EF\u5BFC\u5165" })] }), _jsxs("div", { className: "mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4", children: [_jsx(Metric, { label: "\u70B9\u64AD\u7AD9\u70B9", value: `${inspection.visibleSiteCount}/${inspection.siteCount}` }), _jsx(Metric, { label: "\u53EF\u641C\u7D22", value: String(inspection.searchableSiteCount) }), _jsx(Metric, { label: "\u76F4\u64AD\u6E90", value: String(inspection.liveCount) }), _jsx(Metric, { label: "\u89E3\u6790\u5668", value: String(inspection.parseCount) })] }), inspection.warnings.length > 0 && (_jsx("div", { className: "mt-3 space-y-1", children: inspection.warnings.map((warning) => (_jsxs("div", { className: "flex items-start gap-2 text-xs text-yellow-300", children: [_jsx(AlertCircle, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }), _jsx("span", { children: warning })] }, warning))) }))] }))] }), _jsxs("div", { className: "mt-5 grid gap-3 sm:grid-cols-2", children: [_jsxs("div", { className: "rounded-lg border border-[#2a2a2a] bg-bg-secondary p-4", children: [_jsx(Radio, { className: "mb-3 h-5 w-5 text-accent" }), _jsx("h3", { className: "text-sm font-medium text-text-primary", children: "\u76F4\u64AD\u914D\u7F6E" }), _jsx("p", { className: "mt-1 text-xs leading-5 text-text-muted", children: "\u5305\u542B lives \u5B57\u6BB5\u7684\u914D\u7F6E\u4F1A\u81EA\u52A8\u52A0\u8F7D\u9891\u9053\uFF0C\u5E76\u5728\u76F4\u64AD\u9875\u5C55\u793A\u53EF\u7528\u9891\u9053\u3002" })] }), _jsxs("div", { className: "rounded-lg border border-[#2a2a2a] bg-bg-secondary p-4", children: [_jsx(Search, { className: "mb-3 h-5 w-5 text-accent" }), _jsx("h3", { className: "text-sm font-medium text-text-primary", children: "\u70B9\u64AD\u7AD9\u70B9" }), _jsx("p", { className: "mt-1 text-xs leading-5 text-text-muted", children: "type=0/1/4 \u7684\u7AD9\u70B9\u4F1A\u8FDB\u5165\u9996\u9875\u548C\u641C\u7D22\uFF0C\u6682\u4E0D\u652F\u6301 type=3 Jar \u63D2\u4EF6\u7AD9\u70B9\u3002" })] })] }), _jsxs("div", { className: "mt-6 flex items-center justify-between border-t border-[#2a2a2a] pt-4", children: [_jsx("button", { onClick: () => navigate('/settings'), className: "text-sm text-text-muted transition-colors hover:text-text-primary", children: "\u7A0D\u540E\u5728\u8BBE\u7F6E\u4E2D\u5BFC\u5165" }), _jsx("button", { onClick: () => navigate('/'), className: "text-sm text-text-muted transition-colors hover:text-text-primary", children: "\u5148\u8FDB\u5165\u5E94\u7528" })] })] }) })); + : 'border-red-500/20 bg-red-500/10 text-red-400'}`, children: [message.type === 'success' ? _jsx(Check, { className: "h-4 w-4 shrink-0" }) : _jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }), _jsx("span", { children: message.text })] })), inspection && (_jsxs("div", { className: "mt-4 rounded-lg border border-[#2a2a2a] bg-bg-primary p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm font-medium text-text-primary", children: inspection.name }), _jsx("p", { className: "mt-0.5 truncate text-xs text-text-muted", children: inspection.url })] }), _jsx("span", { className: `shrink-0 text-xs ${compatibilityBadgeClass(inspection.compatibility)}`, children: inspection.compatibilityLabel })] }), _jsxs("div", { className: "mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4", children: [_jsx(Metric, { label: "\u70B9\u64AD\u7AD9\u70B9", value: `${inspection.visibleSiteCount}/${inspection.siteCount}` }), _jsx(Metric, { label: "\u62BD\u6837\u901A\u8FC7", value: probeMetric(inspection) }), _jsx(Metric, { label: "\u76F4\u64AD\u6E90", value: inspection.liveChannelCount > 0 ? `${inspection.liveCount}/${inspection.liveChannelCount}` : String(inspection.liveCount) }), _jsx(Metric, { label: "\u89E3\u6790\u5668", value: String(inspection.parseCount) })] }), inspection.warnings.length > 0 && (_jsx("div", { className: "mt-3 space-y-1", children: inspection.warnings.map((warning) => (_jsxs("div", { className: "flex items-start gap-2 text-xs text-yellow-300", children: [_jsx(AlertCircle, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }), _jsx("span", { children: warning })] }, warning))) }))] }))] }), _jsxs("div", { className: "mt-5 grid gap-3 sm:grid-cols-2", children: [_jsxs("div", { className: "rounded-lg border border-[#2a2a2a] bg-bg-secondary p-4", children: [_jsx(Radio, { className: "mb-3 h-5 w-5 text-accent" }), _jsx("h3", { className: "text-sm font-medium text-text-primary", children: "\u76F4\u64AD\u914D\u7F6E" }), _jsx("p", { className: "mt-1 text-xs leading-5 text-text-muted", children: "\u5305\u542B lives \u5B57\u6BB5\u7684\u914D\u7F6E\u4F1A\u81EA\u52A8\u52A0\u8F7D\u9891\u9053\uFF0C\u5E76\u5728\u76F4\u64AD\u9875\u5C55\u793A\u53EF\u7528\u9891\u9053\u3002" })] }), _jsxs("div", { className: "rounded-lg border border-[#2a2a2a] bg-bg-secondary p-4", children: [_jsx(Search, { className: "mb-3 h-5 w-5 text-accent" }), _jsx("h3", { className: "text-sm font-medium text-text-primary", children: "\u70B9\u64AD\u7AD9\u70B9" }), _jsx("p", { className: "mt-1 text-xs leading-5 text-text-muted", children: "type=0/1/4 \u7684\u7AD9\u70B9\u4F1A\u8FDB\u5165\u9996\u9875\u548C\u641C\u7D22\uFF0C\u6682\u4E0D\u652F\u6301 type=3 Jar \u63D2\u4EF6\u7AD9\u70B9\u3002" })] })] }), _jsxs("div", { className: "mt-6 flex items-center justify-between border-t border-[#2a2a2a] pt-4", children: [_jsx("button", { onClick: () => navigate('/settings'), className: "text-sm text-text-muted transition-colors hover:text-text-primary", children: "\u7A0D\u540E\u5728\u8BBE\u7F6E\u4E2D\u5BFC\u5165" }), _jsx("button", { onClick: () => navigate('/'), className: "text-sm text-text-muted transition-colors hover:text-text-primary", children: "\u5148\u8FDB\u5165\u5E94\u7528" })] })] }) })); } function Metric({ label, value }) { return (_jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: label }), _jsx("p", { className: "mt-1 text-sm text-text-primary", children: value })] })); diff --git a/out/renderer/src/renderer/src/pages/Settings/Settings.js b/out/renderer/src/renderer/src/pages/Settings/Settings.js index 65bbfcf..0dd79a5 100644 --- a/out/renderer/src/renderer/src/pages/Settings/Settings.js +++ b/out/renderer/src/renderer/src/pages/Settings/Settings.js @@ -1,9 +1,28 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useState, useEffect, useCallback } from 'react'; -import { Settings as SettingsIcon, Plus, Trash2, RefreshCw, Globe, Monitor, Info, ChevronRight, Link, Check, AlertCircle, Edit3, Save, X, Tv } from 'lucide-react'; +import { Settings as SettingsIcon, Plus, Trash2, RefreshCw, Globe, Monitor, Info, ChevronRight, Link, Check, AlertCircle, Edit3, Loader2, Save, X, Tv } from 'lucide-react'; import { configApi, invoke, localApi, on } from '@/utils/ipc'; import { useConfigStore } from '@/stores/useConfigStore'; import { useNavigate } from 'react-router-dom'; +function compatibilityBadgeClass(compatibility) { + if (compatibility === 'ready') + return 'text-green-400'; + if (compatibility === 'live') + return 'text-sky-300'; + if (compatibility === 'unsupported') + return 'text-yellow-300'; + return 'text-red-300'; +} +function usableVodSiteCount(inspection) { + if (inspection.probeInspectedSiteCount > 0) + return inspection.probePassedSiteCount; + return inspection.visibleSiteCount; +} +function probeMetric(inspection) { + if (inspection.probeInspectedSiteCount === 0) + return '未抽样'; + return `${inspection.probePassedSiteCount}/${inspection.probeInspectedSiteCount}`; +} export default function Settings() { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('config'); @@ -15,6 +34,8 @@ export default function Settings() { const [inspection, setInspection] = useState(null); const [editingConfigUrl, setEditingConfigUrl] = useState(''); const [editingConfigName, setEditingConfigName] = useState(''); + const [deleteConfirmUrl, setDeleteConfirmUrl] = useState(''); + const [deletingConfigUrl, setDeletingConfigUrl] = useState(''); const [message, setMessage] = useState(null); const [version, setVersion] = useState('1.0.0'); const [localServer, setLocalServer] = useState({ url: '', token: '' }); @@ -143,6 +164,10 @@ export default function Settings() { const currentInspection = inspection?.url === url ? inspection : await handleInspectConfig(); if (!currentInspection) return; + if (!currentInspection.canImport) { + showMessage('error', '该配置暂不兼容:没有可用的 HTTP API 点播站点或直播源'); + return; + } const result = await configApi.load(url); if (result?.success) { setNewConfigUrl(''); @@ -151,7 +176,7 @@ export default function Settings() { // 刷新首页内容 await loadConfig(url); await loadData(); - showMessage('success', `配置添加成功:${currentInspection.visibleSiteCount} 个点播站点,${currentInspection.liveCount} 个直播源`); + showMessage('success', `配置添加成功:${usableVodSiteCount(currentInspection)} 个可用点播站点,${currentInspection.liveCount} 个直播源`); } else { showMessage('error', '加载配置失败: ' + (result?.error || '未知错误')); @@ -164,48 +189,65 @@ export default function Settings() { setLoading(false); } }; + const resetActiveConfig = () => { + useConfigStore.getState().reset(); + useConfigStore.setState({ + sites: [], + currentSiteKey: '', + categories: [], + filters: {}, + homeVideos: [], + categoryVideos: [], + currentPage: 1, + hasMore: false, + isLoading: false, + error: null + }); + }; + const handleRequestDeleteConfig = (config) => { + if (deletingConfigUrl) + return; + setDeleteConfirmUrl(config.url); + if (editingConfigUrl === config.url) { + setEditingConfigUrl(''); + setEditingConfigName(''); + } + }; // 删除配置 const handleDeleteConfig = async (config) => { - const confirmed = window.confirm(`确定删除配置「${config.name}」吗?`); - if (!confirmed) - return; const url = config.url; + const wasCurrent = url === currentUrl; + setDeletingConfigUrl(url); try { const result = await configApi.remove(url); - if (!result.success) { - showMessage('error', result.error || '删除失败'); + if (!result?.success) { + showMessage('error', result?.error || '删除失败'); return; } + const [freshList, freshUrl] = await Promise.all([ + configApi.list(), + configApi.getCurrentUrl() + ]); + const nextUrl = freshUrl || ''; + setConfigs(freshList || []); + setCurrentUrl(nextUrl); + setDeleteConfirmUrl(''); + if (wasCurrent) { + if (nextUrl) { + await loadConfig(nextUrl); + } + else { + resetActiveConfig(); + } + } + showMessage('success', '配置已删除'); } catch (err) { - showMessage('error', '后端删除失败: ' + String(err)); - return; - } - if (url === currentUrl) { - setCurrentUrl(''); - useConfigStore.getState().reset(); - useConfigStore.setState({ - sites: [], - currentSiteKey: '', - categories: [], - filters: {}, - homeVideos: [], - categoryVideos: [], - currentPage: 1, - hasMore: false, - isLoading: false, - error: null, - }); + showMessage('error', '删除失败: ' + String(err)); } - if (editingConfigUrl === url) { - setEditingConfigUrl(''); - setEditingConfigName(''); + finally { + setDeletingConfigUrl(''); } - const freshList = await configApi.list(); - const freshUrl = await configApi.getCurrentUrl(); - setConfigs(freshList || []); - setCurrentUrl(freshUrl || ''); - showMessage('success', '配置已删除'); }; const handleStartRename = (config) => { setEditingConfigUrl(config.url); @@ -283,8 +325,10 @@ export default function Settings() { : 'bg-red-500/10 text-red-400 border border-red-500/20'}`, children: [message.type === 'success' ? _jsx(Check, { className: "w-4 h-4 shrink-0" }) : _jsx(AlertCircle, { className: "w-4 h-4 shrink-0" }), message.text] })), activeTab === 'config' && (_jsxs("div", { className: "space-y-6 max-w-2xl", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-medium text-text-primary mb-3", children: "\u6DFB\u52A0\u914D\u7F6E" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "text", value: newConfigUrl, onChange: (e) => { setNewConfigUrl(e.target.value); setInspection(null); - }, placeholder: "\u8F93\u5165\u914D\u7F6E\u5730\u5740\uFF08JSON URL\uFF09...", className: "flex-1 px-3 py-2 bg-bg-tertiary rounded-lg text-sm text-text-primary placeholder:text-text-muted outline-none focus:ring-1 focus:ring-accent", onKeyDown: (e) => e.key === 'Enter' && handleAddConfig(), disabled: loading || inspectLoading }), _jsxs("button", { onClick: handleInspectConfig, disabled: !newConfigUrl.trim() || loading || inspectLoading, className: "flex items-center gap-1.5 px-4 py-2 border border-[#2a2a2a] hover:bg-bg-hover disabled:opacity-50 text-text-secondary text-sm font-medium rounded-lg transition-colors", children: [_jsx(RefreshCw, { className: `w-4 h-4 ${inspectLoading ? 'animate-spin' : ''}` }), inspectLoading ? '预检中...' : '预检'] }), _jsxs("button", { onClick: handleAddConfig, disabled: !newConfigUrl.trim() || loading || inspectLoading, className: "flex items-center gap-1.5 px-4 py-2 bg-accent hover:bg-accent-hover disabled:opacity-50 text-bg-primary text-sm font-medium rounded-lg transition-colors", children: [_jsx(Plus, { className: "w-4 h-4" }), " ", loading ? '加载中...' : '添加'] })] }), _jsx("p", { className: "text-xs text-text-muted mt-2", children: "\u8F93\u5165 FongMi/TV \u517C\u5BB9\u7684\u914D\u7F6E JSON \u5730\u5740\uFF0C\u52A0\u8F7D\u540E\u5373\u53EF\u6D4F\u89C8\u5185\u5BB9" }), inspection && (_jsxs("div", { className: "mt-3 rounded-lg border border-[#2a2a2a] bg-bg-secondary p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm font-medium text-text-primary truncate", children: inspection.name }), _jsx("p", { className: "text-xs text-text-muted truncate mt-0.5", children: inspection.url })] }), _jsx("span", { className: "shrink-0 text-xs text-green-400", children: "\u53EF\u5BFC\u5165" })] }), _jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-4 gap-2 mt-3", children: [_jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u70B9\u64AD\u7AD9\u70B9" }), _jsxs("p", { className: "text-sm text-text-primary mt-1", children: [inspection.visibleSiteCount, "/", inspection.siteCount] })] }), _jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u53EF\u641C\u7D22" }), _jsx("p", { className: "text-sm text-text-primary mt-1", children: inspection.searchableSiteCount })] }), _jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u76F4\u64AD\u6E90" }), _jsx("p", { className: "text-sm text-text-primary mt-1", children: inspection.liveCount })] }), _jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u89E3\u6790\u5668" }), _jsx("p", { className: "text-sm text-text-primary mt-1", children: inspection.parseCount })] })] }), inspection.warnings.length > 0 && (_jsx("div", { className: "mt-3 space-y-1", children: inspection.warnings.map((warning) => (_jsxs("div", { className: "flex items-start gap-2 text-xs text-yellow-300", children: [_jsx(AlertCircle, { className: "w-3.5 h-3.5 shrink-0 mt-0.5" }), _jsx("span", { children: warning })] }, warning))) }))] }))] }), _jsxs("div", { children: [_jsx("h3", { className: "text-sm font-medium text-text-primary mb-3", children: "\u914D\u7F6E\u5217\u8868" }), configs.length === 0 ? (_jsxs("div", { className: "py-8 text-center", children: [_jsx(Link, { className: "w-10 h-10 text-text-muted mx-auto mb-3" }), _jsx("p", { className: "text-sm text-text-muted", children: "\u6682\u65E0\u914D\u7F6E" }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "\u5728\u4E0A\u65B9\u8F93\u5165\u914D\u7F6E\u5730\u5740\u6DFB\u52A0" })] })) : (_jsx("div", { className: "space-y-2", children: configs.map((config) => { + }, placeholder: "\u8F93\u5165\u914D\u7F6E\u5730\u5740\uFF08JSON URL\uFF09...", className: "flex-1 px-3 py-2 bg-bg-tertiary rounded-lg text-sm text-text-primary placeholder:text-text-muted outline-none focus:ring-1 focus:ring-accent", onKeyDown: (e) => e.key === 'Enter' && handleAddConfig(), disabled: loading || inspectLoading }), _jsxs("button", { onClick: handleInspectConfig, disabled: !newConfigUrl.trim() || loading || inspectLoading, className: "flex items-center gap-1.5 px-4 py-2 border border-[#2a2a2a] hover:bg-bg-hover disabled:opacity-50 text-text-secondary text-sm font-medium rounded-lg transition-colors", children: [_jsx(RefreshCw, { className: `w-4 h-4 ${inspectLoading ? 'animate-spin' : ''}` }), inspectLoading ? '预检中...' : '预检'] }), _jsxs("button", { onClick: handleAddConfig, disabled: !newConfigUrl.trim() || loading || inspectLoading || (inspection?.url === newConfigUrl.trim() && !inspection.canImport), className: "flex items-center gap-1.5 px-4 py-2 bg-accent hover:bg-accent-hover disabled:opacity-50 text-bg-primary text-sm font-medium rounded-lg transition-colors", children: [_jsx(Plus, { className: "w-4 h-4" }), " ", loading ? '加载中...' : '添加'] })] }), _jsx("p", { className: "text-xs text-text-muted mt-2", children: "\u8F93\u5165 FongMi/TV \u517C\u5BB9\u7684\u914D\u7F6E JSON \u5730\u5740\uFF0C\u52A0\u8F7D\u540E\u5373\u53EF\u6D4F\u89C8\u5185\u5BB9" }), inspection && (_jsxs("div", { className: "mt-3 rounded-lg border border-[#2a2a2a] bg-bg-secondary p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm font-medium text-text-primary truncate", children: inspection.name }), _jsx("p", { className: "text-xs text-text-muted truncate mt-0.5", children: inspection.url })] }), _jsx("span", { className: `shrink-0 text-xs ${compatibilityBadgeClass(inspection.compatibility)}`, children: inspection.compatibilityLabel })] }), _jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-4 gap-2 mt-3", children: [_jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u70B9\u64AD\u7AD9\u70B9" }), _jsxs("p", { className: "text-sm text-text-primary mt-1", children: [inspection.visibleSiteCount, "/", inspection.siteCount] })] }), _jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u62BD\u6837\u901A\u8FC7" }), _jsx("p", { className: "text-sm text-text-primary mt-1", children: probeMetric(inspection) })] }), _jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u76F4\u64AD\u6E90" }), _jsx("p", { className: "text-sm text-text-primary mt-1", children: inspection.liveChannelCount > 0 ? `${inspection.liveCount}/${inspection.liveChannelCount}` : inspection.liveCount })] }), _jsxs("div", { className: "rounded-md bg-bg-tertiary px-3 py-2", children: [_jsx("p", { className: "text-[11px] text-text-muted", children: "\u89E3\u6790\u5668" }), _jsx("p", { className: "text-sm text-text-primary mt-1", children: inspection.parseCount })] })] }), inspection.warnings.length > 0 && (_jsx("div", { className: "mt-3 space-y-1", children: inspection.warnings.map((warning) => (_jsxs("div", { className: "flex items-start gap-2 text-xs text-yellow-300", children: [_jsx(AlertCircle, { className: "w-3.5 h-3.5 shrink-0 mt-0.5" }), _jsx("span", { children: warning })] }, warning))) }))] }))] }), _jsxs("div", { children: [_jsx("h3", { className: "text-sm font-medium text-text-primary mb-3", children: "\u914D\u7F6E\u5217\u8868" }), configs.length === 0 ? (_jsxs("div", { className: "py-8 text-center", children: [_jsx(Link, { className: "w-10 h-10 text-text-muted mx-auto mb-3" }), _jsx("p", { className: "text-sm text-text-muted", children: "\u6682\u65E0\u914D\u7F6E" }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "\u5728\u4E0A\u65B9\u8F93\u5165\u914D\u7F6E\u5730\u5740\u6DFB\u52A0" })] })) : (_jsx("div", { className: "space-y-2", children: configs.map((config) => { const isActive = config.url === currentUrl; + const isConfirmingDelete = deleteConfirmUrl === config.url; + const isDeleting = deletingConfigUrl === config.url; return (_jsxs("div", { className: `flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${isActive ? 'border-accent/30 bg-accent-muted' : 'border-[#2a2a2a] bg-bg-secondary hover:bg-bg-hover'}`, children: [_jsxs("div", { className: "flex-1 min-w-0", children: [editingConfigUrl === config.url ? (_jsx("input", { value: editingConfigName, onChange: (e) => setEditingConfigName(e.target.value), onKeyDown: (e) => { @@ -292,7 +336,7 @@ export default function Settings() { handleSaveRename(config.url); if (e.key === 'Escape') handleCancelRename(); - }, className: "w-full px-2 py-1 bg-bg-tertiary rounded-md text-sm text-text-primary outline-none focus:ring-1 focus:ring-accent", autoFocus: true })) : (_jsx("p", { className: `text-sm truncate ${isActive ? 'text-accent' : 'text-text-primary'}`, children: config.name })), _jsx("p", { className: "text-xs text-text-muted truncate mt-0.5", children: config.url })] }), editingConfigUrl === config.url ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => handleSaveRename(config.url), className: "shrink-0 p-1.5 text-text-muted hover:text-green-400 transition-colors", title: "\u4FDD\u5B58\u540D\u79F0", children: _jsx(Save, { className: "w-4 h-4" }) }), _jsx("button", { onClick: handleCancelRename, className: "shrink-0 p-1.5 text-text-muted hover:text-red-400 transition-colors", title: "\u53D6\u6D88\u7F16\u8F91", children: _jsx(X, { className: "w-4 h-4" }) })] })) : (_jsx("button", { onClick: () => handleStartRename(config), className: "shrink-0 p-1.5 text-text-muted hover:text-accent transition-colors", title: "\u91CD\u547D\u540D", children: _jsx(Edit3, { className: "w-4 h-4" }) })), _jsx("button", { onClick: () => handleInspectSavedConfig(config), disabled: inspectLoading || loading, className: "shrink-0 p-1.5 text-text-muted hover:text-accent disabled:opacity-50 transition-colors", title: "\u91CD\u65B0\u9884\u68C0", children: _jsx(AlertCircle, { className: "w-4 h-4" }) }), !isActive && (_jsx("button", { onClick: () => handleSwitchConfig(config.url), className: "shrink-0 p-1.5 text-text-muted hover:text-accent transition-colors", title: "\u5207\u6362\u5230\u6B64\u914D\u7F6E", children: _jsx(RefreshCw, { className: "w-4 h-4" }) })), isActive && (_jsx("span", { className: "shrink-0 text-xs text-accent font-medium", children: "\u5F53\u524D" })), _jsx("button", { onClick: () => handleDeleteConfig(config), className: "shrink-0 p-1.5 text-text-muted hover:text-red-400 transition-colors", title: "\u5220\u9664", children: _jsx(Trash2, { className: "w-4 h-4" }) })] }, config.url)); + }, className: "w-full px-2 py-1 bg-bg-tertiary rounded-md text-sm text-text-primary outline-none focus:ring-1 focus:ring-accent", autoFocus: true })) : (_jsx("p", { className: `text-sm truncate ${isActive ? 'text-accent' : 'text-text-primary'}`, children: config.name })), _jsx("p", { className: "text-xs text-text-muted truncate mt-0.5", children: config.url })] }), isConfirmingDelete ? (_jsxs("div", { className: "shrink-0 flex items-center gap-1.5", children: [_jsx("span", { className: "text-xs text-red-300", children: "\u786E\u8BA4\u5220\u9664\uFF1F" }), _jsx("button", { onClick: () => handleDeleteConfig(config), disabled: isDeleting, className: "shrink-0 p-1.5 text-red-300 hover:text-red-200 disabled:opacity-50 transition-colors", title: "\u786E\u8BA4\u5220\u9664", children: isDeleting ? _jsx(Loader2, { className: "w-4 h-4 animate-spin" }) : _jsx(Check, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => setDeleteConfirmUrl(''), disabled: isDeleting, className: "shrink-0 p-1.5 text-text-muted hover:text-text-primary disabled:opacity-50 transition-colors", title: "\u53D6\u6D88\u5220\u9664", children: _jsx(X, { className: "w-4 h-4" }) })] })) : (_jsxs(_Fragment, { children: [editingConfigUrl === config.url ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => handleSaveRename(config.url), className: "shrink-0 p-1.5 text-text-muted hover:text-green-400 transition-colors", title: "\u4FDD\u5B58\u540D\u79F0", children: _jsx(Save, { className: "w-4 h-4" }) }), _jsx("button", { onClick: handleCancelRename, className: "shrink-0 p-1.5 text-text-muted hover:text-red-400 transition-colors", title: "\u53D6\u6D88\u7F16\u8F91", children: _jsx(X, { className: "w-4 h-4" }) })] })) : (_jsx("button", { onClick: () => handleStartRename(config), className: "shrink-0 p-1.5 text-text-muted hover:text-accent transition-colors", title: "\u91CD\u547D\u540D", children: _jsx(Edit3, { className: "w-4 h-4" }) })), _jsx("button", { onClick: () => handleInspectSavedConfig(config), disabled: inspectLoading || loading, className: "shrink-0 p-1.5 text-text-muted hover:text-accent disabled:opacity-50 transition-colors", title: "\u91CD\u65B0\u9884\u68C0", children: _jsx(AlertCircle, { className: "w-4 h-4" }) }), !isActive && (_jsx("button", { onClick: () => handleSwitchConfig(config.url), className: "shrink-0 p-1.5 text-text-muted hover:text-accent transition-colors", title: "\u5207\u6362\u5230\u6B64\u914D\u7F6E", children: _jsx(RefreshCw, { className: "w-4 h-4" }) })), isActive && (_jsx("span", { className: "shrink-0 text-xs text-accent font-medium", children: "\u5F53\u524D" })), _jsx("button", { onClick: () => handleRequestDeleteConfig(config), disabled: Boolean(deletingConfigUrl), className: "shrink-0 p-1.5 text-text-muted hover:text-red-400 disabled:opacity-50 transition-colors", title: "\u5220\u9664", children: _jsx(Trash2, { className: "w-4 h-4" }) })] }))] }, config.url)); }) }))] })] })), activeTab === 'live' && (_jsxs("div", { className: "space-y-6 max-w-2xl", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-medium text-text-primary mb-3", children: "\u9891\u9053\u5237\u65B0" }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("button", { onClick: handleLiveRefresh, disabled: liveRefreshing, className: `flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${liveRefreshing ? 'bg-accent-muted text-text-muted cursor-not-allowed' : 'bg-accent text-white hover:bg-accent-hover'}`, children: [_jsx(RefreshCw, { className: `w-4 h-4 ${liveRefreshing ? 'animate-spin' : ''}` }), liveRefreshing ? '刷新中...' : '立即刷新'] }), _jsx("span", { className: "text-xs text-text-muted", children: "\u5237\u65B0\u5C06\u6D4B\u8BD5\u6240\u6709\u76F4\u64AD\u6E90\u7684 URL \u8FDE\u901A\u6027\uFF0C\u53BB\u91CD\u9009\u4F18\u540E\u6309 \u56FD\u5BB6-\u7C7B\u522B-\u9891\u9053 \u5206\u7C7B" })] }), liveRefreshing && liveRefreshProgress && (_jsxs("div", { className: "mt-3 space-y-1", children: [_jsxs("div", { className: "flex items-center justify-between text-xs text-text-muted", children: [_jsx("span", { children: liveRefreshProgress.message }), _jsxs("span", { children: [liveRefreshProgress.total > 0 diff --git a/out/renderer/src/renderer/src/pages/VodDetail/VodDetail.js b/out/renderer/src/renderer/src/pages/VodDetail/VodDetail.js index c16ecd6..9886cc4 100644 --- a/out/renderer/src/renderer/src/pages/VodDetail/VodDetail.js +++ b/out/renderer/src/renderer/src/pages/VodDetail/VodDetail.js @@ -15,6 +15,18 @@ function isVideoFormat(url) { /\/m3u8\?|\/playlist\.m3u8|\/index\.m3u8/i.test(url) || /^https?:\/\/[^/]+\/.+\.(m3u8|mp4|flv|ts|mpd)(\?|$)/i.test(url); } +function formatResumeTime(seconds = 0) { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const h = Math.floor(safeSeconds / 3600); + const m = Math.floor((safeSeconds % 3600) / 60); + const s = safeSeconds % 60; + if (h > 0) + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} +function canResume(history) { + return Boolean(history && !history.completed && (history.positionSeconds || 0) > 5); +} export default function VodDetail() { const { siteKey, vodId } = useParams(); const { currentConfig } = useConfigStore(); @@ -28,6 +40,7 @@ export default function VodDetail() { const [isResolving, setIsResolving] = useState(false); const [allSourcesExhausted, setAllSourcesExhausted] = useState(false); const [loadError, setLoadError] = useState(null); + const [resumeHistory, setResumeHistory] = useState(null); // 当前正在播放的源信息 const [currentPlaySource, setCurrentPlaySource] = useState(null); const switchingRef = useRef(false); @@ -45,6 +58,7 @@ export default function VodDetail() { setIsLoading(true); setLoadError(null); setAllSourcesExhausted(false); + setResumeHistory(null); failureHandledRef.current = false; resetSourceSwitch(); loadVodDetail(siteKey, vodId); @@ -62,6 +76,15 @@ export default function VodDetail() { return; } setDetail(vod); + let savedHistory = null; + try { + const historyList = (await historyApi.list()); + savedHistory = historyList.find((item) => item.siteKey === sKey && item.vodId === vId) || null; + setResumeHistory(savedHistory); + } + catch { + setResumeHistory(null); + } // 解析播放源和集数 const playFroms = (vod.vod_play_from || '').split('$$$').filter(Boolean); const urlGroups = (vod.vod_play_url || '').split('$$$'); @@ -111,7 +134,10 @@ export default function VodDetail() { setLineSources(parsedLines); // 优先选择含直链标识的线路(m3u8/mp4/mpd 等) const bestLineIdx = parsedLines.findIndex((l) => /m3u8|mp4|mpd|flv|ts/i.test(l.name) && l.episodes.length > 0); - setActiveLineIndex(bestLineIdx >= 0 ? bestLineIdx : 0); + const resumeLineIdx = canResume(savedHistory) + ? Math.min(Math.max(savedHistory.sourceIndex || 0, 0), Math.max(0, parsedLines.length - 1)) + : -1; + setActiveLineIndex(resumeLineIdx >= 0 ? resumeLineIdx : bestLineIdx >= 0 ? bestLineIdx : 0); setIsLoading(false); } catch (err) { @@ -173,13 +199,13 @@ export default function VodDetail() { switchingRef.current = true; try { setSourceSwitchState('switching', `正在加载「${source.siteName}」...`); - markCurrentSourceBroken(source.siteKey, source.vodId); // 在当前页面内加载新源的详情 try { const res = await siteApi.detailContent(source.siteKey, [source.vodId]); const result = res.data || res; const vod = result.list?.[0]; if (!vod) { + markCurrentSourceBroken(source.siteKey, source.vodId); setSourceSwitchState('idle', '加载失败,尝试下一个...'); setTimeout(() => { if (mountedRef.current) setSourceSwitchState('idle', ''); }, 2000); @@ -240,6 +266,7 @@ export default function VodDetail() { } } if (newLines.length === 0) { + markCurrentSourceBroken(source.siteKey, source.vodId); setSourceSwitchState('idle', '该源无播放数据'); setTimeout(() => { if (mountedRef.current) setSourceSwitchState('idle', ''); }, 2000); @@ -256,6 +283,7 @@ export default function VodDetail() { setSourceSwitchState('idle', ''); }, 2000); } catch (err) { + markCurrentSourceBroken(source.siteKey, source.vodId); setSourceSwitchState('idle', '加载失败'); setTimeout(() => { if (mountedRef.current) setSourceSwitchState('idle', ''); }, 2000); @@ -315,10 +343,11 @@ export default function VodDetail() { }; }, [activeLine, activeLineIndex, currentEpisodeIndex, switchToNextSource]); // 播放集数 - const handlePlay = async (lineIdx, epIdx) => { + const handlePlay = async (lineIdx, epIdx, startPositionSeconds = 0) => { const line = lineSources[lineIdx]; if (!line || !line.episodes[epIdx]) return; + window.dispatchEvent(new Event('player:flushHistory')); setIsResolving(true); setPlaybackPhase('resolving', '正在获取播放信息...'); setCurrentSourceIndex(lineIdx); @@ -354,8 +383,8 @@ export default function VodDetail() { setPlaybackPhase('connecting', '正在连接播放地址...'); const playableUrl = await getPlayableMediaUrl(initialUrl, initialHeader); setCurrentPlaySource({ siteKey: targetSiteKey, vodId: targetVodId }); - setVod(targetDetail, line.episodes, lineIdx, playableUrl, initialHeader, targetSiteKey); - setCurrentEpisodeIndex(epIdx, playableUrl); + setVod(targetDetail, line.episodes, lineIdx, playableUrl, initialHeader, targetSiteKey, line.name, startPositionSeconds); + setCurrentEpisodeIndex(epIdx, playableUrl, startPositionSeconds); setShowPlayer(true); } else { @@ -363,7 +392,7 @@ export default function VodDetail() { setCurrentPlaySource({ siteKey: targetSiteKey, vodId: targetVodId }); setShowPlayer(true); setPlaybackPhase('resolving', '正在解析播放地址...'); - setVod(targetDetail, line.episodes, lineIdx, '__resolving__', initialHeader, targetSiteKey); + setVod(targetDetail, line.episodes, lineIdx, '__resolving__', initialHeader, targetSiteKey, line.name, startPositionSeconds); // 异步解析 try { const parseRes = await siteApi.superParse({ @@ -377,21 +406,35 @@ export default function VodDetail() { const url = await getPlayableMediaUrl(parseRes.data.url, parseRes.data.header || initialHeader); const header = parseRes.data.header || initialHeader; const store = usePlayerStore.getState(); - setCurrentEpisodeIndex(epIdx, url); + setCurrentEpisodeIndex(epIdx, url, startPositionSeconds); store.play(url); + if (startPositionSeconds > 0) + store.setCurrentTime(startPositionSeconds); } else { // 所有解析都失败 - setPlaybackError('解析完全失败'); + setPlaybackError(parseRes.error || '解析失败:未找到可播放地址', { + stage: 'parse', + errorKind: 'parse_failed', + protocol: 'unknown', + sourceId: `${targetSiteKey}::${targetVodId}`, + nextAction: autoSwitchSource ? '自动尝试下一个备选源' : '可手动切换线路或重试当前集' + }); window.dispatchEvent(new CustomEvent('vod:playFailed', { - detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析完全失败', autoSwitch: true } + detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析失败:未找到可播放地址', autoSwitch: true } })); } } catch { - setPlaybackError('解析异常'); + setPlaybackError('解析异常:无法完成播放地址解析', { + stage: 'parse', + errorKind: 'parse_failed', + protocol: 'unknown', + sourceId: `${targetSiteKey}::${targetVodId}`, + nextAction: autoSwitchSource ? '自动尝试下一个备选源' : '可手动切换线路或重试当前集' + }); window.dispatchEvent(new CustomEvent('vod:playFailed', { - detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析异常', autoSwitch: true } + detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析异常:无法完成播放地址解析', autoSwitch: true } })); } } @@ -404,14 +447,32 @@ export default function VodDetail() { siteKey: targetSiteKey, vodName: targetDetail?.vod_name || '', vodPic: targetDetail?.vod_pic, + vodRemarks: targetDetail?.vod_remarks, source: episode.name ? `${line.name} · ${episode.name}` : line.name, - progress: 0 + progress: startPositionSeconds > 0 && resumeHistory?.duration + ? Math.max(0, Math.min(100, Math.round((startPositionSeconds / resumeHistory.duration) * 100))) + : 0, + episodeId: String(epIdx), + episodeName: episode.name || `第 ${epIdx + 1} 集`, + episodeIndex: epIdx, + sourceIndex: lineIdx, + sourceName: line.name, + urlIdentifier: episode.url.split('?')[0].slice(0, 240), + duration: resumeHistory?.duration || 0, + positionSeconds: startPositionSeconds, + completed: false }).catch(() => { }); } catch (err) { setIsResolving(false); setLoadError(err?.message || '获取播放信息失败'); - setPlaybackError(err?.message || '获取播放信息失败'); + setPlaybackError(err?.message || '获取播放信息失败', { + stage: 'connect', + errorKind: 'unknown', + protocol: 'unknown', + sourceId: `${targetSiteKey}::${targetVodId}`, + nextAction: autoSwitchSource ? '自动尝试下一个备选源' : '可手动切换线路或重试当前集' + }); window.dispatchEvent(new CustomEvent('vod:playFailed', { detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: 'playerContent 失败', autoSwitch: true } })); @@ -441,13 +502,24 @@ export default function VodDetail() { if (loadError && !detail) { return (_jsxs("div", { className: "h-full flex flex-col items-center justify-center p-8 text-center", children: [_jsx(AlertCircle, { className: "w-12 h-12 text-red-400 mb-4" }), _jsx("h2", { className: "text-lg font-semibold text-text-primary mb-2", children: "\u65E0\u6CD5\u52A0\u8F7D\u8BE6\u60C5" }), _jsx("p", { className: "text-sm text-text-muted mb-6", children: loadError }), _jsxs("div", { className: "flex gap-3", children: [_jsx("button", { onClick: () => window.history.back(), className: "px-4 py-2 border border-[#2a2a2a] text-text-secondary rounded-lg hover:bg-bg-hover", children: "\u8FD4\u56DE" }), _jsx("button", { onClick: () => window.location.reload(), className: "px-4 py-2 bg-accent text-bg-primary rounded-lg font-medium", children: "\u91CD\u65B0\u52A0\u8F7D" })] })] })); } + const resumeLineIndex = canResume(resumeHistory) + ? Math.min(Math.max(resumeHistory.sourceIndex || 0, 0), Math.max(0, lineSources.length - 1)) + : -1; + const resumeEpisodeIndex = canResume(resumeHistory) + ? Math.min(Math.max(resumeHistory.episodeIndex || 0, 0), Math.max(0, (lineSources[resumeLineIndex]?.episodes.length || 1) - 1)) + : -1; + const resumeEpisodeName = resumeEpisodeIndex >= 0 + ? lineSources[resumeLineIndex]?.episodes[resumeEpisodeIndex]?.name || resumeHistory?.episodeName + : ''; return (_jsxs("div", { className: "h-full overflow-y-auto scrollbar-dark", children: [showPlayer && (_jsxs("div", { className: "relative aspect-video bg-black", children: [_jsx(VideoPlayer, {}), _jsx("button", { onClick: () => setShowPlayer(false), className: "absolute top-3 left-3 z-30 p-2 bg-black/50 rounded-full text-white/80 hover:text-white", children: _jsx(ArrowLeft, { className: "w-5 h-5" }) }), allSourcesExhausted && (_jsxs("div", { className: "absolute inset-0 z-20 bg-black/85 flex flex-col items-center justify-center text-center p-6", children: [_jsx(AlertCircle, { className: "w-12 h-12 text-red-400 mb-3" }), _jsx("h3", { className: "text-lg font-semibold text-white mb-2", children: "\u6240\u6709\u6E90\u5747\u4E0D\u53EF\u7528" }), _jsx("p", { className: "text-sm text-white/70 mb-4", children: "\u5DF2\u5C1D\u8BD5\u591A\u4E2A\u6E90\uFF0C\u4ECD\u7136\u65E0\u6CD5\u64AD\u653E" }), _jsxs("div", { className: "flex gap-2", children: [_jsxs("button", { onClick: () => { if (activeLine) handlePlay(activeLineIndex, currentEpisodeIndex || 0); - }, className: "flex items-center gap-2 px-4 py-2 bg-accent text-bg-primary rounded-lg font-medium hover:bg-accent-hover", children: [_jsx(RotateCw, { className: "w-4 h-4" }), "\u91CD\u8BD5\u5F53\u524D"] }), _jsxs("button", { onClick: switchToNextSource, className: "flex items-center gap-2 px-4 py-2 border border-white/30 text-white rounded-lg hover:bg-white/10", children: [_jsx(RefreshCw, { className: "w-4 h-4" }), "\u5207\u6362\u5176\u4ED6\u6E90"] })] })] }))] })), (sourceSwitchState !== 'idle' || sourceSwitchMessage) && (_jsxs("div", { className: "flex items-center gap-2 px-4 py-2 bg-bg-secondary border-b border-[#2a2a2a] text-xs", children: [sourceSwitchState === 'searching' || sourceSwitchState === 'switching' ? (_jsx(Loader2, { className: "w-3.5 h-3.5 animate-spin text-accent shrink-0" })) : (_jsx(Globe, { className: "w-3.5 h-3.5 text-text-muted shrink-0" })), _jsx("span", { className: "text-text-secondary truncate", children: sourceSwitchMessage || '加载中...' })] })), _jsxs("div", { className: "p-6", children: [_jsxs("div", { className: "flex gap-6", children: [_jsx("div", { className: "shrink-0 w-48", children: _jsx("img", { src: detail?.vod_pic, alt: detail?.vod_name, className: "w-full aspect-[2/3] object-cover rounded-lg bg-bg-tertiary", onError: (e) => { e.target.style.display = 'none'; } }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h1", { className: "text-2xl font-semibold text-text-primary mb-3", children: detail?.vod_name }), _jsxs("div", { className: "space-y-1.5 text-sm text-text-secondary", children: [detail?.vod_year && _jsxs("p", { children: ["\u5E74\u4EFD\uFF1A", detail.vod_year] }), detail?.vod_area && _jsxs("p", { children: ["\u5730\u533A\uFF1A", detail.vod_area] }), detail?.type_name && _jsxs("p", { children: ["\u7C7B\u578B\uFF1A", detail.type_name] }), detail?.vod_director && _jsxs("p", { children: ["\u5BFC\u6F14\uFF1A", detail.vod_director] }), detail?.vod_actor && _jsxs("p", { children: ["\u6F14\u5458\uFF1A", detail.vod_actor] })] }), _jsxs("div", { className: "flex gap-3 mt-4 flex-wrap", children: [_jsxs("button", { onClick: () => { + }, className: "flex items-center gap-2 px-4 py-2 bg-accent text-bg-primary rounded-lg font-medium hover:bg-accent-hover", children: [_jsx(RotateCw, { className: "w-4 h-4" }), "\u91CD\u8BD5\u5F53\u524D"] }), _jsxs("button", { onClick: switchToNextSource, className: "flex items-center gap-2 px-4 py-2 border border-white/30 text-white rounded-lg hover:bg-white/10", children: [_jsx(RefreshCw, { className: "w-4 h-4" }), "\u5207\u6362\u5176\u4ED6\u6E90"] })] })] }))] })), (sourceSwitchState !== 'idle' || sourceSwitchMessage) && (_jsxs("div", { className: "flex items-center gap-2 px-4 py-2 bg-bg-secondary border-b border-[#2a2a2a] text-xs", children: [sourceSwitchState === 'searching' || sourceSwitchState === 'switching' ? (_jsx(Loader2, { className: "w-3.5 h-3.5 animate-spin text-accent shrink-0" })) : (_jsx(Globe, { className: "w-3.5 h-3.5 text-text-muted shrink-0" })), _jsx("span", { className: "text-text-secondary truncate", children: sourceSwitchMessage || '加载中...' })] })), _jsxs("div", { className: "p-6", children: [_jsxs("div", { className: "flex gap-6", children: [_jsx("div", { className: "shrink-0 w-48", children: _jsx("img", { src: detail?.vod_pic, alt: detail?.vod_name, className: "w-full aspect-[2/3] object-cover rounded-lg bg-bg-tertiary", onError: (e) => { e.target.style.display = 'none'; } }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h1", { className: "text-2xl font-semibold text-text-primary mb-3", children: detail?.vod_name }), _jsxs("div", { className: "space-y-1.5 text-sm text-text-secondary", children: [detail?.vod_year && _jsxs("p", { children: ["\u5E74\u4EFD\uFF1A", detail.vod_year] }), detail?.vod_area && _jsxs("p", { children: ["\u5730\u533A\uFF1A", detail.vod_area] }), detail?.type_name && _jsxs("p", { children: ["\u7C7B\u578B\uFF1A", detail.type_name] }), detail?.vod_director && _jsxs("p", { children: ["\u5BFC\u6F14\uFF1A", detail.vod_director] }), detail?.vod_actor && _jsxs("p", { children: ["\u6F14\u5458\uFF1A", detail.vod_actor] })] }), _jsxs("div", { className: "flex gap-3 mt-4 flex-wrap", children: [canResume(resumeHistory) && resumeLineIndex >= 0 && resumeEpisodeIndex >= 0 && (_jsxs("button", { onClick: () => handlePlay(resumeLineIndex, resumeEpisodeIndex, resumeHistory.positionSeconds || 0), disabled: isResolving || lineSources.length === 0, className: "flex items-center gap-2 px-5 py-2 bg-accent hover:bg-accent-hover text-bg-primary rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: [isResolving ? _jsx(Loader2, { className: "w-4 h-4 animate-spin" }) : _jsx(Play, { className: "w-4 h-4" }), "\u7EE7\u7EED ", resumeEpisodeName || resumeHistory.episodeName || '上次观看', " \u00B7 ", formatResumeTime(resumeHistory.positionSeconds)] })), _jsxs("button", { onClick: () => { if (activeLine) handlePlay(activeLineIndex, currentEpisodeIndex || 0); - }, disabled: isResolving || lineSources.length === 0, className: "flex items-center gap-2 px-5 py-2 bg-accent hover:bg-accent-hover text-bg-primary rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: [isResolving ? _jsx(Loader2, { className: "w-4 h-4 animate-spin" }) : _jsx(Play, { className: "w-4 h-4" }), isResolving ? '解析中...' : '播放'] }), _jsxs("button", { onClick: handleToggleKeep, className: `flex items-center gap-2 px-5 py-2 rounded-lg border transition-colors ${isKept + }, disabled: isResolving || lineSources.length === 0, className: `flex items-center gap-2 px-5 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${canResume(resumeHistory) + ? 'border border-[#2a2a2a] text-text-secondary hover:text-accent hover:border-accent' + : 'bg-accent hover:bg-accent-hover text-bg-primary'}`, children: [isResolving ? _jsx(Loader2, { className: "w-4 h-4 animate-spin" }) : _jsx(Play, { className: "w-4 h-4" }), isResolving ? '解析中...' : '播放'] }), _jsxs("button", { onClick: handleToggleKeep, className: `flex items-center gap-2 px-5 py-2 rounded-lg border transition-colors ${isKept ? 'border-accent text-accent bg-accent-muted' : 'border-[#2a2a2a] text-text-secondary hover:text-accent hover:border-accent'}`, children: [_jsx(Heart, { className: `w-4 h-4 ${isKept ? 'fill-accent' : ''}` }), isKept ? '已收藏' : '收藏'] })] }), detail?.vod_content && (_jsx("p", { className: "mt-4 text-sm text-text-muted leading-relaxed line-clamp-3", children: detail.vod_content.replace(/<[^>]+>/g, '') }))] })] }), lineSources.length > 0 && (_jsxs("div", { className: "mt-6", children: [_jsxs("div", { className: "flex items-center gap-2 mb-3", children: [_jsx(Globe, { className: "w-4 h-4 text-text-muted" }), _jsx("span", { className: "text-sm font-medium text-text-secondary", children: "\u591A\u7EBF\u8DEF\u7247\u6E90" }), alternativeSources.length > 0 && (_jsxs("span", { className: "text-xs text-text-muted ml-auto", children: [alternativeSources.length, " \u4E2A\u5907\u9009\u6E90"] }))] }), _jsx("div", { className: "flex gap-2 overflow-x-auto scrollbar-dark pb-1", children: lineSources.map((line, idx) => (_jsxs("button", { onClick: () => setActiveLineIndex(idx), className: `shrink-0 px-3 py-1.5 text-xs rounded-md transition-colors ${activeLineIndex === idx ? 'bg-accent/20 text-accent font-medium' diff --git a/out/renderer/src/renderer/src/stores/useConfigStore.d.ts b/out/renderer/src/renderer/src/stores/useConfigStore.d.ts index 2164a00..f3c72cf 100644 --- a/out/renderer/src/renderer/src/stores/useConfigStore.d.ts +++ b/out/renderer/src/renderer/src/stores/useConfigStore.d.ts @@ -5,6 +5,7 @@ export interface Site { api: string; ext?: string; jar?: string; + hide?: number; searchable: number; changeable: number; } diff --git a/out/renderer/src/renderer/src/stores/useConfigStore.js b/out/renderer/src/renderer/src/stores/useConfigStore.js index b33ef68..22a5325 100644 --- a/out/renderer/src/renderer/src/stores/useConfigStore.js +++ b/out/renderer/src/renderer/src/stores/useConfigStore.js @@ -18,7 +18,7 @@ const initialState = { * 判断站点是否可用于首页(type=0/1/4 是 HTTP API 站点) */ function isVisibleSite(s) { - return s.type === 0 || s.type === 1 || s.type === 4; + return (s.type === 0 || s.type === 1 || s.type === 4) && s.hide !== 1 && Boolean(s.api?.trim()); } /** * 并行探测站点,返回第一个成功的 diff --git a/out/renderer/src/renderer/src/stores/usePlayerStore.d.ts b/out/renderer/src/renderer/src/stores/usePlayerStore.d.ts index 9adbd9f..86c1968 100644 --- a/out/renderer/src/renderer/src/stores/usePlayerStore.d.ts +++ b/out/renderer/src/renderer/src/stores/usePlayerStore.d.ts @@ -26,6 +26,17 @@ export interface AlternativeSource { vodRemarks?: string; } export type PlaybackPhase = 'idle' | 'resolving' | 'connecting' | 'buffering' | 'playing' | 'recovering' | 'failed'; +export interface PlaybackDiagnostic { + stage: 'parse' | 'connect' | 'manifest' | 'buffer' | 'media' | 'network' | 'unknown'; + errorKind: 'parse_failed' | 'timeout' | 'http' | 'hls' | 'media' | 'unsupported' | 'unknown'; + protocol?: 'hls' | 'dash' | 'native' | 'unknown'; + httpStatus?: number; + elapsedMs?: number; + sourceId?: string; + attempt?: number; + sourceCount?: number; + nextAction?: string; +} interface PlayerState { isPlaying: boolean; currentUrl: string; @@ -40,6 +51,7 @@ interface PlayerState { episodes: Episode[]; currentEpisodeIndex: number; currentSourceIndex: number; + currentSourceName: string; playHeader: Record | null; currentSiteKey: string; playbackPhase: PlaybackPhase; @@ -48,6 +60,7 @@ interface PlayerState { playbackStartedAt: number; playbackFirstFrameAt: number; playbackLastErrorAt: number; + playbackDiagnostic: PlaybackDiagnostic | null; /** 备选源队列(来自其他站点的同名 VOD) */ alternativeSources: AlternativeSource[]; /** 已失败的源 key 集合(siteKey + vodId) */ @@ -72,11 +85,11 @@ interface PlayerActions { toggleFullscreen: () => void; nextEpisode: () => void; prevEpisode: () => void; - setVod: (vod: VodDetail, episodes: Episode[], sourceIndex?: number, resolvedUrl?: string, header?: Record, siteKey?: string) => void; - setCurrentEpisodeIndex: (index: number, resolvedUrl?: string) => void; + setVod: (vod: VodDetail, episodes: Episode[], sourceIndex?: number, resolvedUrl?: string, header?: Record, siteKey?: string, sourceName?: string, startPositionSeconds?: number) => void; + setCurrentEpisodeIndex: (index: number, resolvedUrl?: string, startPositionSeconds?: number) => void; setCurrentSourceIndex: (index: number) => void; setPlaybackPhase: (phase: PlaybackPhase, message?: string) => void; - setPlaybackError: (message: string) => void; + setPlaybackError: (message: string, diagnostic?: Partial) => void; markPlaybackFirstFrame: () => void; /** 设置备选源队列 */ setAlternativeSources: (sources: AlternativeSource[]) => void; diff --git a/out/renderer/src/renderer/src/stores/usePlayerStore.js b/out/renderer/src/renderer/src/stores/usePlayerStore.js index ddef2b7..a747af1 100644 --- a/out/renderer/src/renderer/src/stores/usePlayerStore.js +++ b/out/renderer/src/renderer/src/stores/usePlayerStore.js @@ -13,6 +13,7 @@ const initialState = { episodes: [], currentEpisodeIndex: 0, currentSourceIndex: 0, + currentSourceName: '', playHeader: null, currentSiteKey: '', playbackPhase: 'idle', @@ -21,6 +22,7 @@ const initialState = { playbackStartedAt: 0, playbackFirstFrameAt: 0, playbackLastErrorAt: 0, + playbackDiagnostic: null, alternativeSources: [], brokenSources: new Set(), sourceSwitchState: 'idle', @@ -42,6 +44,7 @@ export const usePlayerStore = create()((set, get) => ({ playbackPhase: 'connecting', playbackMessage: '正在连接播放地址...', playbackError: '', + playbackDiagnostic: null, playbackStartedAt: Date.now(), playbackFirstFrameAt: 0, playbackLastErrorAt: 0, @@ -54,6 +57,7 @@ export const usePlayerStore = create()((set, get) => ({ playbackPhase: s.currentUrl ? 'connecting' : s.playbackPhase, playbackMessage: s.currentUrl ? '正在连接播放地址...' : s.playbackMessage, playbackError: '', + playbackDiagnostic: null, playbackLastErrorAt: 0, playKey: s.playKey + 1 })); @@ -68,6 +72,7 @@ export const usePlayerStore = create()((set, get) => ({ playbackPhase: 'idle', playbackMessage: '', playbackError: '', + playbackDiagnostic: null, playbackFirstFrameAt: 0, playbackLastErrorAt: 0 }), @@ -102,14 +107,15 @@ export const usePlayerStore = create()((set, get) => ({ }); } }, - setVod: (vod, episodes, sourceIndex = 0, resolvedUrl, header, siteKey) => { + setVod: (vod, episodes, sourceIndex = 0, resolvedUrl, header, siteKey, sourceName = '', startPositionSeconds = 0) => { set({ currentVod: vod, episodes, currentSourceIndex: sourceIndex, currentEpisodeIndex: 0, + currentSourceName: sourceName, currentUrl: resolvedUrl || (episodes.length > 0 ? episodes[0].url : ''), - currentTime: 0, + currentTime: Math.max(0, startPositionSeconds), duration: 0, isPlaying: episodes.length > 0, playHeader: header || null, @@ -117,22 +123,24 @@ export const usePlayerStore = create()((set, get) => ({ playbackPhase: resolvedUrl === '__resolving__' ? 'resolving' : episodes.length > 0 ? 'connecting' : 'idle', playbackMessage: resolvedUrl === '__resolving__' ? '解析播放地址中...' : episodes.length > 0 ? '正在连接播放地址...' : '', playbackError: '', + playbackDiagnostic: null, playbackStartedAt: episodes.length > 0 ? Date.now() : 0, playbackFirstFrameAt: 0, playbackLastErrorAt: 0 }); }, - setCurrentEpisodeIndex: (index, resolvedUrl) => { + setCurrentEpisodeIndex: (index, resolvedUrl, startPositionSeconds = 0) => { const { episodes } = get(); if (index >= 0 && index < episodes.length) { set({ currentEpisodeIndex: index, currentUrl: resolvedUrl || episodes[index].url, - currentTime: 0, + currentTime: Math.max(0, startPositionSeconds), isPlaying: true, playbackPhase: resolvedUrl === '__resolving__' ? 'resolving' : 'connecting', playbackMessage: resolvedUrl === '__resolving__' ? '解析播放地址中...' : '正在连接播放地址...', playbackError: '', + playbackDiagnostic: null, playbackStartedAt: Date.now(), playbackFirstFrameAt: 0, playbackLastErrorAt: 0 @@ -143,19 +151,32 @@ export const usePlayerStore = create()((set, get) => ({ setPlaybackPhase: (phase, message = '') => set({ playbackPhase: phase, playbackMessage: message, - playbackError: phase === 'failed' ? get().playbackError : '' + playbackError: phase === 'failed' ? get().playbackError : '', + playbackDiagnostic: phase === 'failed' ? get().playbackDiagnostic : null }), - setPlaybackError: (message) => set({ + setPlaybackError: (message, diagnostic = {}) => set((state) => ({ playbackPhase: 'failed', - playbackMessage: '播放失败', + playbackMessage: diagnostic.stage === 'parse' ? '解析失败' : '播放失败', playbackError: message, - playbackLastErrorAt: Date.now() - }), + playbackLastErrorAt: Date.now(), + playbackDiagnostic: { + stage: diagnostic.stage || 'unknown', + errorKind: diagnostic.errorKind || 'unknown', + protocol: diagnostic.protocol || 'unknown', + httpStatus: diagnostic.httpStatus, + elapsedMs: diagnostic.elapsedMs, + sourceId: diagnostic.sourceId || (state.currentSiteKey && state.currentVod ? `${state.currentSiteKey}::${state.currentVod.vod_id}` : undefined), + attempt: diagnostic.attempt ?? state.brokenSources.size + 1, + sourceCount: diagnostic.sourceCount ?? state.alternativeSources.length + state.brokenSources.size + 1, + nextAction: diagnostic.nextAction || (state.autoSwitchSource ? '自动尝试下一条可用线路' : '可手动重试或切换线路') + } + })), markPlaybackFirstFrame: () => set((state) => ({ playbackPhase: 'playing', playbackMessage: '正在播放', playbackFirstFrameAt: state.playbackFirstFrameAt || Date.now(), - playbackError: '' + playbackError: '', + playbackDiagnostic: null })), // ==================== 换源 Actions ==================== setAlternativeSources: (sources) => set({ alternativeSources: sources }), diff --git a/out/renderer/src/renderer/src/utils/playbackMetrics.d.ts b/out/renderer/src/renderer/src/utils/playbackMetrics.d.ts new file mode 100644 index 0000000..cedacf5 --- /dev/null +++ b/out/renderer/src/renderer/src/utils/playbackMetrics.d.ts @@ -0,0 +1,19 @@ +export interface PlaybackMetricSample { + type: 'first_frame' | 'failure'; + at: number; + elapsedMs?: number; + phase?: string; + errorKind?: string; + stage?: string; + protocol?: string; + sourceId?: string; +} +export interface PlaybackMetricSummary { + totalFirstFrameSamples: number; + p50FirstFrameMs: number | null; + p90FirstFrameMs: number | null; + totalFailures: number; +} +export declare function recordPlaybackMetric(sample: PlaybackMetricSample): void; +export declare function getPlaybackMetricSummary(): PlaybackMetricSummary; +export declare function clearPlaybackMetrics(): void; diff --git a/out/renderer/src/renderer/src/utils/playbackMetrics.js b/out/renderer/src/renderer/src/utils/playbackMetrics.js new file mode 100644 index 0000000..6b4fc62 --- /dev/null +++ b/out/renderer/src/renderer/src/utils/playbackMetrics.js @@ -0,0 +1,62 @@ +const STORAGE_KEY = 'iptv:playback-metrics:v1'; +const MAX_SAMPLES = 200; +function readSamples() { + if (typeof window === 'undefined') + return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) + return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isSample) : []; + } + catch { + return []; + } +} +function isSample(value) { + if (!value || typeof value !== 'object') + return false; + const sample = value; + return (sample.type === 'first_frame' || sample.type === 'failure') && typeof sample.at === 'number'; +} +export function recordPlaybackMetric(sample) { + if (typeof window === 'undefined') + return; + try { + const samples = [...readSamples(), sample].slice(-MAX_SAMPLES); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(samples)); + } + catch { + // Metrics should never interrupt playback. + } +} +export function getPlaybackMetricSummary() { + const samples = readSamples(); + const firstFrameMs = samples + .filter((sample) => sample.type === 'first_frame' && typeof sample.elapsedMs === 'number') + .map((sample) => sample.elapsedMs) + .sort((a, b) => a - b); + return { + totalFirstFrameSamples: firstFrameMs.length, + p50FirstFrameMs: percentile(firstFrameMs, 0.5), + p90FirstFrameMs: percentile(firstFrameMs, 0.9), + totalFailures: samples.filter((sample) => sample.type === 'failure').length + }; +} +export function clearPlaybackMetrics() { + if (typeof window === 'undefined') + return; + try { + window.localStorage.removeItem(STORAGE_KEY); + } + catch { + // Ignore storage failures. + } +} +function percentile(values, percentileValue) { + if (values.length === 0) + return null; + const index = Math.round((values.length - 1) * percentileValue); + return values[index] ?? null; +} diff --git a/out/renderer/src/shared/types.d.ts b/out/renderer/src/shared/types.d.ts index 5a616ba..97499f5 100644 --- a/out/renderer/src/shared/types.d.ts +++ b/out/renderer/src/shared/types.d.ts @@ -178,6 +178,18 @@ export interface ConfigInspection { liveCount: number; parseCount: number; hasSpider: boolean; + sourceType?: string; + compatibility: 'ready' | 'live' | 'unsupported' | 'invalid'; + compatibilityLabel: string; + canImport: boolean; + hiddenSiteCount: number; + cspSiteCount: number; + missingApiSiteCount: number; + probeInspectedSiteCount: number; + probePassedSiteCount: number; + probeFailedSiteCount: number; + probeSkippedSiteCount: number; + liveChannelCount: number; warnings: string[]; } /** 追看/時移設定 */ @@ -256,6 +268,15 @@ export interface History { type?: number; source?: string; progress?: number; + episodeId?: string; + episodeName?: string; + episodeIndex?: number; + sourceIndex?: number; + sourceName?: string; + urlIdentifier?: string; + duration?: number; + positionSeconds?: number; + completed?: boolean; createTime?: number; updateTime?: number; } diff --git a/out/renderer/tsconfig.web.tsbuildinfo b/out/renderer/tsconfig.web.tsbuildinfo index af2b4d8..30b20c7 100644 --- a/out/renderer/tsconfig.web.tsbuildinfo +++ b/out/renderer/tsconfig.web.tsbuildinfo @@ -1 +1 @@ -{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.esnext.error.d.ts","../../node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@types/react/global.d.ts","../../node_modules/csstype/index.d.ts","../../node_modules/@types/prop-types/index.d.ts","../../node_modules/@types/react/index.d.ts","../../node_modules/@types/react/jsx-runtime.d.ts","../../node_modules/@remix-run/router/dist/history.d.ts","../../node_modules/@remix-run/router/dist/utils.d.ts","../../node_modules/@remix-run/router/dist/router.d.ts","../../node_modules/@remix-run/router/dist/index.d.ts","../../node_modules/react-router/dist/lib/context.d.ts","../../node_modules/react-router/dist/lib/components.d.ts","../../node_modules/react-router/dist/lib/hooks.d.ts","../../node_modules/react-router/dist/lib/deprecations.d.ts","../../node_modules/react-router/dist/index.d.ts","../../node_modules/react-router-dom/dist/dom.d.ts","../../node_modules/react-router-dom/dist/index.d.ts","../../node_modules/lucide-react/dist/lucide-react.d.ts","../../src/renderer/src/components/applogo/applogo.tsx","../../src/renderer/src/components/sidebar/sidebar.tsx","../../node_modules/hls.js/dist/hls.d.mts","../../node_modules/dashjs/index.d.ts","../../node_modules/@tauri-apps/api/core.d.ts","../../node_modules/@tauri-apps/api/event.d.ts","../../src/shared/types.ts","../../src/renderer/src/utils/ipc.ts","../../src/renderer/src/components/miniplayer/miniplayer.tsx","../../node_modules/zustand/esm/vanilla.d.mts","../../node_modules/zustand/esm/react.d.mts","../../node_modules/zustand/esm/index.d.mts","../../src/renderer/src/stores/useconfigstore.ts","../../src/renderer/src/components/vodcard/vodcard.tsx","../../src/renderer/src/components/emptystate/emptystate.tsx","../../src/renderer/src/pages/home/home.tsx","../../src/renderer/src/stores/useplayerstore.ts","../../src/renderer/src/utils/media.ts","../../src/renderer/src/components/danmakulayer/danmakulayer.tsx","../../src/renderer/src/components/subtitlelayer/subtitlelayer.tsx","../../src/renderer/src/utils/redact.ts","../../src/renderer/src/components/videoplayer/videoplayer.tsx","../../src/renderer/src/pages/voddetail/voddetail.tsx","../../src/renderer/src/stores/uselivestore.ts","../../src/renderer/src/components/channelitem/channelitem.tsx","../../src/renderer/src/components/livetree/livetree.tsx","../../src/renderer/src/components/liverefreshbar/liverefreshbar.tsx","../../src/renderer/src/pages/live/live.tsx","../../src/renderer/src/pages/search/search.tsx","../../src/renderer/src/pages/history/history.tsx","../../src/renderer/src/pages/keep/keep.tsx","../../src/renderer/src/pages/settings/settings.tsx","../../src/renderer/src/pages/onboarding/onboarding.tsx","../../src/renderer/src/app.tsx","../../node_modules/@types/react-dom/client.d.ts","../../src/renderer/src/main.tsx","../../node_modules/vite/types/hmrpayload.d.ts","../../node_modules/vite/types/customevent.d.ts","../../node_modules/vite/types/hot.d.ts","../../node_modules/vite/types/importglob.d.ts","../../node_modules/vite/types/importmeta.d.ts","../../node_modules/vite/client.d.ts","../../src/renderer/src/vite-env.d.ts","../../src/shared/errors.ts","../../node_modules/@babel/types/lib/index.d.ts","../../node_modules/@types/babel__generator/index.d.ts","../../node_modules/@babel/parser/typings/babel-parser.d.ts","../../node_modules/@types/babel__template/index.d.ts","../../node_modules/@types/babel__traverse/index.d.ts","../../node_modules/@types/babel__core/index.d.ts","../../node_modules/@types/deep-eql/index.d.ts","../../node_modules/assertion-error/index.d.ts","../../node_modules/@types/chai/index.d.ts","../../node_modules/@types/estree/index.d.ts","../../node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/@types/node/compatibility/index.d.ts","../../node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/@types/node/globals.d.ts","../../node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/@types/node/web-globals/events.d.ts","../../node_modules/undici-types/header.d.ts","../../node_modules/undici-types/readable.d.ts","../../node_modules/undici-types/file.d.ts","../../node_modules/undici-types/fetch.d.ts","../../node_modules/undici-types/formdata.d.ts","../../node_modules/undici-types/connector.d.ts","../../node_modules/undici-types/client.d.ts","../../node_modules/undici-types/errors.d.ts","../../node_modules/undici-types/dispatcher.d.ts","../../node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/undici-types/global-origin.d.ts","../../node_modules/undici-types/pool-stats.d.ts","../../node_modules/undici-types/pool.d.ts","../../node_modules/undici-types/handlers.d.ts","../../node_modules/undici-types/balanced-pool.d.ts","../../node_modules/undici-types/agent.d.ts","../../node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/undici-types/mock-agent.d.ts","../../node_modules/undici-types/mock-client.d.ts","../../node_modules/undici-types/mock-pool.d.ts","../../node_modules/undici-types/mock-errors.d.ts","../../node_modules/undici-types/proxy-agent.d.ts","../../node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/undici-types/retry-handler.d.ts","../../node_modules/undici-types/retry-agent.d.ts","../../node_modules/undici-types/api.d.ts","../../node_modules/undici-types/interceptors.d.ts","../../node_modules/undici-types/util.d.ts","../../node_modules/undici-types/cookies.d.ts","../../node_modules/undici-types/patch.d.ts","../../node_modules/undici-types/websocket.d.ts","../../node_modules/undici-types/eventsource.d.ts","../../node_modules/undici-types/filereader.d.ts","../../node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/undici-types/content-type.d.ts","../../node_modules/undici-types/cache.d.ts","../../node_modules/undici-types/index.d.ts","../../node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/@types/node/web-globals/navigator.d.ts","../../node_modules/@types/node/web-globals/storage.d.ts","../../node_modules/@types/node/assert.d.ts","../../node_modules/@types/node/assert/strict.d.ts","../../node_modules/@types/node/async_hooks.d.ts","../../node_modules/@types/node/buffer.d.ts","../../node_modules/@types/node/child_process.d.ts","../../node_modules/@types/node/cluster.d.ts","../../node_modules/@types/node/console.d.ts","../../node_modules/@types/node/constants.d.ts","../../node_modules/@types/node/crypto.d.ts","../../node_modules/@types/node/dgram.d.ts","../../node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/@types/node/dns.d.ts","../../node_modules/@types/node/dns/promises.d.ts","../../node_modules/@types/node/domain.d.ts","../../node_modules/@types/node/events.d.ts","../../node_modules/@types/node/fs.d.ts","../../node_modules/@types/node/fs/promises.d.ts","../../node_modules/@types/node/http.d.ts","../../node_modules/@types/node/http2.d.ts","../../node_modules/@types/node/https.d.ts","../../node_modules/@types/node/inspector.d.ts","../../node_modules/@types/node/inspector.generated.d.ts","../../node_modules/@types/node/module.d.ts","../../node_modules/@types/node/net.d.ts","../../node_modules/@types/node/os.d.ts","../../node_modules/@types/node/path.d.ts","../../node_modules/@types/node/perf_hooks.d.ts","../../node_modules/@types/node/process.d.ts","../../node_modules/@types/node/punycode.d.ts","../../node_modules/@types/node/querystring.d.ts","../../node_modules/@types/node/readline.d.ts","../../node_modules/@types/node/readline/promises.d.ts","../../node_modules/@types/node/repl.d.ts","../../node_modules/@types/node/sea.d.ts","../../node_modules/@types/node/sqlite.d.ts","../../node_modules/@types/node/stream.d.ts","../../node_modules/@types/node/stream/promises.d.ts","../../node_modules/@types/node/stream/consumers.d.ts","../../node_modules/@types/node/stream/web.d.ts","../../node_modules/@types/node/string_decoder.d.ts","../../node_modules/@types/node/test.d.ts","../../node_modules/@types/node/timers.d.ts","../../node_modules/@types/node/timers/promises.d.ts","../../node_modules/@types/node/tls.d.ts","../../node_modules/@types/node/trace_events.d.ts","../../node_modules/@types/node/tty.d.ts","../../node_modules/@types/node/url.d.ts","../../node_modules/@types/node/util.d.ts","../../node_modules/@types/node/v8.d.ts","../../node_modules/@types/node/vm.d.ts","../../node_modules/@types/node/wasi.d.ts","../../node_modules/@types/node/worker_threads.d.ts","../../node_modules/@types/node/zlib.d.ts","../../node_modules/@types/node/index.d.ts","../../node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[144,159,207,224,225],[159,207,224,225],[88,89,90,159,207,224,225],[88,89,159,207,224,225],[88,159,207,224,225],[144,145,146,147,148,159,207,224,225],[144,146,159,207,224,225],[150,151,159,207,224,225],[159,204,205,207,224,225],[159,206,207,224,225],[207,224,225],[159,207,212,224,225,242],[159,207,208,213,218,224,225,227,239,250],[159,207,208,209,218,224,225,227],[154,155,156,159,207,224,225],[159,207,210,224,225,251],[159,207,211,212,219,224,225,228],[159,207,212,224,225,239,247],[159,207,213,215,218,224,225,227],[159,206,207,214,224,225],[159,207,215,216,224,225],[159,207,217,218,224,225],[159,206,207,218,224,225],[159,207,218,219,220,224,225,239,250],[159,207,218,219,220,224,225,234,239,242],[159,200,207,215,218,221,224,225,227,239,250],[159,207,218,219,221,222,224,225,227,239,247,250],[159,207,221,223,224,225,239,247,250],[157,158,159,160,161,162,163,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256],[159,207,218,224,225],[159,207,224,225,226,250],[159,207,215,218,224,225,227,239],[159,207,224,225,228],[159,207,224,225,229],[159,206,207,224,225,230],[159,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256],[159,207,224,225,232],[159,207,224,225,233],[159,207,218,224,225,234,235],[159,207,224,225,234,236,251,253],[159,207,219,224,225],[159,207,218,224,225,239,240,242],[159,207,224,225,241,242],[159,207,224,225,239,240],[159,207,224,225,242],[159,207,224,225,243],[159,204,207,224,225,239,244,250],[159,207,218,224,225,245,246],[159,207,224,225,245,246],[159,207,212,224,225,227,239,247],[159,207,224,225,248],[159,207,224,225,227,249],[159,207,221,224,225,233,250],[159,207,212,224,225,251],[159,207,224,225,239,252],[159,207,224,225,226,253],[159,207,224,225,254],[159,200,207,224,225],[159,200,207,218,220,224,225,230,239,242,250,252,253,255],[159,207,224,225,239,256],[86,159,207,224,225],[83,84,85,159,207,224,225],[91,159,207,224,225],[86,91,96,97,159,207,224,225],[91,92,93,94,95,159,207,224,225],[86,91,92,159,207,224,225],[86,91,159,207,224,225],[91,93,159,207,224,225],[159,172,176,207,224,225,250],[159,172,207,224,225,239,250],[159,167,207,224,225],[159,169,172,207,224,225,247,250],[159,207,224,225,227,247],[159,207,224,225,257],[159,167,207,224,225,257],[159,169,172,207,224,225,227,250],[159,164,165,168,171,207,218,224,225,239,250],[159,172,179,207,224,225],[159,164,170,207,224,225],[159,172,193,194,207,224,225],[159,168,172,207,224,225,242,250,257],[159,193,207,224,225,257],[159,166,167,207,224,225,257],[159,172,207,224,225],[159,166,167,168,169,170,171,172,173,174,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,194,195,196,197,198,199,207,224,225],[159,172,187,207,224,225],[159,172,179,180,207,224,225],[159,170,172,180,181,207,224,225],[159,171,207,224,225],[159,164,167,172,207,224,225],[159,172,176,180,181,207,224,225],[159,176,207,224,225],[159,170,172,175,207,224,225,250],[159,164,169,172,179,207,224,225],[159,207,224,225,239],[159,167,172,193,207,224,225,255,257],[140,159,207,224,225],[136,159,207,224,225],[137,159,207,224,225],[138,139,159,207,224,225],[109,110,159,207,224,225],[109,159,207,224,225],[86,87,98,101,107,108,112,115,122,127,128,129,130,131,132,159,207,224,225],[87,141,159,207,224,225],[87,123,159,207,224,225],[86,87,159,207,224,225],[86,87,99,159,207,224,225],[87,99,159,207,224,225],[86,87,99,102,103,107,159,207,224,225],[86,87,98,99,100,159,207,224,225],[86,87,99,102,103,107,116,118,119,120,159,207,224,225],[87,112,159,207,224,225],[86,87,98,133,134,141,159,207,224,225],[86,87,98,99,106,107,159,207,224,225],[86,87,98,99,112,113,114,159,207,224,225],[86,87,98,99,106,107,112,113,159,207,224,225],[86,87,98,99,107,112,114,116,117,121,123,124,125,126,159,207,224,225],[86,87,98,99,100,106,107,112,159,207,224,225],[86,87,98,99,107,112,113,114,159,207,224,225],[86,87,98,99,106,107,112,159,207,224,225],[86,87,98,99,107,112,116,117,121,159,207,224,225],[87,107,111,159,207,224,225],[87,106,107,111,159,207,224,225],[87,111,159,207,224,225],[87,104,105,106,159,207,224,225],[87,107,159,207,224,225],[87,159,207,224,225],[141,159,207,224,225]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"bcfe74f2b819ae25094c2f5e474971691e41867bdb409851897682bbe53e398d","affectsGlobalScope":true,"impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"5af7c35a9c5c4760fd084fedb6ba4c7059ac9598b410093e5312ac10616bf17b","impliedFormat":1},{"version":"503cdeeaeca0f08ff204ea818f2b3fe711b5e98ac574b6cded51495c943f2c34","impliedFormat":1},{"version":"3c8c1edb7ed8a842cb14d9f2ba6863183168a9fc8d6aa15dec221ebf8b946393","impliedFormat":1},{"version":"0a6e1a3f199d6cc3df0410b4df05a914987b1e152c4beacfd0c5142e15302cac","impliedFormat":1},{"version":"ec3c1376b4f34b271b1815674546fe09a2f449b0773afd381bbce7eb2f96a731","impliedFormat":1},{"version":"c2a262a3157e868d279327daf428dd629bd485f825800c8e62b001e6c879aff6","impliedFormat":1},{"version":"f7e84f314f9276b44b707289446303db8ef34a6c2c6d6b08d03a76e168367072","impliedFormat":1},{"version":"2b493706eb7879d42a8e8009379292b59827d612ab3a275bc476f87e60259c79","impliedFormat":1},{"version":"5542628b04d7bd4e0cd4871b6791b3b936a917ac6b819dcd20487f040acb01f1","impliedFormat":1},{"version":"f07b335f87cfac999fc1da00dfbc616837ced55be67bcaa41524d475b7394554","impliedFormat":1},{"version":"938326adb4731184e14e17fc57fca2a3b69c337ef8d6c00c65d73472fe8feca4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5f0258de817857a01db5d1ab9aed63c4e88af54b62829fd4777c4325fa8ab2ef","impliedFormat":1},{"version":"d8d8d1bc5f2164126edffc39993517ea2d26a640cf39d6e7c5446d79856e7e50","signature":"2116701713791a859eb181d77a66d9e78b6818132ff2a3f40d3d1df65e3f4923"},{"version":"83a9cd4455bc32eb6d33349a3bd22c67d77e98710457dee315269d7c96d7caa8","signature":"6a20dbddfd986b0589ed4c8176bdfbf754a4627e23cb742287a363451dd4dd73"},{"version":"1c97ee9a298f6c15c3e637015ee8ce3020a07329105d430d4ec45a5b6cecbb3c","impliedFormat":99},{"version":"702cc0c98bf4587edbdae3d2046581b23fb6258f4b20170b4c550c0badaf0735","impliedFormat":1},{"version":"db60ed38e38bea3fabc38769e626552f9fe9e2c0c7a4e40e7ead930b46840910","impliedFormat":99},{"version":"0d738dff2ba5e25de6ef3fe078235a1f32f4c93438f50c4146cac00c9c0e7456","affectsGlobalScope":true,"impliedFormat":99},{"version":"e316002d69229e83f915843e81b72f4001a4dd85979bcac625358e73f65e3916","signature":"6667107826fd0337abffe88fdeb8b44edc68905f2f21d1e2dcef789c66a79130"},{"version":"a49a168d837079b88b9e78c171acac642477533c5704c79a70f693fd207f78f5","signature":"6dac481510937211dd6a5a20c396ef4e0d10e750e250dd28c2fdbe8bd9e3683f"},{"version":"f3b8be6211ca2db8910264263a9a1b64df397ea50e1552792ddebbd6dcfa71cd","signature":"2893ef918249432b0d2130b9e3ab3784ab3ce74b3af7ce13e9605d643d4b7381"},{"version":"4d7d964609a07368d076ce943b07106c5ebee8138c307d3273ba1cf3a0c3c751","impliedFormat":99},{"version":"0e48c1354203ba2ca366b62a0f22fec9e10c251d9d6420c6d435da1d079e6126","impliedFormat":99},{"version":"0662a451f0584bb3026340c3661c3a89774182976cd373eca502a1d3b5c7b580","impliedFormat":99},{"version":"1d947c9def614e65b43ee7b2b75c2e3b3990da0743488efbe812126063962dd0","signature":"ff564342f7874e5423117cd3970185ecaf32d11b1f077b88e531cb7e62895e62"},{"version":"c73bd72a6d9d02b63ccc57a41bfd3ce7b88dfece6138a598d8187e66033fae57","signature":"ebe2c15f92102f938beb6a5d136b609eb29b98d235071fc3bda522c6782273df"},{"version":"3446cf96e65ca09a7be4886d850527b16c68681cec9adc3a256cd6ff4a06892f","signature":"027cb8856abc015753acbc20e3dbe12d9bbe6f05f8b47fc9d34e0d90b70274ff"},{"version":"80588de277dd2e0f9f0848308045ac1362805031b36866d1be499ed06b995196","signature":"3eb972ae325aa293fdb6077cdf956f209ee6ea34b4e874ff7ec8686b3079972f"},{"version":"4fc252fda886107f76e001151dd6db9c5fab8039fa2711eb6de598f83b8c2319","signature":"0759ba7ba64a55f8aa5d31ad3a52830fd22f765723d86ae3845db19061b66590"},{"version":"013eaa1dcf568f4ef7eb96a26957facb66208fa9be33c4c1ebf9546e1b09b508","signature":"fe1f679caec8c196aa2197656ad2a6d446db605db24ed199d32a639f1573b732"},{"version":"40fc2bfa95c1556bffe704bd71f67f8104285e2e8757a9ebc41b58172d92be6f","signature":"9dc42250606565574ff85f6f1b69c7afe5774086f69bff011b06bac888b705c2"},{"version":"4e66e21b0e01c8540725f8cc3cd2c6707ffa2074c2191784782a856268a67af7","signature":"37b67aabddfbcf28e52398404b278e3353dbdb504bbb5b2e978700e85118e3b8"},{"version":"aa3df903722043c96cd208122a53802ec5314b5adc7d3f7f81fe021426398737","signature":"ef8ddc9716f5fe50c9191a97201465490f75448cf0e225b536dcc78e4993b3df"},{"version":"82ec8d8e0dcc36abd4d40bd25a5a6a4cd271d222d19b32e48c09f4caf3945ce2","signature":"9623271f887d558d9d845479a93fe2a8cb6d69a76d8511164a3db0828c38f340"},{"version":"57bc260211c1ff9aa4907ac08b472f4aac0fa4750abe09e0a85e258007d3a875","signature":"18a2155772d1b8b5dbf2e644c2f4dad3d466a19b22884b4517fa27c64006da42"},{"version":"34104a5b66161181db9d2c1cf11bce9163c8e09b0111a7d537701b1646b70c72","signature":"1f3463f0970a8b2ba6abe72da5aa50e980523a0ae9545b5ee27ea85ca1556b49"},{"version":"0ca2e03e7bf61fc604cbf72615ef352b13b4167f2617b1a0a448db72ef530d61","signature":"405b5b4593e64948f5ccccf462cdeade2bfdfdb433176238856ea1b73a2e0acc"},{"version":"41b6d9f33a5099c6382ef959492255cd150bac8ad0036f29b34eb88f456b24f3","signature":"bc0a9b9763f2961081a33aaf39889745c64c2d1f005dace8425628b1523fade9"},{"version":"2733bbafb1e6d2fe8c681bb45a53ebe20ebef1ff4b3966cdc70aaaff4e15c039","signature":"cf22f07239b95297f178a2923a7365d6af28b83c662e260bb3fbdedf3d307c45"},{"version":"b3e1e73be8ba358efec2729115edf2a7c0f308ce38b342a7d8a9ad7b7107abcb","signature":"83c02a1b42a7b7ef93da510ae4862b6bf48e192ab6d0618fcebe31046acc63c7"},{"version":"bbee77da5c5cfb2ebc761e2ce2e469fa16463e46320d53ca76cd45492421126a","signature":"43e356c655d6c4b3a80cf4f0cb2ebef023e8773f64eb5aa64be9eef19f943025"},{"version":"3b6288366589ac1556cfc0d60acd0c8b06650e908ec9f640181223f67d1bd955","signature":"69f06f09ae7e2e2acde02a9c1a2942669beb515764ca6ac668fef8fea0f5a703"},{"version":"a16bc3541ba6c6be218109a537d294cd32b253e59ab8eec6b870b81129435df8","signature":"45e9729ec1435c3dc81b899ebe72062fe9463da87c99b84a4f2b55265c6f11e1"},{"version":"d2a9a2d07dabef84c723db699b03c85eb1f3d38e98aae207a89220d5b2efd66c","signature":"5c5fb8b6a2397c9e97d463b2663943673dd93ca70f404b12cae89a5065cf0be3"},{"version":"1410dec79e0d5d41d83462a0b08f5da0dad233d772659e5f0aaee7d34cca2228","signature":"34e6a9269593849277a1fdb1867a8410f04e745ccfda48ce639a197bc42f1ffe"},{"version":"10836c6e7e1dca66000ef2ae14f5bbd8b69717a10cb3a50b3c7c2699a51af36f","signature":"b94e9e43e570e6ff75805b98d30ed5aafcc7ee09645d6dccc0dd7d0bc24f19c7"},{"version":"05321b823dd3781d0b6aac8700bfdc0c9181d56479fe52ba6a40c9196fd661a8","impliedFormat":1},{"version":"728a616a43140cffc5c2613336b9eb0454a7fbe96fe1ef9f1283c2df96451b39","signature":"5e27e58e235a551ef56cb47dbd7c7f4686e88886a9cf1d8b6226833f84013fff"},{"version":"a7ca8df4f2931bef2aa4118078584d84a0b16539598eaadf7dce9104dfaa381c","impliedFormat":1},{"version":"72950913f4900b680f44d8cab6dd1ea0311698fc1eefb014eb9cdfc37ac4a734","impliedFormat":1},{"version":"36977c14a7f7bfc8c0426ae4343875689949fb699f3f84ecbe5b300ebf9a2c55","impliedFormat":1},{"version":"59f8dc89b9e724a6a667f52cdf4b90b6816ae6c9842ce176d38fcc973669009e","affectsGlobalScope":true,"impliedFormat":1},{"version":"474eba6689e97bf58edd28c90524e70f4fb11820df66752182a1ad1ff9970bb2","affectsGlobalScope":true,"impliedFormat":1},{"version":"1eef826bc4a19de22155487984e345a34c9cd511dd1170edc7a447cb8231dd4a","affectsGlobalScope":true,"impliedFormat":99},"65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5",{"version":"b97d49c5583ecf4fea8ace80f89b188645259355b3d2a4aa690a6bb110104bcb","signature":"05d914a20e0b0726cc5b0c89f047d9065b9a4ba3dacd2980a23e836fac93d4ff"},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"9855e02d837744303391e5623a531734443a5f8e6e8755e018c41d63ad797db2","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"c906fb15bd2aabc9ed1e3f44eb6a8661199d6c320b3aa196b826121552cb3695","impliedFormat":1},{"version":"22295e8103f1d6d8ea4b5d6211e43421fe4564e34d0dd8e09e520e452d89e659","impliedFormat":1},{"version":"58647d85d0f722a1ce9de50955df60a7489f0593bf1a7015521efe901c06d770","impliedFormat":1},{"version":"73b5fa37db36eeac90c4d752e39586f1b57187400c4f5280fd05f16437287a45","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"a3fc63c0d7b031693f665f5494412ba4b551fe644ededccc0ab5922401079c95","impliedFormat":1},{"version":"80523c00b8544a2000ae0143e4a90a00b47f99823eb7926c1e03c494216fc363","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"746911b62b329587939560deb5c036aca48aece03147b021fa680223255d5183","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"c8d3e5a18ba35629954e48c4cc8f11dc88224650067a172685c736b27a34a4dc","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2b55d426ff2b9087485e52ac4bc7cfafe1dc420fc76dad926cd46526567c501a","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"47613031a5a31510831304405af561b0ffaedb734437c595256bb61a90f9311b","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"8a1a0d0a4a06a8d278947fcb66bf684f117bf147f89b06e50662d79a53be3e9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"9f55299850d4f0921e79b6bf344b47c420ce0f507b9dcf593e532b09ea7eeea1","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1}],"root":[100,101,[106,108],[112,133],135,142,143],"options":{"allowSyntheticDefaultImports":true,"composite":true,"esModuleInterop":true,"jsx":4,"module":99,"outDir":"./","strict":true,"target":99},"referencedMap":[[146,1],[144,2],[88,2],[91,3],[90,4],[89,5],[104,2],[105,2],[149,6],[145,1],[147,7],[148,1],[152,8],[150,2],[153,2],[204,9],[205,9],[206,10],[159,11],[207,12],[208,13],[209,14],[154,2],[157,15],[155,2],[156,2],[210,16],[211,17],[212,18],[213,19],[214,20],[215,21],[216,21],[217,22],[218,23],[219,24],[220,25],[160,2],[158,2],[221,26],[222,27],[223,28],[257,29],[224,30],[225,2],[226,31],[227,32],[228,33],[229,34],[230,35],[231,36],[232,37],[233,38],[234,39],[235,39],[236,40],[237,2],[238,41],[239,42],[241,43],[240,44],[242,45],[243,46],[244,47],[245,48],[246,49],[247,50],[248,51],[249,52],[250,53],[251,54],[252,55],[253,56],[254,57],[161,2],[162,2],[163,2],[201,58],[202,2],[203,2],[255,59],[256,60],[85,2],[134,61],[258,61],[83,2],[86,62],[87,61],[151,2],[84,2],[103,2],[102,2],[99,61],[97,63],[98,64],[96,65],[93,66],[92,67],[95,68],[94,66],[81,2],[82,2],[13,2],[14,2],[16,2],[15,2],[2,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[24,2],[3,2],[25,2],[26,2],[4,2],[27,2],[31,2],[28,2],[29,2],[30,2],[32,2],[33,2],[34,2],[5,2],[35,2],[36,2],[37,2],[38,2],[6,2],[42,2],[39,2],[40,2],[41,2],[43,2],[7,2],[44,2],[49,2],[50,2],[45,2],[46,2],[47,2],[48,2],[8,2],[54,2],[51,2],[52,2],[53,2],[55,2],[9,2],[56,2],[57,2],[58,2],[60,2],[59,2],[61,2],[62,2],[10,2],[63,2],[64,2],[65,2],[11,2],[66,2],[67,2],[68,2],[69,2],[70,2],[1,2],[71,2],[72,2],[12,2],[76,2],[74,2],[79,2],[78,2],[73,2],[77,2],[75,2],[80,2],[179,69],[189,70],[178,69],[199,71],[170,72],[169,73],[198,74],[192,75],[197,76],[172,77],[186,78],[171,79],[195,80],[167,81],[166,74],[196,82],[168,83],[173,84],[174,2],[177,84],[164,2],[200,85],[190,86],[181,87],[182,88],[184,89],[180,90],[183,91],[193,74],[175,92],[176,93],[185,94],[165,95],[188,86],[187,84],[191,2],[194,96],[141,97],[137,98],[136,2],[138,99],[139,2],[140,100],[111,101],[110,102],[109,2],[133,103],[100,104],[124,105],[118,106],[114,107],[126,108],[125,107],[108,109],[101,110],[119,106],[121,111],[113,112],[135,113],[129,114],[115,115],[130,116],[127,117],[132,118],[128,119],[131,120],[122,121],[112,122],[123,123],[116,124],[107,125],[117,126],[120,127],[142,128],[143,127],[106,127]],"latestChangedDtsFile":"./src/renderer/src/utils/ipc.d.ts","version":"5.9.3"} \ No newline at end of file +{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.esnext.error.d.ts","../../node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@types/react/global.d.ts","../../node_modules/csstype/index.d.ts","../../node_modules/@types/prop-types/index.d.ts","../../node_modules/@types/react/index.d.ts","../../node_modules/@types/react/jsx-runtime.d.ts","../../node_modules/@remix-run/router/dist/history.d.ts","../../node_modules/@remix-run/router/dist/utils.d.ts","../../node_modules/@remix-run/router/dist/router.d.ts","../../node_modules/@remix-run/router/dist/index.d.ts","../../node_modules/react-router/dist/lib/context.d.ts","../../node_modules/react-router/dist/lib/components.d.ts","../../node_modules/react-router/dist/lib/hooks.d.ts","../../node_modules/react-router/dist/lib/deprecations.d.ts","../../node_modules/react-router/dist/index.d.ts","../../node_modules/react-router-dom/dist/dom.d.ts","../../node_modules/react-router-dom/dist/index.d.ts","../../node_modules/lucide-react/dist/lucide-react.d.ts","../../src/renderer/src/components/applogo/applogo.tsx","../../src/renderer/src/components/sidebar/sidebar.tsx","../../node_modules/hls.js/dist/hls.d.mts","../../node_modules/dashjs/index.d.ts","../../node_modules/@tauri-apps/api/core.d.ts","../../node_modules/@tauri-apps/api/event.d.ts","../../src/shared/types.ts","../../src/renderer/src/utils/ipc.ts","../../src/renderer/src/components/miniplayer/miniplayer.tsx","../../node_modules/zustand/esm/vanilla.d.mts","../../node_modules/zustand/esm/react.d.mts","../../node_modules/zustand/esm/index.d.mts","../../src/renderer/src/stores/useconfigstore.ts","../../src/renderer/src/components/vodcard/vodcard.tsx","../../src/renderer/src/components/emptystate/emptystate.tsx","../../src/renderer/src/pages/home/home.tsx","../../src/renderer/src/stores/useplayerstore.ts","../../src/renderer/src/utils/media.ts","../../src/renderer/src/components/danmakulayer/danmakulayer.tsx","../../src/renderer/src/components/subtitlelayer/subtitlelayer.tsx","../../src/renderer/src/utils/redact.ts","../../src/renderer/src/utils/playbackmetrics.ts","../../src/renderer/src/components/videoplayer/videoplayer.tsx","../../src/renderer/src/pages/voddetail/voddetail.tsx","../../src/renderer/src/stores/uselivestore.ts","../../src/renderer/src/components/channelitem/channelitem.tsx","../../src/renderer/src/components/livetree/livetree.tsx","../../src/renderer/src/components/liverefreshbar/liverefreshbar.tsx","../../src/renderer/src/pages/live/live.tsx","../../src/renderer/src/pages/search/search.tsx","../../src/renderer/src/pages/history/history.tsx","../../src/renderer/src/pages/keep/keep.tsx","../../src/renderer/src/pages/settings/settings.tsx","../../src/renderer/src/pages/onboarding/onboarding.tsx","../../src/renderer/src/components/alphaplaybacksmoke/alphaplaybacksmoke.tsx","../../src/renderer/src/components/betacontinuesmoke/betacontinuesmoke.tsx","../../src/renderer/src/app.tsx","../../node_modules/@types/react-dom/client.d.ts","../../src/renderer/src/main.tsx","../../node_modules/vite/types/hmrpayload.d.ts","../../node_modules/vite/types/customevent.d.ts","../../node_modules/vite/types/hot.d.ts","../../node_modules/vite/types/importglob.d.ts","../../node_modules/vite/types/importmeta.d.ts","../../node_modules/vite/client.d.ts","../../src/renderer/src/vite-env.d.ts","../../src/shared/errors.ts","../../node_modules/@babel/types/lib/index.d.ts","../../node_modules/@types/babel__generator/index.d.ts","../../node_modules/@babel/parser/typings/babel-parser.d.ts","../../node_modules/@types/babel__template/index.d.ts","../../node_modules/@types/babel__traverse/index.d.ts","../../node_modules/@types/babel__core/index.d.ts","../../node_modules/@types/deep-eql/index.d.ts","../../node_modules/assertion-error/index.d.ts","../../node_modules/@types/chai/index.d.ts","../../node_modules/@types/estree/index.d.ts","../../node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/@types/node/compatibility/index.d.ts","../../node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/@types/node/globals.d.ts","../../node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/@types/node/web-globals/events.d.ts","../../node_modules/undici-types/header.d.ts","../../node_modules/undici-types/readable.d.ts","../../node_modules/undici-types/file.d.ts","../../node_modules/undici-types/fetch.d.ts","../../node_modules/undici-types/formdata.d.ts","../../node_modules/undici-types/connector.d.ts","../../node_modules/undici-types/client.d.ts","../../node_modules/undici-types/errors.d.ts","../../node_modules/undici-types/dispatcher.d.ts","../../node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/undici-types/global-origin.d.ts","../../node_modules/undici-types/pool-stats.d.ts","../../node_modules/undici-types/pool.d.ts","../../node_modules/undici-types/handlers.d.ts","../../node_modules/undici-types/balanced-pool.d.ts","../../node_modules/undici-types/agent.d.ts","../../node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/undici-types/mock-agent.d.ts","../../node_modules/undici-types/mock-client.d.ts","../../node_modules/undici-types/mock-pool.d.ts","../../node_modules/undici-types/mock-errors.d.ts","../../node_modules/undici-types/proxy-agent.d.ts","../../node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/undici-types/retry-handler.d.ts","../../node_modules/undici-types/retry-agent.d.ts","../../node_modules/undici-types/api.d.ts","../../node_modules/undici-types/interceptors.d.ts","../../node_modules/undici-types/util.d.ts","../../node_modules/undici-types/cookies.d.ts","../../node_modules/undici-types/patch.d.ts","../../node_modules/undici-types/websocket.d.ts","../../node_modules/undici-types/eventsource.d.ts","../../node_modules/undici-types/filereader.d.ts","../../node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/undici-types/content-type.d.ts","../../node_modules/undici-types/cache.d.ts","../../node_modules/undici-types/index.d.ts","../../node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/@types/node/web-globals/navigator.d.ts","../../node_modules/@types/node/web-globals/storage.d.ts","../../node_modules/@types/node/assert.d.ts","../../node_modules/@types/node/assert/strict.d.ts","../../node_modules/@types/node/async_hooks.d.ts","../../node_modules/@types/node/buffer.d.ts","../../node_modules/@types/node/child_process.d.ts","../../node_modules/@types/node/cluster.d.ts","../../node_modules/@types/node/console.d.ts","../../node_modules/@types/node/constants.d.ts","../../node_modules/@types/node/crypto.d.ts","../../node_modules/@types/node/dgram.d.ts","../../node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/@types/node/dns.d.ts","../../node_modules/@types/node/dns/promises.d.ts","../../node_modules/@types/node/domain.d.ts","../../node_modules/@types/node/events.d.ts","../../node_modules/@types/node/fs.d.ts","../../node_modules/@types/node/fs/promises.d.ts","../../node_modules/@types/node/http.d.ts","../../node_modules/@types/node/http2.d.ts","../../node_modules/@types/node/https.d.ts","../../node_modules/@types/node/inspector.d.ts","../../node_modules/@types/node/inspector.generated.d.ts","../../node_modules/@types/node/module.d.ts","../../node_modules/@types/node/net.d.ts","../../node_modules/@types/node/os.d.ts","../../node_modules/@types/node/path.d.ts","../../node_modules/@types/node/perf_hooks.d.ts","../../node_modules/@types/node/process.d.ts","../../node_modules/@types/node/punycode.d.ts","../../node_modules/@types/node/querystring.d.ts","../../node_modules/@types/node/readline.d.ts","../../node_modules/@types/node/readline/promises.d.ts","../../node_modules/@types/node/repl.d.ts","../../node_modules/@types/node/sea.d.ts","../../node_modules/@types/node/sqlite.d.ts","../../node_modules/@types/node/stream.d.ts","../../node_modules/@types/node/stream/promises.d.ts","../../node_modules/@types/node/stream/consumers.d.ts","../../node_modules/@types/node/stream/web.d.ts","../../node_modules/@types/node/string_decoder.d.ts","../../node_modules/@types/node/test.d.ts","../../node_modules/@types/node/timers.d.ts","../../node_modules/@types/node/timers/promises.d.ts","../../node_modules/@types/node/tls.d.ts","../../node_modules/@types/node/trace_events.d.ts","../../node_modules/@types/node/tty.d.ts","../../node_modules/@types/node/url.d.ts","../../node_modules/@types/node/util.d.ts","../../node_modules/@types/node/v8.d.ts","../../node_modules/@types/node/vm.d.ts","../../node_modules/@types/node/wasi.d.ts","../../node_modules/@types/node/worker_threads.d.ts","../../node_modules/@types/node/zlib.d.ts","../../node_modules/@types/node/index.d.ts","../../node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[147,162,210,227,228],[162,210,227,228],[88,89,90,162,210,227,228],[88,89,162,210,227,228],[88,162,210,227,228],[147,148,149,150,151,162,210,227,228],[147,149,162,210,227,228],[153,154,162,210,227,228],[162,207,208,210,227,228],[162,209,210,227,228],[210,227,228],[162,210,215,227,228,245],[162,210,211,216,221,227,228,230,242,253],[162,210,211,212,221,227,228,230],[157,158,159,162,210,227,228],[162,210,213,227,228,254],[162,210,214,215,222,227,228,231],[162,210,215,227,228,242,250],[162,210,216,218,221,227,228,230],[162,209,210,217,227,228],[162,210,218,219,227,228],[162,210,220,221,227,228],[162,209,210,221,227,228],[162,210,221,222,223,227,228,242,253],[162,210,221,222,223,227,228,237,242,245],[162,203,210,218,221,224,227,228,230,242,253],[162,210,221,222,224,225,227,228,230,242,250,253],[162,210,224,226,227,228,242,250,253],[160,161,162,163,164,165,166,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259],[162,210,221,227,228],[162,210,227,228,229,253],[162,210,218,221,227,228,230,242],[162,210,227,228,231],[162,210,227,228,232],[162,209,210,227,228,233],[162,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259],[162,210,227,228,235],[162,210,227,228,236],[162,210,221,227,228,237,238],[162,210,227,228,237,239,254,256],[162,210,222,227,228],[162,210,221,227,228,242,243,245],[162,210,227,228,244,245],[162,210,227,228,242,243],[162,210,227,228,245],[162,210,227,228,246],[162,207,210,227,228,242,247,253],[162,210,221,227,228,248,249],[162,210,227,228,248,249],[162,210,215,227,228,230,242,250],[162,210,227,228,251],[162,210,227,228,230,252],[162,210,224,227,228,236,253],[162,210,215,227,228,254],[162,210,227,228,242,255],[162,210,227,228,229,256],[162,210,227,228,257],[162,203,210,227,228],[162,203,210,221,223,227,228,233,242,245,253,255,256,258],[162,210,227,228,242,259],[86,162,210,227,228],[83,84,85,162,210,227,228],[91,162,210,227,228],[86,91,96,97,162,210,227,228],[91,92,93,94,95,162,210,227,228],[86,91,92,162,210,227,228],[86,91,162,210,227,228],[91,93,162,210,227,228],[162,175,179,210,227,228,253],[162,175,210,227,228,242,253],[162,170,210,227,228],[162,172,175,210,227,228,250,253],[162,210,227,228,230,250],[162,210,227,228,260],[162,170,210,227,228,260],[162,172,175,210,227,228,230,253],[162,167,168,171,174,210,221,227,228,242,253],[162,175,182,210,227,228],[162,167,173,210,227,228],[162,175,196,197,210,227,228],[162,171,175,210,227,228,245,253,260],[162,196,210,227,228,260],[162,169,170,210,227,228,260],[162,175,210,227,228],[162,169,170,171,172,173,174,175,176,177,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,197,198,199,200,201,202,210,227,228],[162,175,190,210,227,228],[162,175,182,183,210,227,228],[162,173,175,183,184,210,227,228],[162,174,210,227,228],[162,167,170,175,210,227,228],[162,175,179,183,184,210,227,228],[162,179,210,227,228],[162,173,175,178,210,227,228,253],[162,167,172,175,182,210,227,228],[162,210,227,228,242],[162,170,175,196,210,227,228,258,260],[143,162,210,227,228],[139,162,210,227,228],[140,162,210,227,228],[141,142,162,210,227,228],[109,110,162,210,227,228],[109,162,210,227,228],[86,87,98,101,107,108,112,115,123,128,129,130,131,132,133,134,135,162,210,227,228],[86,87,107,116,117,121,122,162,210,227,228],[87,144,162,210,227,228],[86,87,106,107,162,210,227,228],[87,124,162,210,227,228],[86,87,162,210,227,228],[86,87,99,162,210,227,228],[87,99,162,210,227,228],[86,87,99,102,103,107,162,210,227,228],[86,87,98,99,100,162,210,227,228],[86,87,99,102,103,107,116,118,119,120,121,162,210,227,228],[87,112,162,210,227,228],[86,87,98,136,137,144,162,210,227,228],[86,87,98,99,106,107,162,210,227,228],[86,87,98,99,106,107,112,113,114,162,210,227,228],[86,87,98,99,106,107,112,113,162,210,227,228],[86,87,98,99,107,112,114,116,117,122,124,125,126,127,162,210,227,228],[86,87,98,99,100,106,107,112,162,210,227,228],[86,87,98,99,107,112,113,114,162,210,227,228],[86,87,98,99,106,107,112,162,210,227,228],[86,87,98,99,106,107,112,116,117,122,162,210,227,228],[87,107,111,162,210,227,228],[87,106,107,111,162,210,227,228],[87,111,162,210,227,228],[87,104,105,106,162,210,227,228],[87,107,162,210,227,228],[87,162,210,227,228],[144,162,210,227,228]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"bcfe74f2b819ae25094c2f5e474971691e41867bdb409851897682bbe53e398d","affectsGlobalScope":true,"impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"5af7c35a9c5c4760fd084fedb6ba4c7059ac9598b410093e5312ac10616bf17b","impliedFormat":1},{"version":"503cdeeaeca0f08ff204ea818f2b3fe711b5e98ac574b6cded51495c943f2c34","impliedFormat":1},{"version":"3c8c1edb7ed8a842cb14d9f2ba6863183168a9fc8d6aa15dec221ebf8b946393","impliedFormat":1},{"version":"0a6e1a3f199d6cc3df0410b4df05a914987b1e152c4beacfd0c5142e15302cac","impliedFormat":1},{"version":"ec3c1376b4f34b271b1815674546fe09a2f449b0773afd381bbce7eb2f96a731","impliedFormat":1},{"version":"c2a262a3157e868d279327daf428dd629bd485f825800c8e62b001e6c879aff6","impliedFormat":1},{"version":"f7e84f314f9276b44b707289446303db8ef34a6c2c6d6b08d03a76e168367072","impliedFormat":1},{"version":"2b493706eb7879d42a8e8009379292b59827d612ab3a275bc476f87e60259c79","impliedFormat":1},{"version":"5542628b04d7bd4e0cd4871b6791b3b936a917ac6b819dcd20487f040acb01f1","impliedFormat":1},{"version":"f07b335f87cfac999fc1da00dfbc616837ced55be67bcaa41524d475b7394554","impliedFormat":1},{"version":"938326adb4731184e14e17fc57fca2a3b69c337ef8d6c00c65d73472fe8feca4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5f0258de817857a01db5d1ab9aed63c4e88af54b62829fd4777c4325fa8ab2ef","impliedFormat":1},{"version":"d8d8d1bc5f2164126edffc39993517ea2d26a640cf39d6e7c5446d79856e7e50","signature":"2116701713791a859eb181d77a66d9e78b6818132ff2a3f40d3d1df65e3f4923"},{"version":"83a9cd4455bc32eb6d33349a3bd22c67d77e98710457dee315269d7c96d7caa8","signature":"6a20dbddfd986b0589ed4c8176bdfbf754a4627e23cb742287a363451dd4dd73"},{"version":"1c97ee9a298f6c15c3e637015ee8ce3020a07329105d430d4ec45a5b6cecbb3c","impliedFormat":99},{"version":"702cc0c98bf4587edbdae3d2046581b23fb6258f4b20170b4c550c0badaf0735","impliedFormat":1},{"version":"db60ed38e38bea3fabc38769e626552f9fe9e2c0c7a4e40e7ead930b46840910","impliedFormat":99},{"version":"0d738dff2ba5e25de6ef3fe078235a1f32f4c93438f50c4146cac00c9c0e7456","affectsGlobalScope":true,"impliedFormat":99},{"version":"8c44875096f6ead32acc17119a43d5fedc1092af10f42f07f7994de76e5d3056","signature":"359e7bafe2dce5d3529d24c193bbe3da210f003bf04c24d37e102911632aaf47"},{"version":"a49a168d837079b88b9e78c171acac642477533c5704c79a70f693fd207f78f5","signature":"6dac481510937211dd6a5a20c396ef4e0d10e750e250dd28c2fdbe8bd9e3683f"},{"version":"f3b8be6211ca2db8910264263a9a1b64df397ea50e1552792ddebbd6dcfa71cd","signature":"2893ef918249432b0d2130b9e3ab3784ab3ce74b3af7ce13e9605d643d4b7381"},{"version":"4d7d964609a07368d076ce943b07106c5ebee8138c307d3273ba1cf3a0c3c751","impliedFormat":99},{"version":"0e48c1354203ba2ca366b62a0f22fec9e10c251d9d6420c6d435da1d079e6126","impliedFormat":99},{"version":"0662a451f0584bb3026340c3661c3a89774182976cd373eca502a1d3b5c7b580","impliedFormat":99},{"version":"b4472a21038a0d9998988686c00f1e2d1de76058e2659d378bfddb85e59ef1bd","signature":"5ddcbcdd92cd7e321cdcd701d8262a9876ecd3ef89faf7fd0e51836d8d648b0a"},{"version":"c73bd72a6d9d02b63ccc57a41bfd3ce7b88dfece6138a598d8187e66033fae57","signature":"ebe2c15f92102f938beb6a5d136b609eb29b98d235071fc3bda522c6782273df"},{"version":"3446cf96e65ca09a7be4886d850527b16c68681cec9adc3a256cd6ff4a06892f","signature":"027cb8856abc015753acbc20e3dbe12d9bbe6f05f8b47fc9d34e0d90b70274ff"},{"version":"d531c5729502fe101fef9a7a59b51dd98452d064edb04067586763d00f97c2e5","signature":"3eb972ae325aa293fdb6077cdf956f209ee6ea34b4e874ff7ec8686b3079972f"},{"version":"2af60a145d2d9b1a187830cca3334ed1bfc03bbaba08fe2503eae1c44aa22f56","signature":"5d636c32a08adb68f41023b5fc8480639bf9e2bca3e4a3904caf88cdc9c94167"},{"version":"013eaa1dcf568f4ef7eb96a26957facb66208fa9be33c4c1ebf9546e1b09b508","signature":"fe1f679caec8c196aa2197656ad2a6d446db605db24ed199d32a639f1573b732"},{"version":"40fc2bfa95c1556bffe704bd71f67f8104285e2e8757a9ebc41b58172d92be6f","signature":"9dc42250606565574ff85f6f1b69c7afe5774086f69bff011b06bac888b705c2"},{"version":"4e66e21b0e01c8540725f8cc3cd2c6707ffa2074c2191784782a856268a67af7","signature":"37b67aabddfbcf28e52398404b278e3353dbdb504bbb5b2e978700e85118e3b8"},{"version":"aa3df903722043c96cd208122a53802ec5314b5adc7d3f7f81fe021426398737","signature":"ef8ddc9716f5fe50c9191a97201465490f75448cf0e225b536dcc78e4993b3df"},{"version":"144dcc52de4f8daaee26b1b287e387558b1f961591baa4772f906fbb34a61e04","signature":"3712889fc7949afbf53235be4334ab4517b1921873f2449876d6927f0857a878"},{"version":"860b18b7a4f21e2f71ca7f208a9423e4fbac6ae54e4111cae393ae9f156bfada","signature":"e443951643883fc3f1fa06e85d8f9f98e1852c0e92d48f9fe5044c068bc2f7ab","affectsGlobalScope":true},{"version":"ff152dff1eec84492a64106440f619e4dec156c8eba350ff22fb5849a98abb8e","signature":"18a2155772d1b8b5dbf2e644c2f4dad3d466a19b22884b4517fa27c64006da42"},{"version":"34104a5b66161181db9d2c1cf11bce9163c8e09b0111a7d537701b1646b70c72","signature":"1f3463f0970a8b2ba6abe72da5aa50e980523a0ae9545b5ee27ea85ca1556b49"},{"version":"0ca2e03e7bf61fc604cbf72615ef352b13b4167f2617b1a0a448db72ef530d61","signature":"405b5b4593e64948f5ccccf462cdeade2bfdfdb433176238856ea1b73a2e0acc"},{"version":"41b6d9f33a5099c6382ef959492255cd150bac8ad0036f29b34eb88f456b24f3","signature":"bc0a9b9763f2961081a33aaf39889745c64c2d1f005dace8425628b1523fade9"},{"version":"2733bbafb1e6d2fe8c681bb45a53ebe20ebef1ff4b3966cdc70aaaff4e15c039","signature":"cf22f07239b95297f178a2923a7365d6af28b83c662e260bb3fbdedf3d307c45"},{"version":"200cc0aa693a4eac432efaab9b78560a2897503134cfbc48ab5d0e502741bff2","signature":"83c02a1b42a7b7ef93da510ae4862b6bf48e192ab6d0618fcebe31046acc63c7"},{"version":"bbee77da5c5cfb2ebc761e2ce2e469fa16463e46320d53ca76cd45492421126a","signature":"43e356c655d6c4b3a80cf4f0cb2ebef023e8773f64eb5aa64be9eef19f943025"},{"version":"035e7898897016c6a2b7f4e31d9a6a1eb22d35b78829edb23bba85279028058c","signature":"69f06f09ae7e2e2acde02a9c1a2942669beb515764ca6ac668fef8fea0f5a703"},{"version":"a16bc3541ba6c6be218109a537d294cd32b253e59ab8eec6b870b81129435df8","signature":"45e9729ec1435c3dc81b899ebe72062fe9463da87c99b84a4f2b55265c6f11e1"},{"version":"8699d38f1b6bb540f793fb87d837413c438d8f75c3a4c49d310bc004a24d4b40","signature":"5c5fb8b6a2397c9e97d463b2663943673dd93ca70f404b12cae89a5065cf0be3"},{"version":"2f2e45158d0838068dafb67c7294e7447d5acc86d39b2df71b65a0f62c5a1554","signature":"34e6a9269593849277a1fdb1867a8410f04e745ccfda48ce639a197bc42f1ffe"},{"version":"f59e81f4479855d33eef44033b5e80d22b42a72ac9cd3952d717f4c9d6561778","signature":"ada4ba9cc18e2f43f7dfc93651ce8e789e3392858ed2e7993ce6a50556e2af34"},{"version":"96a1c05ee2122bb8f744b9cce6979ab96f75936dd5ece66fa6ca9b8f1fb66958","signature":"bb9de516f6ab8dc4d7ae1798679f72e29cfa8915fb266f84617c578141c92bce"},{"version":"c12362fa34646a1ba80d2828a1af7250c9d4d73f801b617719786c48d5ea2f51","signature":"b94e9e43e570e6ff75805b98d30ed5aafcc7ee09645d6dccc0dd7d0bc24f19c7"},{"version":"05321b823dd3781d0b6aac8700bfdc0c9181d56479fe52ba6a40c9196fd661a8","impliedFormat":1},{"version":"728a616a43140cffc5c2613336b9eb0454a7fbe96fe1ef9f1283c2df96451b39","signature":"5e27e58e235a551ef56cb47dbd7c7f4686e88886a9cf1d8b6226833f84013fff"},{"version":"a7ca8df4f2931bef2aa4118078584d84a0b16539598eaadf7dce9104dfaa381c","impliedFormat":1},{"version":"72950913f4900b680f44d8cab6dd1ea0311698fc1eefb014eb9cdfc37ac4a734","impliedFormat":1},{"version":"36977c14a7f7bfc8c0426ae4343875689949fb699f3f84ecbe5b300ebf9a2c55","impliedFormat":1},{"version":"59f8dc89b9e724a6a667f52cdf4b90b6816ae6c9842ce176d38fcc973669009e","affectsGlobalScope":true,"impliedFormat":1},{"version":"474eba6689e97bf58edd28c90524e70f4fb11820df66752182a1ad1ff9970bb2","affectsGlobalScope":true,"impliedFormat":1},{"version":"1eef826bc4a19de22155487984e345a34c9cd511dd1170edc7a447cb8231dd4a","affectsGlobalScope":true,"impliedFormat":99},"65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5",{"version":"b97d49c5583ecf4fea8ace80f89b188645259355b3d2a4aa690a6bb110104bcb","signature":"05d914a20e0b0726cc5b0c89f047d9065b9a4ba3dacd2980a23e836fac93d4ff"},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"9855e02d837744303391e5623a531734443a5f8e6e8755e018c41d63ad797db2","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"c906fb15bd2aabc9ed1e3f44eb6a8661199d6c320b3aa196b826121552cb3695","impliedFormat":1},{"version":"22295e8103f1d6d8ea4b5d6211e43421fe4564e34d0dd8e09e520e452d89e659","impliedFormat":1},{"version":"58647d85d0f722a1ce9de50955df60a7489f0593bf1a7015521efe901c06d770","impliedFormat":1},{"version":"73b5fa37db36eeac90c4d752e39586f1b57187400c4f5280fd05f16437287a45","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"a3fc63c0d7b031693f665f5494412ba4b551fe644ededccc0ab5922401079c95","impliedFormat":1},{"version":"80523c00b8544a2000ae0143e4a90a00b47f99823eb7926c1e03c494216fc363","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"746911b62b329587939560deb5c036aca48aece03147b021fa680223255d5183","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"c8d3e5a18ba35629954e48c4cc8f11dc88224650067a172685c736b27a34a4dc","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2b55d426ff2b9087485e52ac4bc7cfafe1dc420fc76dad926cd46526567c501a","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"47613031a5a31510831304405af561b0ffaedb734437c595256bb61a90f9311b","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"8a1a0d0a4a06a8d278947fcb66bf684f117bf147f89b06e50662d79a53be3e9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"9f55299850d4f0921e79b6bf344b47c420ce0f507b9dcf593e532b09ea7eeea1","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1}],"root":[100,101,[106,108],[112,136],138,145,146],"options":{"allowSyntheticDefaultImports":true,"composite":true,"esModuleInterop":true,"jsx":4,"module":99,"outDir":"./","strict":true,"target":99},"referencedMap":[[149,1],[147,2],[88,2],[91,3],[90,4],[89,5],[104,2],[105,2],[152,6],[148,1],[150,7],[151,1],[155,8],[153,2],[156,2],[207,9],[208,9],[209,10],[162,11],[210,12],[211,13],[212,14],[157,2],[160,15],[158,2],[159,2],[213,16],[214,17],[215,18],[216,19],[217,20],[218,21],[219,21],[220,22],[221,23],[222,24],[223,25],[163,2],[161,2],[224,26],[225,27],[226,28],[260,29],[227,30],[228,2],[229,31],[230,32],[231,33],[232,34],[233,35],[234,36],[235,37],[236,38],[237,39],[238,39],[239,40],[240,2],[241,41],[242,42],[244,43],[243,44],[245,45],[246,46],[247,47],[248,48],[249,49],[250,50],[251,51],[252,52],[253,53],[254,54],[255,55],[256,56],[257,57],[164,2],[165,2],[166,2],[204,58],[205,2],[206,2],[258,59],[259,60],[85,2],[137,61],[261,61],[83,2],[86,62],[87,61],[154,2],[84,2],[103,2],[102,2],[99,61],[97,63],[98,64],[96,65],[93,66],[92,67],[95,68],[94,66],[81,2],[82,2],[13,2],[14,2],[16,2],[15,2],[2,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[24,2],[3,2],[25,2],[26,2],[4,2],[27,2],[31,2],[28,2],[29,2],[30,2],[32,2],[33,2],[34,2],[5,2],[35,2],[36,2],[37,2],[38,2],[6,2],[42,2],[39,2],[40,2],[41,2],[43,2],[7,2],[44,2],[49,2],[50,2],[45,2],[46,2],[47,2],[48,2],[8,2],[54,2],[51,2],[52,2],[53,2],[55,2],[9,2],[56,2],[57,2],[58,2],[60,2],[59,2],[61,2],[62,2],[10,2],[63,2],[64,2],[65,2],[11,2],[66,2],[67,2],[68,2],[69,2],[70,2],[1,2],[71,2],[72,2],[12,2],[76,2],[74,2],[79,2],[78,2],[73,2],[77,2],[75,2],[80,2],[182,69],[192,70],[181,69],[202,71],[173,72],[172,73],[201,74],[195,75],[200,76],[175,77],[189,78],[174,79],[198,80],[170,81],[169,74],[199,82],[171,83],[176,84],[177,2],[180,84],[167,2],[203,85],[193,86],[184,87],[185,88],[187,89],[183,90],[186,91],[196,74],[178,92],[179,93],[188,94],[168,95],[191,86],[190,84],[194,2],[197,96],[144,97],[140,98],[139,2],[141,99],[142,2],[143,100],[111,101],[110,102],[109,2],[136,103],[134,104],[100,105],[135,106],[125,107],[118,108],[114,109],[127,110],[126,109],[108,111],[101,112],[119,108],[122,113],[113,114],[138,115],[130,116],[115,117],[131,118],[128,119],[133,120],[129,121],[132,122],[123,123],[112,124],[124,125],[116,126],[107,127],[117,128],[121,129],[120,129],[145,130],[146,129],[106,129]],"latestChangedDtsFile":"./src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.d.ts","version":"5.9.3"} \ No newline at end of file diff --git a/package.json b/package.json index 4edc1a3..d7bd738 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "typecheck": "tsc -b", "test": "vitest run", "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml", + "smoke:alpha-playback-ui": "node scripts/alpha-playback-smoke.mjs", + "smoke:beta-continue": "node scripts/beta-continue-smoke.mjs", "check:tauri": "npm run check", "check": "npm run typecheck && npm test && npm run test:rust && npm run build:web && npm run build:check", "check:release": "npm run check && npm audit --omit=dev --registry=https://registry.npmjs.org && npm run build" diff --git a/scripts/alpha-playback-smoke.mjs b/scripts/alpha-playback-smoke.mjs new file mode 100644 index 0000000..f36ef0b --- /dev/null +++ b/scripts/alpha-playback-smoke.mjs @@ -0,0 +1,137 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const RESULT_KEY = '__alphaPlaybackSmokeResult' +const RUNS = Number.parseInt(process.env.IPTV_ALPHA_PLAYBACK_SMOKE_RUNS || '1', 10) +const SMOKE_TIMEOUT_MS = Number.parseInt(process.env.IPTV_ALPHA_PLAYBACK_SMOKE_TIMEOUT_MS || '20000', 10) +const MAX_FIRST_FRAME_MS = Number.parseInt(process.env.IPTV_ALPHA_PLAYBACK_SMOKE_MAX_FIRST_FRAME_MS || '10000', 10) +const PROCESS_TIMEOUT_MS = Math.max(SMOKE_TIMEOUT_MS + 90000, 120000) +const EXPECT_FAILURE = process.env.IPTV_ALPHA_PLAYBACK_SMOKE_EXPECT_FAILURE === '1' + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function percentile(values, ratio) { + if (values.length === 0) return null + const sorted = [...values].sort((a, b) => a - b) + const index = Math.round((sorted.length - 1) * ratio) + return sorted[index] ?? null +} + +function stopProcess(child) { + if (!child.pid || child.exitCode != null) return + try { + process.kill(-child.pid, 'SIGTERM') + } catch { + try { + child.kill('SIGTERM') + } catch {} + } +} + +async function waitForResult(settingsPath, child, outputLines) { + const startedAt = Date.now() + while (Date.now() - startedAt < PROCESS_TIMEOUT_MS) { + if (child.exitCode != null) { + throw new Error(`Tauri exited before smoke result, code=${child.exitCode}`) + } + + try { + const raw = await readFile(settingsPath, 'utf8') + const settings = JSON.parse(raw) + if (settings && typeof settings === 'object' && settings[RESULT_KEY]) { + return settings[RESULT_KEY] + } + } catch { + // The app may not have created settings.json yet. + } + + await sleep(500) + } + + const tail = outputLines.slice(-80).join('\n') + throw new Error(`Timed out waiting for smoke result after ${PROCESS_TIMEOUT_MS}ms\n${tail}`) +} + +async function runOnce(index) { + const dataDir = await mkdtemp(join(tmpdir(), `iptv-alpha-playback-smoke-${index}-`)) + const settingsPath = join(dataDir, 'settings.json') + const outputLines = [] + const child = spawn('npm', ['run', 'dev:tauri'], { + cwd: process.cwd(), + detached: true, + env: { + ...process.env, + IPTV_TEST_DATA_DIR: dataDir, + IPTV_ALPHA_PLAYBACK_SMOKE: '1', + IPTV_ALPHA_PLAYBACK_SMOKE_TIMEOUT_MS: String(SMOKE_TIMEOUT_MS) + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + const collectOutput = (chunk) => { + const text = chunk.toString() + process.stdout.write(text) + for (const line of text.split(/\r?\n/)) { + if (line.trim()) outputLines.push(line) + } + if (outputLines.length > 300) outputLines.splice(0, outputLines.length - 300) + } + + child.stdout.on('data', collectOutput) + child.stderr.on('data', collectOutput) + + try { + const result = await waitForResult(settingsPath, child, outputLines) + stopProcess(child) + await sleep(1000) + await rm(dataDir, { recursive: true, force: true }) + return result + } catch (error) { + stopProcess(child) + await sleep(1000) + throw error + } +} + +const results = [] +for (let index = 1; index <= RUNS; index += 1) { + console.log(`\n=== Alpha playback smoke run ${index}/${RUNS} ===`) + const result = await runOnce(index) + console.log(`SMOKE_RESULT ${JSON.stringify(result)}`) + results.push(result) +} + +const elapsedSamples = results + .filter((result) => result.ok && typeof result.elapsedMs === 'number') + .map((result) => result.elapsedMs) +const failed = results.filter((result) => !result.ok) +const p50 = percentile(elapsedSamples, 0.5) +const p90 = percentile(elapsedSamples, 0.9) + +console.log('\n=== Alpha playback smoke summary ===') +console.log(`runs=${results.length} passed=${results.length - failed.length} failed=${failed.length}`) +console.log(`first_frame_ms p50=${p50 ?? '-'} p90=${p90 ?? '-'}`) + +if (EXPECT_FAILURE) { + const failuresWithDiagnostics = failed.filter((result) => result.diagnostic) + if (failuresWithDiagnostics.length !== results.length) { + console.error('Smoke failed: expected every run to fail with diagnostics') + process.exit(1) + } + console.log('expected_failure=true diagnostics=present') + process.exit(0) +} + +if (failed.length > 0) { + console.error(`Smoke failed: ${failed[0].message}`) + process.exit(1) +} + +if (p90 == null || p90 > MAX_FIRST_FRAME_MS) { + console.error(`Smoke failed: p90 ${p90 ?? '-'}ms exceeds ${MAX_FIRST_FRAME_MS}ms`) + process.exit(1) +} diff --git a/scripts/beta-continue-smoke.mjs b/scripts/beta-continue-smoke.mjs new file mode 100644 index 0000000..ae6e92f --- /dev/null +++ b/scripts/beta-continue-smoke.mjs @@ -0,0 +1,112 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const RESULT_KEY = '__betaContinueSmokeResult' +const POSITION_SECONDS = Number.parseInt(process.env.IPTV_BETA_CONTINUE_SMOKE_POSITION_SECONDS || '372', 10) +const TOLERANCE_SECONDS = Number.parseInt(process.env.IPTV_BETA_CONTINUE_SMOKE_TOLERANCE_SECONDS || '20', 10) +const PROCESS_TIMEOUT_MS = Number.parseInt(process.env.IPTV_BETA_CONTINUE_SMOKE_PROCESS_TIMEOUT_MS || '120000', 10) + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function stopProcess(child, signal = 'SIGTERM') { + if (!child.pid || child.exitCode != null) return + try { + process.kill(-child.pid, signal) + } catch { + try { + child.kill(signal) + } catch {} + } +} + +async function waitForResult(settingsPath, child, outputLines, phase) { + const startedAt = Date.now() + while (Date.now() - startedAt < PROCESS_TIMEOUT_MS) { + if (child.exitCode != null) { + throw new Error(`Tauri exited before ${phase} smoke result, code=${child.exitCode}`) + } + + try { + const raw = await readFile(settingsPath, 'utf8') + const settings = JSON.parse(raw) + const result = settings?.[RESULT_KEY] + if (result?.phase === phase) return result + } catch { + // The app may not have created settings.json yet. + } + + await sleep(500) + } + + const tail = outputLines.slice(-80).join('\n') + throw new Error(`Timed out waiting for ${phase} smoke result after ${PROCESS_TIMEOUT_MS}ms\n${tail}`) +} + +async function runPhase(dataDir, phase, killSignal) { + const settingsPath = join(dataDir, 'settings.json') + const outputLines = [] + const child = spawn('npm', ['run', 'dev:tauri'], { + cwd: process.cwd(), + detached: true, + env: { + ...process.env, + IPTV_TEST_DATA_DIR: dataDir, + IPTV_BETA_CONTINUE_SMOKE: '1', + IPTV_BETA_CONTINUE_SMOKE_PHASE: phase, + IPTV_BETA_CONTINUE_SMOKE_POSITION_SECONDS: String(POSITION_SECONDS), + IPTV_BETA_CONTINUE_SMOKE_TOLERANCE_SECONDS: String(TOLERANCE_SECONDS) + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + const collectOutput = (chunk) => { + const text = chunk.toString() + process.stdout.write(text) + for (const line of text.split(/\r?\n/)) { + if (line.trim()) outputLines.push(line) + } + if (outputLines.length > 300) outputLines.splice(0, outputLines.length - 300) + } + + child.stdout.on('data', collectOutput) + child.stderr.on('data', collectOutput) + + try { + const result = await waitForResult(settingsPath, child, outputLines, phase) + stopProcess(child, killSignal) + await sleep(1000) + return result + } catch (error) { + stopProcess(child, 'SIGTERM') + await sleep(1000) + throw error + } +} + +const dataDir = await mkdtemp(join(tmpdir(), 'iptv-beta-continue-smoke-')) +try { + console.log('\n=== Beta continue smoke seed ===') + const seed = await runPhase(dataDir, 'seed', 'SIGKILL') + console.log(`SMOKE_SEED ${JSON.stringify(seed)}`) + if (!seed.ok) { + console.error(`Smoke failed during seed: ${seed.message}`) + process.exit(1) + } + + console.log('\n=== Beta continue smoke validate ===') + const validate = await runPhase(dataDir, 'validate', 'SIGTERM') + console.log(`SMOKE_VALIDATE ${JSON.stringify(validate)}`) + if (!validate.ok) { + console.error(`Smoke failed during validate: ${validate.message}`) + process.exit(1) + } + + console.log('\n=== Beta continue smoke summary ===') + console.log(`expected_position=${POSITION_SECONDS}s actual_position=${validate.actualPositionSeconds}s tolerance=${TOLERANCE_SECONDS}s`) +} finally { + await rm(dataDir, { recursive: true, force: true }) +} diff --git a/src-tauri/examples/alpha_2_1_playback_probe.rs b/src-tauri/examples/alpha_2_1_playback_probe.rs new file mode 100644 index 0000000..3a639a7 --- /dev/null +++ b/src-tauri/examples/alpha_2_1_playback_probe.rs @@ -0,0 +1,552 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::time::{Duration, Instant}; + +use reqwest::header::{CONTENT_TYPE, RANGE}; +use serde::Deserialize; +use serde_json::Value; + +#[path = "../src/config.rs"] +mod config; +#[path = "../src/error.rs"] +mod error; +#[path = "../src/live.rs"] +mod live; +#[path = "../src/network.rs"] +mod network; +#[path = "../src/spider.rs"] +mod spider; +#[path = "../src/super_parse.rs"] +mod super_parse; + +const TARGET_SAMPLES: usize = 10; +const MIN_SUCCESS_RATE: f64 = 0.85; +const MAX_CONFIGS: usize = 12; +const MAX_SITES_PER_CONFIG: usize = 12; +const MAX_HOME_ITEMS_PER_SITE: usize = 16; +const MAX_EPISODES_PER_VOD: usize = 1; +const MAX_SAMPLES_PER_CONFIG: usize = 10; +const MAX_SAMPLES_PER_SITE: usize = 10; + +const DEFAULT_VOD_SOURCES: &[(&str, &str)] = &[ + ( + "开心点播 · 如意采集", + "https://700sjro44343.vicp.fun/vip/vip/tv.json", + ), + ("饭太硬", "https://qist.wyfc.qzz.io/fty.json"), + ("潇洒", "https://qist.wyfc.qzz.io/xiaosa/api.json"), + ("肥猫影视", "http://fmys.top/fmys.json"), + ("47.96 API", "http://47.96.82.41:5188/api.json"), + ("124.223 API", "http://124.223.214.31:8/api.json"), + ("203511", "https://tv.203511.xyz/0821.json"), +]; + +#[derive(Debug, Deserialize)] +struct ConfigStore { + #[serde(default)] + configs: Vec, +} + +#[derive(Debug, Deserialize)] +struct StoredConfig { + url: String, + #[serde(default)] + name: String, +} + +#[derive(Debug)] +struct ProbeSample { + source_name: String, + config_url: String, + site_key: String, + site_name: String, + vod_id: String, + vod_name: String, + flag: String, + episode_url: String, +} + +#[derive(Debug)] +struct ProbeResult { + sample: ProbeSample, + resolved_from: String, + resolved_url: String, + ok: bool, + elapsed_ms: u128, + error: Option, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = network::create_client()?; + let sources = load_sources(); + let mut samples = Vec::new(); + let mut seen = HashSet::new(); + + println!("Alpha 2.1 playback probe"); + println!("target_samples={TARGET_SAMPLES} min_success_rate={MIN_SUCCESS_RATE}"); + + for (source_name, config_url) in sources.into_iter().take(MAX_CONFIGS) { + if samples.len() >= TARGET_SAMPLES { + break; + } + + println!("\n=== source: {source_name} ==="); + println!("config: {config_url}"); + let (effective_url, config_value) = match load_config(&client, &config_url).await { + Ok(value) => value, + Err(error) => { + println!("CONFIG FAIL: {error}"); + continue; + } + }; + + let sites = config_value + .get("sites") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let parses = config_value + .get("parses") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + println!( + "CONFIG OK: effective={} sites={} parses={}", + effective_url, + sites.len(), + parses.len() + ); + + collect_samples_from_config( + &source_name, + &effective_url, + &sites, + &mut samples, + &mut seen, + ) + .await; + } + + if samples.len() < TARGET_SAMPLES { + println!( + "\nRESULT: FAIL - only collected {} VOD samples, need {}", + samples.len(), + TARGET_SAMPLES + ); + return Err("not enough VOD samples".into()); + } + + println!("\n========== SAMPLE REQUESTS =========="); + let mut results = Vec::new(); + for sample in samples.into_iter().take(TARGET_SAMPLES) { + let result = probe_sample(&client, sample).await; + print_result(&result); + results.push(result); + } + + let success = results.iter().filter(|result| result.ok).count(); + let total = results.len(); + let success_rate = success as f64 / total as f64; + let mut elapsed: Vec = results + .iter() + .filter(|result| result.ok) + .map(|result| result.elapsed_ms) + .collect(); + elapsed.sort_unstable(); + let p50 = percentile(&elapsed, 0.50); + let p90 = percentile(&elapsed, 0.90); + + println!("\n========== ALPHA 2.1 PLAYBACK PROBE RESULTS =========="); + println!( + "samples: total={total} success={success} failed={}", + total - success + ); + println!("playback_request_success_rate={:.0}%", success_rate * 100.0); + println!( + "request_elapsed_ms: p50={} p90={}", + display_opt(p50), + display_opt(p90) + ); + + if success_rate < MIN_SUCCESS_RATE { + println!( + "RESULT: FAIL - success rate {:.0}% below {:.0}%", + success_rate * 100.0, + MIN_SUCCESS_RATE * 100.0 + ); + return Err("playback probe failed".into()); + } + + println!("RESULT: PASS"); + Ok(()) +} + +fn load_sources() -> Vec<(String, String)> { + let mut sources = Vec::new(); + for (name, url) in DEFAULT_VOD_SOURCES { + sources.push(((*name).to_string(), (*url).to_string())); + } + + if let Ok(text) = fs::read_to_string("data/config-store.json") { + if let Ok(store) = serde_json::from_str::(&text) { + for item in store.configs { + if !item.url.trim().is_empty() + && !sources.iter().any(|(_, existing)| existing == &item.url) + { + let name = if item.name.trim().is_empty() { + "saved".to_string() + } else { + item.name + }; + sources.push((name, item.url)); + } + } + } + } + sources +} + +async fn load_config( + client: &reqwest::Client, + url: &str, +) -> Result<(String, Value), error::AppError> { + let text = network::http_get(client, url).await?; + match config::parse_config_or_live_source(url, &text) { + Ok(value) => Ok((url.to_string(), value)), + Err(error) => { + if let Some(fallback_url) = config::github_raw_fallback_url(url) { + let fallback_text = network::http_get(client, &fallback_url).await?; + let value = config::parse_config_or_live_source(&fallback_url, &fallback_text)?; + Ok((fallback_url, value)) + } else { + Err(error) + } + } + } +} + +async fn collect_samples_from_config( + source_name: &str, + config_url: &str, + sites: &[Value], + samples: &mut Vec, + seen: &mut HashSet, +) { + let config_start_len = samples.len(); + for site in sites + .iter() + .filter(|site| is_supported_site(site)) + .take(MAX_SITES_PER_CONFIG) + { + if samples.len() >= TARGET_SAMPLES + || samples.len().saturating_sub(config_start_len) >= MAX_SAMPLES_PER_CONFIG + { + break; + } + + let Some(spider) = spider_from_site(config_url, site) else { + continue; + }; + let site_key = site.get("key").and_then(Value::as_str).unwrap_or_default(); + let site_name = site + .get("name") + .and_then(Value::as_str) + .unwrap_or(site_key) + .to_string(); + let home = + match tokio::time::timeout(Duration::from_secs(12), spider.home_content(false)).await { + Ok(Ok(home)) => home, + Ok(Err(error)) => { + println!(" SITE FAIL [{site_name}]: {error}"); + continue; + } + Err(_) => { + println!(" SITE FAIL [{site_name}]: timeout"); + continue; + } + }; + let Some(list) = home.list else { + continue; + }; + + let site_start_len = samples.len(); + for vod in list.into_iter().take(MAX_HOME_ITEMS_PER_SITE) { + if samples.len() >= TARGET_SAMPLES + || samples.len().saturating_sub(config_start_len) >= MAX_SAMPLES_PER_CONFIG + || samples.len().saturating_sub(site_start_len) >= MAX_SAMPLES_PER_SITE + { + break; + } + if vod.vod_id.is_empty() || vod.vod_name.is_empty() { + continue; + } + let details = match tokio::time::timeout( + Duration::from_secs(12), + spider.detail_content(std::slice::from_ref(&vod.vod_id)), + ) + .await + { + Ok(Ok(details)) => details, + _ => continue, + }; + let Some(detail) = details.list.and_then(|items| items.into_iter().next()) else { + continue; + }; + let Some(play_url) = detail.vod_play_url.as_deref() else { + continue; + }; + let flags = parse_flags(detail.vod_play_from.as_deref()); + for (group_index, episode_url) in first_episode_urls(play_url) + .into_iter() + .take(MAX_EPISODES_PER_VOD) + .enumerate() + { + let key = format!("{site_key}::{}::{episode_url}", vod.vod_id); + if !seen.insert(key) { + continue; + } + let flag = flags + .get(group_index) + .cloned() + .unwrap_or_else(|| "默认".to_string()); + samples.push(ProbeSample { + source_name: source_name.to_string(), + config_url: config_url.to_string(), + site_key: site_key.to_string(), + site_name: site_name.clone(), + vod_id: vod.vod_id.clone(), + vod_name: vod.vod_name.clone(), + flag, + episode_url, + }); + if samples.len() >= TARGET_SAMPLES { + break; + } + } + } + } +} + +async fn probe_sample(client: &reqwest::Client, sample: ProbeSample) -> ProbeResult { + let started = Instant::now(); + let resolved = resolve_sample(&sample).await; + let (resolved_from, resolved_url, header) = match resolved { + Ok(value) => value, + Err(error) => { + return ProbeResult { + sample, + resolved_from: "-".to_string(), + resolved_url: "-".to_string(), + ok: false, + elapsed_ms: started.elapsed().as_millis(), + error: Some(error), + }; + } + }; + + let request_result = test_playback_request(client, &resolved_url, header.as_ref()).await; + ProbeResult { + sample, + resolved_from, + resolved_url, + ok: request_result.is_ok(), + elapsed_ms: started.elapsed().as_millis(), + error: request_result.err(), + } +} + +async fn resolve_sample( + sample: &ProbeSample, +) -> Result<(String, String, Option>), String> { + let config_text = network::http_get( + &network::create_client().map_err(|error| error.to_string())?, + &sample.config_url, + ) + .await + .map_err(|error| error.to_string())?; + let config_value = config::parse_config_or_live_source(&sample.config_url, &config_text) + .map_err(|error| error.to_string())?; + let sites = config_value + .get("sites") + .and_then(Value::as_array) + .ok_or_else(|| "config has no sites".to_string())?; + let parses = config_value + .get("parses") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let site = sites + .iter() + .find(|site| site.get("key").and_then(Value::as_str) == Some(sample.site_key.as_str())) + .ok_or_else(|| format!("site not found: {}", sample.site_key))?; + let spider = + spider_from_site(&sample.config_url, site).ok_or_else(|| "unsupported site".to_string())?; + let player = spider + .player_content(&sample.flag, &sample.episode_url, &[]) + .await + .map_err(|error| error.to_string())?; + let player_value = serde_json::to_value(&player).map_err(|error| error.to_string())?; + let parse_result = super_parse::super_parse( + &sample.episode_url, + &sample.flag, + &sample.site_key, + Some(&player_value), + Some(&parses), + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "super_parse returned no playable URL".to_string())?; + + Ok((parse_result.from, parse_result.url, parse_result.header)) +} + +async fn test_playback_request( + client: &reqwest::Client, + url: &str, + headers: Option<&HashMap>, +) -> Result<(), String> { + let mut request = client + .get(url) + .timeout(Duration::from_secs(10)) + .header(RANGE, "bytes=0-2047"); + if let Some(headers) = headers { + for (key, value) in headers { + request = request.header(key, value); + } + } + + let response = request.send().await.map_err(|error| error.to_string())?; + let status = response.status(); + if !status.is_success() { + return Err(format!("HTTP {}", status.as_u16())); + } + + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + let looks_like_hls = url.to_ascii_lowercase().contains(".m3u8") + || content_type.contains("mpegurl") + || content_type.contains("vnd.apple"); + + if looks_like_hls { + let text = response.text().await.map_err(|error| error.to_string())?; + if text.contains("#EXTM3U") + || text.contains("#EXTINF") + || text.contains("#EXT-X-STREAM-INF") + { + return Ok(()); + } + return Err("HLS manifest response did not contain playlist markers".to_string()); + } + + Ok(()) +} + +fn spider_from_site(config_url: &str, site: &Value) -> Option { + let site_type = site.get("type").and_then(Value::as_i64).unwrap_or(1); + if !matches!(site_type, 0 | 1 | 4) { + return None; + } + if site.get("hide").and_then(Value::as_i64) == Some(1) { + return None; + } + let key = site.get("key").and_then(Value::as_str)?.trim(); + let api = site.get("api").and_then(Value::as_str)?.trim(); + if key.is_empty() || api.is_empty() { + return None; + } + + Some(spider::HttpSpider::new(spider::SiteConfig { + key: key.to_string(), + name: site + .get("name") + .and_then(Value::as_str) + .unwrap_or(key) + .to_string(), + site_type, + api: config::resolve_relative_url(config_url, api), + ext: site.get("ext").cloned(), + play_url: site + .get("playUrl") + .or_else(|| site.get("play_url")) + .and_then(Value::as_str) + .map(String::from), + click: site.get("click").and_then(Value::as_str).map(String::from), + header: site.get("header").cloned(), + timeout: site.get("timeout").and_then(Value::as_i64), + })) +} + +fn is_supported_site(site: &Value) -> bool { + site.get("api").and_then(Value::as_str).is_some() + && matches!( + site.get("type").and_then(Value::as_i64).unwrap_or(1), + 0 | 1 | 4 + ) + && site.get("hide").and_then(Value::as_i64) != Some(1) +} + +fn parse_flags(value: Option<&str>) -> Vec { + value + .unwrap_or_default() + .split("$$$") + .map(str::trim) + .filter(|flag| !flag.is_empty()) + .map(String::from) + .collect() +} + +fn first_episode_urls(vod_play_url: &str) -> Vec { + let mut urls = Vec::new(); + for group in vod_play_url.split("$$$") { + for episode in group.split('#') { + let url = episode + .rsplit_once('$') + .map(|(_, url)| url) + .unwrap_or(episode) + .trim(); + if !url.is_empty() { + urls.push(url.to_string()); + break; + } + } + } + urls +} + +fn percentile(values: &[u128], percentile: f64) -> Option { + if values.is_empty() { + return None; + } + let index = ((values.len() - 1) as f64 * percentile).round() as usize; + values.get(index).copied() +} + +fn display_opt(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()) +} + +fn print_result(result: &ProbeResult) { + let status = if result.ok { "OK" } else { "FAIL" }; + println!( + "{status} [{} / {} / {} ({})] from={} elapsed={}ms url={}{}", + result.sample.source_name, + result.sample.site_name, + result.sample.vod_name, + result.sample.vod_id, + result.resolved_from, + result.elapsed_ms, + result.resolved_url, + result + .error + .as_ref() + .map(|error| format!(" error={error}")) + .unwrap_or_default() + ); +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 5812688..1e1cf4e 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -2,8 +2,146 @@ use serde_json::Value; use tauri::State; use crate::error::AppError; +use crate::spider::{HttpSpider, SiteConfig}; use crate::AppState; +const MAX_INSPECT_SITE_PROBES: usize = 6; +const INSPECT_SITE_TIMEOUT_SECS: i64 = 5; + +#[derive(Default)] +struct SiteProbeStats { + inspected: i64, + passed: i64, + failed: i64, + skipped: i64, +} + +fn site_type(site: &Value) -> i64 { + site.get("type").and_then(Value::as_i64).unwrap_or(1) +} + +fn site_api(site: &Value) -> Option<&str> { + site.get("api") + .and_then(Value::as_str) + .map(str::trim) + .filter(|api| !api.is_empty()) +} + +fn site_name(site: &Value) -> Option<&str> { + site.get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) +} + +fn is_hidden_site(site: &Value) -> bool { + site.get("hide").and_then(Value::as_i64) == Some(1) +} + +fn is_http_api_site(site: &Value) -> bool { + matches!(site_type(site), 0 | 1 | 4) +} + +fn is_directly_supported_site(site: &Value) -> bool { + is_http_api_site(site) && !is_hidden_site(site) && site_api(site).is_some() +} + +fn is_searchable_site(site: &Value) -> bool { + is_directly_supported_site(site) && site.get("searchable").and_then(Value::as_i64) != Some(0) +} + +fn live_channel_count(live: &Value) -> i64 { + if let Some(count) = live.get("channelCount").and_then(Value::as_i64) { + return count; + } + + live.get("groups") + .and_then(Value::as_array) + .map(|groups| { + groups + .iter() + .map(|group| { + group + .get("channel") + .or_else(|| group.get("channels")) + .and_then(Value::as_array) + .map(|channels| channels.len() as i64) + .unwrap_or(0) + }) + .sum() + }) + .unwrap_or(0) +} + +async fn probe_supported_sites(config_url: &str, config: &Value) -> SiteProbeStats { + let mut stats = SiteProbeStats::default(); + let Some(sites) = config.get("sites").and_then(Value::as_array) else { + return stats; + }; + + for site in sites.iter().filter(|site| is_directly_supported_site(site)) { + if stats.inspected as usize >= MAX_INSPECT_SITE_PROBES { + stats.skipped += 1; + continue; + } + + let Some(api) = site_api(site) else { + continue; + }; + + stats.inspected += 1; + let key = site + .get("key") + .and_then(Value::as_str) + .filter(|key| !key.trim().is_empty()) + .unwrap_or(api); + let timeout = site + .get("timeout") + .and_then(Value::as_i64) + .filter(|timeout| *timeout > 0) + .map(|timeout| timeout.min(INSPECT_SITE_TIMEOUT_SECS)) + .unwrap_or(INSPECT_SITE_TIMEOUT_SECS); + let spider = HttpSpider::new(SiteConfig { + key: key.to_string(), + name: site_name(site).unwrap_or(key).to_string(), + site_type: site_type(site), + api: crate::config::resolve_relative_url(config_url, api), + ext: site.get("ext").cloned(), + play_url: site + .get("playUrl") + .and_then(Value::as_str) + .map(String::from), + click: site.get("click").and_then(Value::as_str).map(String::from), + header: site.get("header").cloned(), + timeout: Some(timeout), + }); + + let passed = match spider.home_content(true).await { + Ok(result) => { + result + .class + .as_ref() + .map(|items| !items.is_empty()) + .unwrap_or(false) + || result + .list + .as_ref() + .map(|items| !items.is_empty()) + .unwrap_or(false) + } + Err(_) => false, + }; + + if passed { + stats.passed += 1; + } else { + stats.failed += 1; + } + } + + stats +} + /// 获取当前配置 pub fn handle_config_get_current(state: &State<'_, AppState>) -> Result { let url = state @@ -44,11 +182,15 @@ pub fn handle_config_list(state: &State<'_, AppState>) -> Result, url: String) -> Result { - let mgr = state.config_manager.lock(); let rt = tokio::runtime::Runtime::new() .map_err(|e| AppError::internal("创建运行时失败").with_internal(e.to_string()))?; - match rt.block_on(mgr.load_from_url(&url)) { + let config_result = { + let mgr = state.config_manager.lock(); + rt.block_on(mgr.load_from_url(&url)) + }; + + match config_result { Ok(config) => { let sites = config .get("sites") @@ -76,58 +218,119 @@ pub fn handle_config_inspect(state: &State<'_, AppState>, url: String) -> Result .unwrap_or("tvbox"); let mut warnings: Vec = Vec::new(); - if sites == 0 && lives == 0 { - warnings.push("配置中没有可用站点或直播源".to_string()); - } if source_type == "live" { warnings.push("已识别为直播源直链,将作为单个直播源加载".to_string()); } - let visible_sites = config + let site_entries = config .get("sites") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter(|s| { - let t = s.get("type").and_then(Value::as_i64).unwrap_or(0); - t == 0 || t == 1 || t == 4 - }) - .count() as i64 - }) - .unwrap_or(0); + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); - let searchable_sites = config - .get("sites") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter(|s| s.get("searchable").and_then(Value::as_i64) != Some(0)) - .count() as i64 - }) - .unwrap_or(0); + let visible_sites = site_entries + .iter() + .filter(|site| is_directly_supported_site(site)) + .count() as i64; - let unsupported_sites = config - .get("sites") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter(|s| s.get("type").and_then(Value::as_i64).unwrap_or(0) == 3) - .count() as i64 + let searchable_sites = site_entries + .iter() + .filter(|site| is_searchable_site(site)) + .count() as i64; + + let hidden_sites = site_entries + .iter() + .filter(|site| is_hidden_site(site)) + .count() as i64; + + let csp_sites = site_entries + .iter() + .filter(|site| site_type(site) == 3) + .count() as i64; + + let missing_api_sites = site_entries + .iter() + .filter(|site| { + is_http_api_site(site) && !is_hidden_site(site) && site_api(site).is_none() }) - .unwrap_or(0); + .count() as i64; - if unsupported_sites > 0 { + let unsupported_sites = site_entries + .iter() + .filter(|site| !is_hidden_site(site) && !is_directly_supported_site(site)) + .count() as i64; + + if csp_sites > 0 { + warnings.push(format!( + "包含 {} 个 type=3 CSP/Jar/JS/Python 站点,当前桌面端暂不执行此类爬虫", + csp_sites + )); + } + if hidden_sites > 0 { + warnings.push(format!("已忽略 {} 个 hide=1 的隐藏站点", hidden_sites)); + } + if missing_api_sites > 0 { warnings.push(format!( - "包含 {} 个暂不支持的 CSP Jar 站点", - unsupported_sites + "有 {} 个 HTTP API 站点缺少 api 地址,已排除", + missing_api_sites )); } + let probe_stats = rt.block_on(probe_supported_sites(&url, &config)); + if probe_stats.inspected > 0 && probe_stats.passed == 0 { + warnings.push(format!( + "已抽样测试 {} 个 HTTP API 站点,暂未拿到首页分类或列表", + probe_stats.inspected + )); + } else if probe_stats.failed > 0 { + warnings.push(format!( + "抽样测试中 {} 个 HTTP API 站点未返回可用首页内容", + probe_stats.failed + )); + } + if probe_stats.skipped > 0 { + warnings.push(format!( + "为避免预检过慢,另有 {} 个 HTTP API 站点留待导入后探测", + probe_stats.skipped + )); + } + + let live_channels = config + .get("lives") + .and_then(Value::as_array) + .map(|items| items.iter().map(live_channel_count).sum::()) + .unwrap_or(0); + + let has_probe_data = probe_stats.inspected > 0; + let direct_sites_ready = + visible_sites > 0 && (!has_probe_data || probe_stats.passed > 0); + let has_live_sources = lives > 0; + let (compatibility, compatibility_label, can_import) = if direct_sites_ready { + ("ready", "点播可用", true) + } else if has_live_sources { + ("live", "仅直播可用", true) + } else if sites > 0 || parses > 0 { + ("unsupported", "暂不兼容", false) + } else { + ("invalid", "不可用", false) + }; + + if !can_import { + warnings.push("当前项目没有可消费的 type=0/1/4 HTTP API 站点或直播源".to_string()); + } + let name = config .get("sites") .and_then(|v| v.as_array()) - .and_then(|arr| arr.first()) - .and_then(|s| s.get("name").and_then(Value::as_str)) + .and_then(|arr| arr.iter().find(|site| is_directly_supported_site(site))) + .and_then(site_name) + .or_else(|| { + config + .get("sites") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(site_name) + }) .or_else(|| { config .get("lives") @@ -151,6 +354,17 @@ pub fn handle_config_inspect(state: &State<'_, AppState>, url: String) -> Result "parseCount": parses, "hasSpider": has_spider, "sourceType": source_type, + "compatibility": compatibility, + "compatibilityLabel": compatibility_label, + "canImport": can_import, + "hiddenSiteCount": hidden_sites, + "cspSiteCount": csp_sites, + "missingApiSiteCount": missing_api_sites, + "probeInspectedSiteCount": probe_stats.inspected, + "probePassedSiteCount": probe_stats.passed, + "probeFailedSiteCount": probe_stats.failed, + "probeSkippedSiteCount": probe_stats.skipped, + "liveChannelCount": live_channels, "warnings": warnings, } })) diff --git a/src-tauri/src/commands/site.rs b/src-tauri/src/commands/site.rs index a84f1a1..53a56e4 100644 --- a/src-tauri/src/commands/site.rs +++ b/src-tauri/src/commands/site.rs @@ -1,65 +1,132 @@ use std::collections::HashMap; +use std::time::Duration; -use serde_json::Value; +use serde_json::{json, Value}; use tauri::State; use crate::error::AppError; use crate::spider::{HttpSpider, SiteConfig}; use crate::AppState; -/// 创建 HttpSpider 实例 -fn create_spider(state: &State<'_, AppState>, site_key: &str) -> Result { - let config_url = match state.config_manager.lock().get_current_url() { - Some(u) => u.to_string(), - None => return Err(AppError::not_found("无当前配置")), - }; +const MAX_ACROSS_SITE_SEARCH_SITES: usize = 24; +const DEFAULT_ACROSS_SITE_TIMEOUT_MS: u64 = 8_000; - let rt = tokio::runtime::Runtime::new().map_err(|e| AppError::internal("创建运行时失败").with_internal(e.to_string()))?; +fn load_current_config(state: &State<'_, AppState>) -> Result<(String, Value), AppError> { + let config_url = state + .config_manager + .lock() + .get_current_url() + .map(|url| url.to_string()) + .ok_or_else(|| AppError::not_found("无当前配置"))?; + + let rt = tokio::runtime::Runtime::new() + .map_err(|e| AppError::internal("创建运行时失败").with_internal(e.to_string()))?; let mgr = state.config_manager.lock(); - let config = match rt.block_on(mgr.load_from_url(&config_url)) { - Ok(c) => c, - Err(_) => return Err(AppError::not_found("配置加载失败")), - }; + let config = rt + .block_on(mgr.load_from_url(&config_url)) + .map_err(|_| AppError::not_found("配置加载失败"))?; + Ok((config_url, config)) +} + +fn site_type(site: &Value) -> i64 { + site.get("type").and_then(Value::as_i64).unwrap_or(1) +} + +fn is_hidden_site(site: &Value) -> bool { + site.get("hide").and_then(Value::as_i64) == Some(1) +} + +fn is_http_api_site(site: &Value) -> bool { + matches!(site_type(site), 0 | 1 | 4) +} + +fn is_searchable_site(site: &Value) -> bool { + is_http_api_site(site) + && !is_hidden_site(site) + && site.get("searchable").and_then(Value::as_i64) != Some(0) + && site + .get("api") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|api| !api.is_empty()) +} + +fn site_key(site: &Value) -> Option<&str> { + site.get("key") + .and_then(Value::as_str) + .map(str::trim) + .filter(|key| !key.is_empty()) +} + +fn site_name<'a>(site: &'a Value, fallback: &'a str) -> &'a str { + site.get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(fallback) +} + +fn spider_from_site(config_url: &str, site: &Value) -> Result { + let key = site_key(site).ok_or_else(|| AppError::invalid_input("站点缺少 key"))?; + let api = site + .get("api") + .and_then(Value::as_str) + .map(str::trim) + .filter(|api| !api.is_empty()) + .ok_or_else(|| AppError::invalid_input(format!("站点 {} 无 API 地址", key)))?; + + Ok(HttpSpider::new(SiteConfig { + key: key.to_string(), + name: site_name(site, key).to_string(), + site_type: site_type(site), + api: crate::config::resolve_relative_url(config_url, api), + ext: site.get("ext").cloned(), + play_url: site + .get("playUrl") + .or_else(|| site.get("play_url")) + .and_then(Value::as_str) + .map(String::from), + click: site.get("click").and_then(Value::as_str).map(String::from), + header: site.get("header").cloned(), + timeout: site.get("timeout").and_then(Value::as_i64), + })) +} + +/// 创建 HttpSpider 实例 +fn create_spider(state: &State<'_, AppState>, site_key: &str) -> Result { + let (config_url, config) = load_current_config(state)?; let sites = match config.get("sites").and_then(Value::as_array) { Some(s) => s, None => return Err(AppError::not_found("配置中无站点")), }; - let site_entry = match sites.iter().find(|s| s.get("key").and_then(Value::as_str) == Some(site_key)) { + let site_entry = match sites + .iter() + .find(|s| s.get("key").and_then(Value::as_str) == Some(site_key)) + { Some(s) => s, None => return Err(AppError::not_found(format!("站点不存在: {}", site_key))), }; - let site_type = site_entry.get("type").and_then(Value::as_i64).unwrap_or(1); - let api = match site_entry.get("api").and_then(Value::as_str) { - Some(a) => a, - None => return Err(AppError::invalid_input(format!("站点 {} 无 API 地址", site_key))), - }; - - let resolved_api = crate::config::resolve_relative_url(&config_url, api); - - Ok(HttpSpider::new(SiteConfig { - key: site_key.to_string(), - name: site_entry.get("name").and_then(Value::as_str).unwrap_or(site_key).to_string(), - site_type, - api: resolved_api, - ext: site_entry.get("ext").cloned(), - play_url: site_entry.get("playUrl").and_then(Value::as_str).map(String::from), - click: site_entry.get("click").and_then(Value::as_str).map(String::from), - header: site_entry.get("header").cloned(), - timeout: site_entry.get("timeout").and_then(Value::as_i64), - })) + spider_from_site(&config_url, site_entry) } fn block_on(fut: F) -> Result -where F: std::future::Future> { - let rt = tokio::runtime::Runtime::new().map_err(|e| AppError::internal("创建运行时失败").with_internal(e.to_string()))?; +where + F: std::future::Future>, +{ + let rt = tokio::runtime::Runtime::new() + .map_err(|e| AppError::internal("创建运行时失败").with_internal(e.to_string()))?; rt.block_on(fut) } /// site:homeContent -pub fn handle_site_home_content(state: &State<'_, AppState>, site_key: String, filter: bool) -> Result { +pub fn handle_site_home_content( + state: &State<'_, AppState>, + site_key: String, + filter: bool, +) -> Result { let spider = create_spider(state, &site_key)?; let result = block_on(spider.home_content(filter))?; Ok(serde_json::json!({ "success": true, "data": result })) @@ -68,18 +135,31 @@ pub fn handle_site_home_content(state: &State<'_, AppState>, site_key: String, f /// site:categoryContent pub fn handle_site_category_content( state: &State<'_, AppState>, - site_key: String, tid: String, pg: String, filter: bool, extend: Value, + site_key: String, + tid: String, + pg: String, + filter: bool, + extend: Value, ) -> Result { let spider = create_spider(state, &site_key)?; - let extend_map: HashMap = extend.as_object() - .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()) + let extend_map: HashMap = extend + .as_object() + .map(|obj| { + obj.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect() + }) .unwrap_or_default(); let result = block_on(spider.category_content(&tid, &pg, filter, &extend_map))?; Ok(serde_json::json!({ "success": true, "data": result })) } /// site:detailContent -pub fn handle_site_detail_content(state: &State<'_, AppState>, site_key: String, ids: Vec) -> Result { +pub fn handle_site_detail_content( + state: &State<'_, AppState>, + site_key: String, + ids: Vec, +) -> Result { let spider = create_spider(state, &site_key)?; let result = block_on(spider.detail_content(&ids))?; Ok(serde_json::json!({ "success": true, "data": result })) @@ -87,7 +167,11 @@ pub fn handle_site_detail_content(state: &State<'_, AppState>, site_key: String, /// site:searchContent pub fn handle_site_search_content( - state: &State<'_, AppState>, site_key: String, keyword: String, quick: bool, pg: Option, + state: &State<'_, AppState>, + site_key: String, + keyword: String, + quick: bool, + pg: Option, ) -> Result { let spider = create_spider(state, &site_key)?; let result = block_on(spider.search_content(&keyword, quick, pg.as_deref()))?; @@ -96,7 +180,11 @@ pub fn handle_site_search_content( /// site:playerContent pub fn handle_site_player_content( - state: &State<'_, AppState>, site_key: String, flag: String, id: String, vip_flags: Vec, + state: &State<'_, AppState>, + site_key: String, + flag: String, + id: String, + vip_flags: Vec, ) -> Result { let spider = create_spider(state, &site_key)?; let result = block_on(spider.player_content(&flag, &id, &vip_flags))?; @@ -104,7 +192,10 @@ pub fn handle_site_player_content( } /// site:probe -pub fn handle_site_probe(state: &State<'_, AppState>, site_keys: Vec) -> Result { +pub fn handle_site_probe( + state: &State<'_, AppState>, + site_keys: Vec, +) -> Result { let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => return Err(AppError::internal("创建运行时失败").with_internal(e.to_string())), @@ -119,7 +210,11 @@ pub fn handle_site_probe(state: &State<'_, AppState>, site_keys: Vec) -> Ok(r) => r, Err(_) => continue, }; - let has_content = result.class.as_ref().map(|c| !c.is_empty()).unwrap_or(false) + let has_content = result + .class + .as_ref() + .map(|c| !c.is_empty()) + .unwrap_or(false) || result.list.as_ref().map(|l| !l.is_empty()).unwrap_or(false); if has_content { return Ok(serde_json::json!({ @@ -132,11 +227,206 @@ pub fn handle_site_probe(state: &State<'_, AppState>, site_keys: Vec) -> } /// site:superParse -pub fn handle_site_super_parse(_state: &State<'_, AppState>, _params: Value) -> Result { - Err(AppError::unsupported("SuperParse 尚未迁移到 Rust")) +pub fn handle_site_super_parse( + state: &State<'_, AppState>, + params: Value, +) -> Result { + let url = params + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else(|| AppError::invalid_input("缺少播放地址"))?; + let flag = params + .get("flag") + .and_then(Value::as_str) + .unwrap_or_default(); + let site_key = params + .get("siteKey") + .and_then(Value::as_str) + .unwrap_or_default(); + let player_result = params.get("playerResult"); + + let (_, config) = load_current_config(state)?; + let parses = config + .get("parses") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let result = block_on(crate::super_parse::super_parse( + url, + flag, + site_key, + player_result, + Some(&parses), + ))?; + + match result { + Some(data) => Ok(json!({ "success": true, "data": data })), + None => Ok(json!({ + "success": false, + "error": "解析失败,未找到可播放地址" + })), + } } /// site:findAcrossSites -pub fn handle_site_find_across_sites(_state: &State<'_, AppState>, _keyword: String, _options: Option) -> Result { - Err(AppError::unsupported("跨站搜索尚未迁移到 Rust")) +pub fn handle_site_find_across_sites( + state: &State<'_, AppState>, + keyword: String, + options: Option, +) -> Result { + let keyword = keyword.trim().to_string(); + if keyword.is_empty() { + return Ok(json!({ "success": true, "data": [] })); + } + + let options = options.unwrap_or(Value::Null); + let exclude_site_key = options + .get("excludeSiteKey") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let exclude_vod_id = options + .get("excludeVodId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let limit = options + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(20) + .clamp(1, 100) as usize; + let timeout_ms = options + .get("timeoutMs") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_ACROSS_SITE_TIMEOUT_MS) + .clamp(1_000, 30_000); + + let (config_url, config) = load_current_config(state)?; + let sites = config + .get("sites") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let mut candidates = Vec::new(); + for site in sites.iter().filter(|site| is_searchable_site(site)) { + let Some(key) = site_key(site) else { continue }; + if key == exclude_site_key { + continue; + } + let name = site_name(site, key).to_string(); + let spider = match spider_from_site(&config_url, site) { + Ok(spider) => spider, + Err(_) => continue, + }; + candidates.push((key.to_string(), name, spider)); + if candidates.len() >= MAX_ACROSS_SITE_SEARCH_SITES { + break; + } + } + + if candidates.is_empty() { + return Ok(json!({ "success": true, "data": [] })); + } + + let rt = tokio::runtime::Runtime::new() + .map_err(|e| AppError::internal("创建运行时失败").with_internal(e.to_string()))?; + let total_timeout = Duration::from_millis(timeout_ms); + let per_site_timeout = Duration::from_millis(timeout_ms.min(8_000)); + let keyword_for_tasks = keyword.clone(); + let search_result = rt.block_on(tokio::time::timeout(total_timeout, async move { + let mut handles = Vec::new(); + for (site_key, site_name, spider) in candidates { + let keyword = keyword_for_tasks.clone(); + let exclude_vod_id = exclude_vod_id.clone(); + handles.push(tokio::spawn(async move { + let search = tokio::time::timeout( + per_site_timeout, + spider.search_content(&keyword, true, Some("1")), + ) + .await; + let Ok(Ok(result)) = search else { + return Vec::new(); + }; + let Some(list) = result.list else { + return Vec::new(); + }; + + list.into_iter() + .filter(|vod| !vod.vod_id.is_empty() && vod.vod_id != exclude_vod_id) + .map(|vod| { + json!({ + "siteKey": site_key, + "siteName": site_name, + "vodId": vod.vod_id, + "vodName": vod.vod_name, + "vodPic": vod.vod_pic, + "vodRemarks": vod.vod_remarks, + }) + }) + .collect::>() + })); + } + + let mut found = Vec::new(); + for handle in handles { + if let Ok(items) = handle.await { + found.extend(items); + } + } + found + })); + + let mut found = match search_result { + Ok(items) => items, + Err(_) => Vec::new(), + }; + let keyword_normalized = normalize_title(&keyword); + found.sort_by(|a, b| { + let a_name = a.get("vodName").and_then(Value::as_str).unwrap_or_default(); + let b_name = b.get("vodName").and_then(Value::as_str).unwrap_or_default(); + let a_exact = normalize_title(a_name) == keyword_normalized; + let b_exact = normalize_title(b_name) == keyword_normalized; + b_exact + .cmp(&a_exact) + .then_with(|| a_name.len().cmp(&b_name.len())) + .then_with(|| { + a.get("siteName") + .and_then(Value::as_str) + .unwrap_or_default() + .cmp( + b.get("siteName") + .and_then(Value::as_str) + .unwrap_or_default(), + ) + }) + }); + + let mut seen = std::collections::HashSet::new(); + found.retain(|item| { + let key = format!( + "{}::{}", + item.get("siteKey") + .and_then(Value::as_str) + .unwrap_or_default(), + item.get("vodId") + .and_then(Value::as_str) + .unwrap_or_default() + ); + seen.insert(key) + }); + found.truncate(limit); + + Ok(json!({ "success": true, "data": found })) +} + +fn normalize_title(value: &str) -> String { + value + .chars() + .filter(|ch| !ch.is_whitespace() && !matches!(ch, '-' | '_' | ':' | ':' | '·')) + .flat_map(char::to_lowercase) + .collect() } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index a7f2c44..99c5671 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -131,14 +131,19 @@ impl ConfigManager { } pub fn remove(&mut self, url: &str) -> Result<(), AppError> { - let before = self.items.len(); - self.items.retain(|i| i.url != url); - if self.items.len() == before { + let Some(removed_index) = self.items.iter().position(|i| i.url == url) else { return Err(AppError::not_found(format!("配置不存在: {}", url))); - } + }; + + self.items.remove(removed_index); if self.current_url.as_deref() == Some(url) { - self.current_url = None; + self.current_url = self + .items + .get(removed_index) + .or_else(|| self.items.last()) + .map(|item| item.url.clone()); } + self.save_store() } @@ -232,7 +237,9 @@ pub fn parse_config_or_live_source(url: &str, text: &str) -> Result) -> Value { for config in &configs { if let Some(sites) = config.get("sites").and_then(Value::as_array) { for site in sites { - let key = site.get("key").and_then(Value::as_str).unwrap_or("").to_string(); + let key = site + .get("key") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !key.is_empty() && !seen_site_keys.contains(&key) { seen_site_keys.insert(key); all_sites.push(site.clone()); @@ -381,7 +392,7 @@ async fn load_single_sub_config(client: &reqwest::Client, url: &str) -> Result 0 { return Ok(wrap_live_source_config(url, groups.len(), channel_count)); } - return Ok(parsed); + return Err(AppError::parse_error("无法识别子配置格式")); } // 可能直播源文本 let groups = crate::live::parse_live_content(&text); @@ -389,9 +400,7 @@ async fn load_single_sub_config(client: &reqwest::Client, url: &str) -> Result 0 { return Ok(wrap_live_source_config(url, groups.len(), channel_count)); } - Err(AppError::parse_error( - "无法识别子配置格式", - )) + Err(AppError::parse_error("无法识别子配置格式")) } fn wrap_live_source_config(url: &str, group_count: usize, channel_count: usize) -> Value { @@ -442,6 +451,23 @@ pub fn github_raw_fallback_url(url: &str) -> Option { return None; } + if parsed.host_str() == Some("github.com") { + let parts: Vec<&str> = parsed.path().trim_start_matches('/').split('/').collect(); + if parts.len() >= 5 && (parts[2] == "blob" || parts[2] == "raw") { + let owner = parts[0]; + let repo = parts[1]; + let branch = parts[3]; + let file_path = parts[4..].join("/"); + if !owner.is_empty() && !repo.is_empty() && !branch.is_empty() && !file_path.is_empty() + { + return Some(format!( + "https://raw.githubusercontent.com/{}/{}/{}/{}", + owner, repo, branch, file_path + )); + } + } + } + let path = parsed.path().trim_start_matches('/'); let raw_path = path.strip_prefix("raw.githubusercontent.com/")?; let mut fallback = format!("https://raw.githubusercontent.com/{}", raw_path); @@ -513,6 +539,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn config_manager_selects_next_config_when_current_is_removed() { + let dir = test_dir("iptv-config-test-remove-current"); + let mut mgr = ConfigManager::new(dir.clone()); + mgr.add("https://example.com/a.json", "A").unwrap(); + mgr.add("https://example.com/b.json", "B").unwrap(); + mgr.add("https://example.com/c.json", "C").unwrap(); + mgr.set_current_url(Some("https://example.com/b.json".to_string())) + .unwrap(); + + mgr.remove("https://example.com/b.json").unwrap(); + assert_eq!(mgr.get_current_url(), Some("https://example.com/c.json")); + assert_eq!(mgr.list().len(), 2); + + let reloaded = ConfigManager::new(dir.clone()); + assert_eq!( + reloaded.get_current_url(), + Some("https://example.com/c.json") + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn config_manager_clears_current_when_last_config_is_removed() { + let dir = test_dir("iptv-config-test-remove-last"); + let mut mgr = ConfigManager::new(dir.clone()); + mgr.add("https://example.com/only.json", "Only").unwrap(); + mgr.set_current_url(Some("https://example.com/only.json".to_string())) + .unwrap(); + + mgr.remove("https://example.com/only.json").unwrap(); + assert!(mgr.list().is_empty()); + assert_eq!(mgr.get_current_url(), None); + + let reloaded = ConfigManager::new(dir.clone()); + assert_eq!(reloaded.get_current_url(), None); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn config_manager_rename() { let dir = test_dir("iptv-config-test-2"); @@ -660,6 +727,16 @@ mod tests { assert_eq!(config["lives"][0]["channelCount"], 1); } + #[test] + fn parse_unknown_json_rejects_non_config_payload() { + let result = parse_config_or_live_source( + "https://example.com/api.json", + r#"{"code":200,"list":[{"name":"not a config"}]}"#, + ); + + assert!(result.is_err()); + } + #[test] fn github_raw_fallback_rewrites_proxy_url() { let fallback = github_raw_fallback_url( @@ -672,6 +749,17 @@ mod tests { ); } + #[test] + fn github_raw_fallback_rewrites_blob_url() { + let fallback = + github_raw_fallback_url("https://github.com/owner/repo/blob/main/path/config.json"); + + assert_eq!( + fallback.as_deref(), + Some("https://raw.githubusercontent.com/owner/repo/main/path/config.json") + ); + } + #[test] fn load_from_local_file_fails_on_nonexistent() { let dir = test_dir("iptv-config-test-4"); diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 0ac51a5..cfd5955 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -7,7 +7,7 @@ use std::{ use rusqlite::{params, Connection, Row}; use serde_json::{json, Value}; -const SCHEMA_VERSION: i64 = 2; +const SCHEMA_VERSION: i64 = 3; struct Migration { version: i64, @@ -88,6 +88,20 @@ const MIGRATIONS: &[Migration] = &[ VALUES (1, 'idle', 30); ", }, + Migration { + version: 3, + up: " + ALTER TABLE history ADD COLUMN episodeId TEXT DEFAULT ''; + ALTER TABLE history ADD COLUMN episodeName TEXT DEFAULT ''; + ALTER TABLE history ADD COLUMN episodeIndex INTEGER DEFAULT 0; + ALTER TABLE history ADD COLUMN sourceIndex INTEGER DEFAULT 0; + ALTER TABLE history ADD COLUMN sourceName TEXT DEFAULT ''; + ALTER TABLE history ADD COLUMN urlIdentifier TEXT DEFAULT ''; + ALTER TABLE history ADD COLUMN duration INTEGER DEFAULT 0; + ALTER TABLE history ADD COLUMN positionSeconds INTEGER DEFAULT 0; + ALTER TABLE history ADD COLUMN completed INTEGER DEFAULT 0; + ", + }, ]; #[derive(Debug)] @@ -194,8 +208,10 @@ impl Database { .execute( " INSERT INTO history - (siteKey, vodId, vodName, vodPic, vodRemarks, type, source, progress, createTime, updateTime) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9) + (siteKey, vodId, vodName, vodPic, vodRemarks, type, source, progress, + episodeId, episodeName, episodeIndex, sourceIndex, sourceName, urlIdentifier, + duration, positionSeconds, completed, createTime, updateTime) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?18) ON CONFLICT(siteKey, vodId) DO UPDATE SET vodName = excluded.vodName, vodPic = excluded.vodPic, @@ -203,6 +219,15 @@ impl Database { type = excluded.type, source = excluded.source, progress = excluded.progress, + episodeId = excluded.episodeId, + episodeName = excluded.episodeName, + episodeIndex = excluded.episodeIndex, + sourceIndex = excluded.sourceIndex, + sourceName = excluded.sourceName, + urlIdentifier = excluded.urlIdentifier, + duration = excluded.duration, + positionSeconds = excluded.positionSeconds, + completed = excluded.completed, updateTime = excluded.updateTime ", params![ @@ -214,6 +239,15 @@ impl Database { optional_i64(item, "type"), optional_text(item, "source"), optional_i64(item, "progress"), + optional_text(item, "episodeId"), + optional_text(item, "episodeName"), + optional_i64(item, "episodeIndex"), + optional_i64(item, "sourceIndex"), + optional_text(item, "sourceName"), + optional_text(item, "urlIdentifier"), + optional_i64(item, "duration"), + optional_i64(item, "positionSeconds"), + optional_bool_i64(item, "completed"), now ], ) @@ -223,7 +257,9 @@ impl Database { pub fn history_list(&self, limit: i64, offset: i64) -> Result { self.list( - "SELECT id, siteKey, vodId, vodName, vodPic, vodRemarks, type, source, progress, createTime, updateTime + "SELECT id, siteKey, vodId, vodName, vodPic, vodRemarks, type, source, progress, + episodeId, episodeName, episodeIndex, sourceIndex, sourceName, urlIdentifier, + duration, positionSeconds, completed, createTime, updateTime FROM history ORDER BY updateTime DESC LIMIT ?1 OFFSET ?2", limit, offset, @@ -505,8 +541,17 @@ fn history_row(row: &Row<'_>) -> rusqlite::Result { "type": row.get::<_, i64>(6)?, "source": row.get::<_, String>(7)?, "progress": row.get::<_, i64>(8)?, - "createTime": row.get::<_, i64>(9)?, - "updateTime": row.get::<_, i64>(10)? + "episodeId": row.get::<_, String>(9)?, + "episodeName": row.get::<_, String>(10)?, + "episodeIndex": row.get::<_, i64>(11)?, + "sourceIndex": row.get::<_, i64>(12)?, + "sourceName": row.get::<_, String>(13)?, + "urlIdentifier": row.get::<_, String>(14)?, + "duration": row.get::<_, i64>(15)?, + "positionSeconds": row.get::<_, i64>(16)?, + "completed": row.get::<_, i64>(17)? == 1, + "createTime": row.get::<_, i64>(18)?, + "updateTime": row.get::<_, i64>(19)? })) } @@ -571,6 +616,14 @@ fn optional_i64(value: &Value, key: &str) -> i64 { value.get(key).and_then(Value::as_i64).unwrap_or(0) } +fn optional_bool_i64(value: &Value, key: &str) -> i64 { + match value.get(key) { + Some(Value::Bool(true)) => 1, + Some(Value::Number(number)) if number.as_i64().unwrap_or(0) != 0 => 1, + _ => 0, + } +} + #[allow(dead_code)] fn optional_f64(value: &Value, key: &str) -> f64 { value.get(key).and_then(Value::as_f64).unwrap_or(0.0) @@ -623,7 +676,16 @@ mod tests { "siteKey": "site-a", "vodId": "vod-1", "vodName": "Updated", - "progress": 35 + "progress": 35, + "episodeId": "2", + "episodeName": "第 2 集", + "episodeIndex": 1, + "sourceIndex": 2, + "sourceName": "高清", + "urlIdentifier": "abc123", + "duration": 1800, + "positionSeconds": 630, + "completed": false })) .unwrap(); @@ -631,6 +693,11 @@ mod tests { assert_eq!(rows.as_array().unwrap().len(), 1); assert_eq!(rows[0]["vodName"], "Updated"); assert_eq!(rows[0]["progress"], 35); + assert_eq!(rows[0]["episodeName"], "第 2 集"); + assert_eq!(rows[0]["episodeIndex"], 1); + assert_eq!(rows[0]["sourceIndex"], 2); + assert_eq!(rows[0]["positionSeconds"], 630); + assert_eq!(rows[0]["completed"], false); } #[test] @@ -775,6 +842,82 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn migration_from_v2_adds_resume_history_columns() { + let dir = std::env::temp_dir().join("iptv-mig-test-v2"); + let _ = std::fs::remove_file(&dir); + let path = dir.to_string_lossy().to_string() + ".db"; + + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + siteKey TEXT NOT NULL, + vodId TEXT NOT NULL, + vodName TEXT NOT NULL, + vodPic TEXT DEFAULT '', + vodRemarks TEXT DEFAULT '', + type INTEGER DEFAULT 0, + source TEXT DEFAULT '', + progress INTEGER DEFAULT 0, + createTime INTEGER NOT NULL DEFAULT 0, + updateTime INTEGER NOT NULL DEFAULT 0, + UNIQUE(siteKey, vodId) + ); + CREATE INDEX IF NOT EXISTS idx_history_update ON history(updateTime DESC); + CREATE TABLE IF NOT EXISTS keep ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + siteKey TEXT NOT NULL, + vodId TEXT NOT NULL, + vodName TEXT NOT NULL, + vodPic TEXT DEFAULT '', + vodRemarks TEXT DEFAULT '', + type INTEGER DEFAULT 0, + source TEXT DEFAULT '', + createTime INTEGER NOT NULL DEFAULT 0, + updateTime INTEGER NOT NULL DEFAULT 0, + UNIQUE(siteKey, vodId) + ); + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + createTime INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS live_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + urls TEXT NOT NULL, + best_url TEXT NOT NULL, + country TEXT NOT NULL, + category TEXT NOT NULL, + sort_order REAL DEFAULT 0, + latency INTEGER DEFAULT -1, + original_groups TEXT, + last_test_time INTEGER NOT NULL, + is_alive INTEGER DEFAULT 1, + UNIQUE(name, country, category) + ); + CREATE TABLE IF NOT EXISTS live_refresh_status ( + id INTEGER PRIMARY KEY CHECK (id = 1), + status TEXT DEFAULT 'idle' + ); + INSERT INTO history (siteKey, vodId, vodName, progress, createTime, updateTime) + VALUES ('site-a', 'vod-1', 'Legacy', 12, 1, 1); + PRAGMA user_version = 2;", + ) + .unwrap(); + drop(conn); + + let db = Database::open(PathBuf::from(&path)).unwrap(); + let rows = db.history_list(50, 0).unwrap(); + assert_eq!(rows[0]["vodName"], "Legacy"); + assert_eq!(rows[0]["episodeIndex"], 0); + assert_eq!(rows[0]["positionSeconds"], 0); + assert_eq!(rows[0]["completed"], false); + + let _ = std::fs::remove_file(&path); + } + #[test] fn corrupt_database_creates_backup_and_new_db() { let dir = std::env::temp_dir().join("iptv-corrupt-test-dir"); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 65f507a..0980950 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -41,7 +41,11 @@ pub struct AppError { impl AppError { pub fn new(code: ErrorCode, message: impl Into) -> Self { - Self { code, message: message.into(), internal: None } + Self { + code, + message: message.into(), + internal: None, + } } pub fn with_internal(mut self, internal: impl Into) -> Self { @@ -55,14 +59,30 @@ impl AppError { } // Convenience constructors - pub fn invalid_input(msg: impl Into) -> Self { Self::new(ErrorCode::InvalidInput, msg) } - pub fn not_found(msg: impl Into) -> Self { Self::new(ErrorCode::NotFound, msg) } - pub fn network_error(msg: impl Into) -> Self { Self::new(ErrorCode::NetworkError, msg) } - pub fn timeout(msg: impl Into) -> Self { Self::new(ErrorCode::Timeout, msg) } - pub fn parse_error(msg: impl Into) -> Self { Self::new(ErrorCode::ParseError, msg) } - pub fn database_error(msg: impl Into) -> Self { Self::new(ErrorCode::DatabaseError, msg) } - pub fn unsupported(msg: impl Into) -> Self { Self::new(ErrorCode::Unsupported, msg) } - pub fn internal(msg: impl Into) -> Self { Self::new(ErrorCode::InternalError, msg) } + pub fn invalid_input(msg: impl Into) -> Self { + Self::new(ErrorCode::InvalidInput, msg) + } + pub fn not_found(msg: impl Into) -> Self { + Self::new(ErrorCode::NotFound, msg) + } + pub fn network_error(msg: impl Into) -> Self { + Self::new(ErrorCode::NetworkError, msg) + } + pub fn timeout(msg: impl Into) -> Self { + Self::new(ErrorCode::Timeout, msg) + } + pub fn parse_error(msg: impl Into) -> Self { + Self::new(ErrorCode::ParseError, msg) + } + pub fn database_error(msg: impl Into) -> Self { + Self::new(ErrorCode::DatabaseError, msg) + } + pub fn unsupported(msg: impl Into) -> Self { + Self::new(ErrorCode::Unsupported, msg) + } + pub fn internal(msg: impl Into) -> Self { + Self::new(ErrorCode::InternalError, msg) + } } impl fmt::Display for AppError { @@ -74,7 +94,9 @@ impl fmt::Display for AppError { impl std::error::Error for AppError {} impl From for String { - fn from(error: AppError) -> Self { error.message } + fn from(error: AppError) -> Self { + error.message + } } impl From for AppError { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fed36b0..1c58987 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod epg; mod error; mod hls; mod live; +mod local_proxy; mod logging; mod network; mod path_safety; @@ -24,6 +25,10 @@ use error::AppError; const MAX_SETTINGS_FILE_SIZE: u64 = 1 * 1024 * 1024; // 1MB const MAX_SETTINGS_VALUE_SIZE: usize = 100 * 1024; // 100KB const MAX_SETTINGS_KEY_LENGTH: usize = 100; +const ALPHA_PLAYBACK_SMOKE_KEY: &str = "__alphaPlaybackSmoke"; +const BETA_CONTINUE_SMOKE_KEY: &str = "__betaContinueSmoke"; +const DEFAULT_ALPHA_PLAYBACK_SMOKE_MEDIA_URL: &str = + "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"; pub struct AppState { pub database: Mutex, @@ -31,6 +36,7 @@ pub struct AppState { pub settings_path: PathBuf, pub settings: Mutex>, pub player_state: Mutex>, + pub local_proxy: local_proxy::LocalProxyInfo, } fn load_settings(path: &PathBuf) -> Map { @@ -73,6 +79,67 @@ fn persist_settings(state: &AppState) -> Result<(), String> { fs::rename(temporary_path, &state.settings_path).map_err(|error| error.to_string()) } +#[cfg(debug_assertions)] +fn debug_data_dir_override() -> Option { + std::env::var_os("IPTV_TEST_DATA_DIR").map(PathBuf::from) +} + +#[cfg(not(debug_assertions))] +fn debug_data_dir_override() -> Option { + None +} + +fn inject_alpha_playback_smoke_settings(settings: &mut Map) { + if std::env::var("IPTV_ALPHA_PLAYBACK_SMOKE").ok().as_deref() != Some("1") { + return; + } + + let media_url = std::env::var("IPTV_ALPHA_PLAYBACK_SMOKE_MEDIA_URL") + .unwrap_or_else(|_| DEFAULT_ALPHA_PLAYBACK_SMOKE_MEDIA_URL.to_string()); + let timeout_ms = std::env::var("IPTV_ALPHA_PLAYBACK_SMOKE_TIMEOUT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(20_000); + + settings.insert( + ALPHA_PLAYBACK_SMOKE_KEY.to_string(), + json!({ + "enabled": true, + "mediaUrl": media_url, + "timeoutMs": timeout_ms + }), + ); + settings.remove("__alphaPlaybackSmokeResult"); +} + +fn inject_beta_continue_smoke_settings(settings: &mut Map) { + if std::env::var("IPTV_BETA_CONTINUE_SMOKE").ok().as_deref() != Some("1") { + return; + } + + let phase = std::env::var("IPTV_BETA_CONTINUE_SMOKE_PHASE") + .unwrap_or_else(|_| "seed".to_string()); + let position_seconds = std::env::var("IPTV_BETA_CONTINUE_SMOKE_POSITION_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(372); + let tolerance_seconds = std::env::var("IPTV_BETA_CONTINUE_SMOKE_TOLERANCE_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(20); + + settings.insert( + BETA_CONTINUE_SMOKE_KEY.to_string(), + json!({ + "enabled": true, + "phase": if phase == "validate" { "validate" } else { "seed" }, + "positionSeconds": position_seconds, + "toleranceSeconds": tolerance_seconds + }), + ); + settings.remove("__betaContinueSmokeResult"); +} + pub fn build_live_tree(channels: Vec) -> Value { let mut countries: Vec = Vec::new(); let mut country_index: std::collections::HashMap = std::collections::HashMap::new(); @@ -344,10 +411,7 @@ fn invoke_ipc( } // Local server - "local:getServerInfo" => Ok(json!({ - "success": true, - "data": { "port": 0, "token": "" } - })), + "local:getServerInfo" => Ok(json!(state.local_proxy.clone())), // DLNA (stubs) "dlna:search" => Ok(json!({ "success": true, "data": [] })), "dlna:cast" | "dlna:control" => { @@ -520,6 +584,10 @@ mod tests { settings_path: path.clone(), settings: Mutex::new(settings_map), player_state: Mutex::new(None), + local_proxy: local_proxy::LocalProxyInfo { + url: "http://127.0.0.1:0".to_string(), + token: "test".to_string(), + }, }; persist_settings(&state).unwrap(); @@ -540,17 +608,22 @@ mod tests { pub fn run() { tauri::Builder::default() .setup(|app| { - let data_dir = app.path().app_data_dir()?; + let data_dir = debug_data_dir_override().unwrap_or(app.path().app_data_dir()?); fs::create_dir_all(&data_dir)?; let settings_path = data_dir.join("settings.json"); let database = Database::open(data_dir.join("iptv.db")).map_err(std::io::Error::other)?; + let local_proxy = local_proxy::start_local_proxy().map_err(std::io::Error::other)?; + let mut settings = load_settings(&settings_path); + inject_alpha_playback_smoke_settings(&mut settings); + inject_beta_continue_smoke_settings(&mut settings); app.manage(AppState { database: Mutex::new(database), config_manager: Mutex::new(config::ConfigManager::new(data_dir.clone())), - settings: Mutex::new(load_settings(&settings_path)), + settings: Mutex::new(settings), settings_path, player_state: Mutex::new(None), + local_proxy, }); // 启动自动刷新后台任务 diff --git a/src-tauri/src/live.rs b/src-tauri/src/live.rs index fa539e6..1a89723 100644 --- a/src-tauri/src/live.rs +++ b/src-tauri/src/live.rs @@ -100,7 +100,9 @@ fn parse_txt_format(content: &str) -> Vec { for raw_line in content.lines() { let line = raw_line.trim(); - if line.is_empty() { continue; } + if line.is_empty() { + continue; + } if line.contains("#genre#") { let comma_idx = line.find(','); @@ -116,11 +118,21 @@ fn parse_txt_format(content: &str) -> Vec { let rest = line[genre_idx + 7..].trim().to_string(); if rest.starts_with('_') && rest.len() > 1 { Some(rest[1..].to_string()) - } else { None } - } else { None } - } else { None }; + } else { + None + } + } else { + None + } + } else { + None + }; - groups.push(Group { name: group_name, pass: after_genre, channel: Vec::new() }); + groups.push(Group { + name: group_name, + pass: after_genre, + channel: Vec::new(), + }); current_group = Some(groups.len() - 1); group_directives = Directives::default(); continue; @@ -147,7 +159,11 @@ fn parse_txt_format(content: &str) -> Vec { } let group_idx = current_group.unwrap_or_else(|| { - groups.push(Group { name: String::new(), pass: None, channel: Vec::new() }); + groups.push(Group { + name: String::new(), + pass: None, + channel: Vec::new(), + }); current_group = Some(groups.len() - 1); current_group.unwrap() }); @@ -168,7 +184,9 @@ fn parse_m3u_format(content: &str) -> Vec { let mut global_catchup: Option = None; let lines: Vec<&str> = content.lines().collect(); - if lines.is_empty() { return groups; } + if lines.is_empty() { + return groups; + } if lines[0].contains("#EXTM3U") { global_catchup = parse_m3u_catchup(lines[0]); @@ -179,11 +197,15 @@ fn parse_m3u_format(content: &str) -> Vec { let mut pending_directives = Directives::default(); let mut start_line = 0; - if lines[0].contains("#EXTM3U") { start_line = 1; } + if lines[0].contains("#EXTM3U") { + start_line = 1; + } for i in start_line..lines.len() { let line = lines[i].trim(); - if line.is_empty() { continue; } + if line.is_empty() { + continue; + } if line.starts_with("#EXTINF:") { let parsed = parse_extinf_line(line); @@ -211,7 +233,9 @@ fn parse_m3u_format(content: &str) -> Vec { if line.contains("://") && pending_channel.is_some() { let (url, inline_headers) = parse_inline_headers(line); let pc = pending_channel.take().unwrap_or_else(|| Channel { - name: String::new(), urls: vec![], ..Default::default() + name: String::new(), + urls: vec![], + ..Default::default() }); let group_name = std::mem::take(&mut pending_group_name); @@ -219,7 +243,11 @@ fn parse_m3u_format(content: &str) -> Vec { Some(&idx) => idx, None => { let idx = groups.len(); - groups.push(Group { name: group_name.clone(), pass: None, channel: Vec::new() }); + groups.push(Group { + name: group_name.clone(), + pass: None, + channel: Vec::new(), + }); group_map.insert(group_name.clone(), idx); idx } @@ -249,7 +277,9 @@ fn parse_m3u_format(content: &str) -> Vec { apply_directives_to_channel(&mut ch, &merged_dirs); if pc.header.is_some() || !inline_headers.is_empty() { let mut h = pc.header.unwrap_or_default(); - for (k, v) in &inline_headers { h.insert(k.clone(), v.clone()); } + for (k, v) in &inline_headers { + h.insert(k.clone(), v.clone()); + } ch.header = Some(h); } @@ -268,48 +298,88 @@ fn parse_json_format(content: &str) -> Vec { Err(_) => return vec![], }; - raw.into_iter().map(|g| { - let name = g.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - let pass = g.get("pass").and_then(Value::as_str).map(String::from); - let channels = g.get("channel").and_then(Value::as_array).map(|arr| { - arr.iter().map(|c| { - let name = c.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - let urls = c.get("urls").and_then(Value::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_default(); - let header = c.get("header").and_then(|v| { - v.as_object().map(|obj| { - obj.iter().map(|(k, v)| { - (k.clone(), v.as_str().unwrap_or("").to_string()) - }).collect() - }) - }); - let catchup = c.get("catchup").map(|v| serde_json::from_value(v.clone()).unwrap_or_default()); - let drm = c.get("drm").map(|v| serde_json::from_value(v.clone()).unwrap_or_default()); - - Channel { - name, - urls, - number: c.get("number").and_then(Value::as_str).map(String::from), - logo: c.get("logo").and_then(Value::as_str).map(String::from), - epg: c.get("epg").and_then(Value::as_str).map(String::from), - ua: c.get("ua").and_then(Value::as_str).map(String::from), - click: c.get("click").and_then(Value::as_str).map(String::from), - format: c.get("format").and_then(Value::as_str).map(String::from), - origin: c.get("origin").and_then(Value::as_str).map(String::from), - referer: c.get("referer").and_then(Value::as_str).map(String::from), - tvg_id: c.get("tvgId").or_else(|| c.get("tvg_id")).and_then(Value::as_str).map(String::from), - tvg_name: c.get("tvgName").or_else(|| c.get("tvg_name")).and_then(Value::as_str).map(String::from), - header, - parse: c.get("parse").and_then(Value::as_i64), - catchup, - drm, - } - }).collect() - }).unwrap_or_default(); - - Group { name, pass, channel: channels } - }).collect() + raw.into_iter() + .map(|g| { + let name = g + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let pass = g.get("pass").and_then(Value::as_str).map(String::from); + let channels = g + .get("channel") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .map(|c| { + let name = c + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let urls = c + .get("urls") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let header = c.get("header").and_then(|v| { + v.as_object().map(|obj| { + obj.iter() + .map(|(k, v)| { + (k.clone(), v.as_str().unwrap_or("").to_string()) + }) + .collect() + }) + }); + let catchup = c + .get("catchup") + .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()); + let drm = c + .get("drm") + .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()); + + Channel { + name, + urls, + number: c.get("number").and_then(Value::as_str).map(String::from), + logo: c.get("logo").and_then(Value::as_str).map(String::from), + epg: c.get("epg").and_then(Value::as_str).map(String::from), + ua: c.get("ua").and_then(Value::as_str).map(String::from), + click: c.get("click").and_then(Value::as_str).map(String::from), + format: c.get("format").and_then(Value::as_str).map(String::from), + origin: c.get("origin").and_then(Value::as_str).map(String::from), + referer: c.get("referer").and_then(Value::as_str).map(String::from), + tvg_id: c + .get("tvgId") + .or_else(|| c.get("tvg_id")) + .and_then(Value::as_str) + .map(String::from), + tvg_name: c + .get("tvgName") + .or_else(|| c.get("tvg_name")) + .and_then(Value::as_str) + .map(String::from), + header, + parse: c.get("parse").and_then(Value::as_i64), + catchup, + drm, + } + }) + .collect() + }) + .unwrap_or_default(); + + Group { + name, + pass, + channel: channels, + } + }) + .collect() } // ==================== 辅助函数 ==================== @@ -317,57 +387,116 @@ fn parse_json_format(content: &str) -> Vec { impl Default for Channel { fn default() -> Self { Self { - name: String::new(), urls: vec![], - number: None, logo: None, epg: None, - ua: None, click: None, format: None, - origin: None, referer: None, - tvg_id: None, tvg_name: None, - header: None, parse: None, - catchup: None, drm: None, + name: String::new(), + urls: vec![], + number: None, + logo: None, + epg: None, + ua: None, + click: None, + format: None, + origin: None, + referer: None, + tvg_id: None, + tvg_name: None, + header: None, + parse: None, + catchup: None, + drm: None, } } } impl Default for Catchup { - fn default() -> Self { Self { catchup_type: None, source: None, replace: None } } + fn default() -> Self { + Self { + catchup_type: None, + source: None, + replace: None, + } + } } impl Default for Drm { - fn default() -> Self { Self { drm_type: String::new(), key: String::new(), header: None } } + fn default() -> Self { + Self { + drm_type: String::new(), + key: String::new(), + header: None, + } + } } fn parse_directive(line: &str) -> Option { if line.starts_with("ua=") { - Some(Directives { ua: Some(line[3..].trim().to_string()), ..Default::default() }) + Some(Directives { + ua: Some(line[3..].trim().to_string()), + ..Default::default() + }) } else if line.starts_with("origin=") { - Some(Directives { origin: Some(line[7..].trim().to_string()), ..Default::default() }) + Some(Directives { + origin: Some(line[7..].trim().to_string()), + ..Default::default() + }) } else if line.starts_with("referer=") || line.starts_with("referrer=") { let eq_idx = line.find('=')?; - Some(Directives { referer: Some(line[eq_idx + 1..].trim().to_string()), ..Default::default() }) + Some(Directives { + referer: Some(line[eq_idx + 1..].trim().to_string()), + ..Default::default() + }) } else if line.starts_with("header=") { let header = serde_json::from_str::>(line[7..].trim()).ok(); - Some(Directives { header, ..Default::default() }) + Some(Directives { + header, + ..Default::default() + }) } else if line.starts_with("format=") { - Some(Directives { format: Some(map_format(line[7..].trim())), ..Default::default() }) + Some(Directives { + format: Some(map_format(line[7..].trim())), + ..Default::default() + }) } else if line.starts_with("parse=") { - Some(Directives { parse: line[6..].trim().parse::().ok(), ..Default::default() }) + Some(Directives { + parse: line[6..].trim().parse::().ok(), + ..Default::default() + }) } else if line.starts_with("click=") { - Some(Directives { click: Some(line[6..].trim().to_string()), ..Default::default() }) + Some(Directives { + click: Some(line[6..].trim().to_string()), + ..Default::default() + }) } else if line.starts_with("forceKey=") { - Some(Directives { force_key: line[9..].trim() == "true", ..Default::default() }) + Some(Directives { + force_key: line[9..].trim() == "true", + ..Default::default() + }) } else { None } } fn merge_directives(target: &mut Directives, src: &Directives) { - if src.ua.is_some() { target.ua = src.ua.clone(); } - if src.origin.is_some() { target.origin = src.origin.clone(); } - if src.referer.is_some() { target.referer = src.referer.clone(); } - if src.format.is_some() { target.format = src.format.clone(); } - if src.parse.is_some() { target.parse = src.parse; } - if src.click.is_some() { target.click = src.click.clone(); } - if src.force_key { target.force_key = true; } + if src.ua.is_some() { + target.ua = src.ua.clone(); + } + if src.origin.is_some() { + target.origin = src.origin.clone(); + } + if src.referer.is_some() { + target.referer = src.referer.clone(); + } + if src.format.is_some() { + target.format = src.format.clone(); + } + if src.parse.is_some() { + target.parse = src.parse; + } + if src.click.is_some() { + target.click = src.click.clone(); + } + if src.force_key { + target.force_key = true; + } if let Some(ref h) = src.header { merge_header(target, h); } @@ -375,7 +504,9 @@ fn merge_directives(target: &mut Directives, src: &Directives) { fn merge_header(dirs: &mut Directives, headers: &HashMap) { let mut h = dirs.header.take().unwrap_or_default(); - for (k, v) in headers { h.insert(k.clone(), v.clone()); } + for (k, v) in headers { + h.insert(k.clone(), v.clone()); + } dirs.header = Some(h); } @@ -393,15 +524,25 @@ fn parse_channel_urls(name: &str, url_part: &str, directives: &Directives) -> Ch for segment in url_part.split('#') { let trimmed = segment.trim(); - if trimmed.is_empty() { continue; } + if trimmed.is_empty() { + continue; + } let (url, headers) = parse_inline_headers(trimmed); urls.push(url); - for (k, v) in headers { all_headers.insert(k, v); } + for (k, v) in headers { + all_headers.insert(k, v); + } } - let mut ch = Channel { name: name.to_string(), urls, ..Default::default() }; + let mut ch = Channel { + name: name.to_string(), + urls, + ..Default::default() + }; apply_directives_to_channel(&mut ch, directives); - if !all_headers.is_empty() { ch.header = Some(all_headers); } + if !all_headers.is_empty() { + ch.header = Some(all_headers); + } ch } @@ -422,39 +563,67 @@ fn parse_inline_headers(url: &str) -> (String, HashMap) { }; let key = decode_inline_part(&pair[..eq_idx]); let value = decode_inline_part(&pair[eq_idx + 1..]); - if !key.is_empty() { headers.insert(key, value); } + if !key.is_empty() { + headers.insert(key, value); + } } (actual_url, headers) } fn decode_inline_part(value: &str) -> String { - urlencoding::decode(value).map(|s| s.to_string()).unwrap_or_else(|_| value.to_string()) + urlencoding::decode(value) + .map(|s| s.to_string()) + .unwrap_or_else(|_| value.to_string()) } fn apply_directives_to_channel(ch: &mut Channel, dirs: &Directives) { - if let Some(ref ua) = dirs.ua { ch.ua = Some(ua.clone()); } - if let Some(ref origin) = dirs.origin { ch.origin = Some(origin.clone()); } - if let Some(ref referer) = dirs.referer { ch.referer = Some(referer.clone()); } - if let Some(ref format) = dirs.format { ch.format = Some(format.clone()); } - if let Some(parse) = dirs.parse { ch.parse = Some(parse); } - if let Some(ref click) = dirs.click { ch.click = Some(click.clone()); } + if let Some(ref ua) = dirs.ua { + ch.ua = Some(ua.clone()); + } + if let Some(ref origin) = dirs.origin { + ch.origin = Some(origin.clone()); + } + if let Some(ref referer) = dirs.referer { + ch.referer = Some(referer.clone()); + } + if let Some(ref format) = dirs.format { + ch.format = Some(format.clone()); + } + if let Some(parse) = dirs.parse { + ch.parse = Some(parse); + } + if let Some(ref click) = dirs.click { + ch.click = Some(click.clone()); + } if let Some(ref header) = dirs.header { let mut h = ch.header.take().unwrap_or_default(); - for (k, v) in header { h.insert(k.clone(), v.clone()); } + for (k, v) in header { + h.insert(k.clone(), v.clone()); + } ch.header = Some(h); } } fn merge_channels(target: &mut Channel, src: &Channel) { - if src.ua.is_some() { target.ua = src.ua.clone(); } - if src.referer.is_some() { target.referer = src.referer.clone(); } - if src.origin.is_some() { target.origin = src.origin.clone(); } - if src.drm.is_some() { target.drm = src.drm.clone(); } + if src.ua.is_some() { + target.ua = src.ua.clone(); + } + if src.referer.is_some() { + target.referer = src.referer.clone(); + } + if src.origin.is_some() { + target.origin = src.origin.clone(); + } + if src.drm.is_some() { + target.drm = src.drm.clone(); + } if src.header.is_some() { let mut h = target.header.take().unwrap_or_default(); if let Some(ref sh) = src.header { - for (k, v) in sh { h.insert(k.clone(), v.clone()); } + for (k, v) in sh { + h.insert(k.clone(), v.clone()); + } } target.header = Some(h); } @@ -463,7 +632,8 @@ fn merge_channels(target: &mut Channel, src: &Channel) { fn extract_attr(line: &str, attr: &str) -> Option { let pattern = format!(r#"{}=["']([^"']*)["']"#, regex::escape(attr)); let re = regex::Regex::new(&pattern).ok()?; - re.captures(line).and_then(|c| c.get(1).map(|m| m.as_str().to_string())) + re.captures(line) + .and_then(|c| c.get(1).map(|m| m.as_str().to_string())) } fn parse_extinf_line(line: &str) -> M3uParsedLine { @@ -495,7 +665,10 @@ fn parse_extinf_line(line: &str) -> M3uParsedLine { }); } - M3uParsedLine { channel: Some(channel), group_name } + M3uParsedLine { + channel: Some(channel), + group_name, + } } fn parse_m3u_catchup(line: &str) -> Option { @@ -503,9 +676,15 @@ fn parse_m3u_catchup(line: &str) -> Option { let catchup_source = extract_attr(line, "catchup-source"); let catchup_replace = extract_attr(line, "catchup-replace"); - if catchup_type.is_none() && catchup_source.is_none() { return None; } + if catchup_type.is_none() && catchup_source.is_none() { + return None; + } - Some(Catchup { catchup_type, source: catchup_source, replace: catchup_replace }) + Some(Catchup { + catchup_type, + source: catchup_source, + replace: catchup_replace, + }) } struct M3uParsedLine { @@ -521,26 +700,41 @@ struct M3uDirectiveResult { fn parse_m3u_directive(line: &str) -> Option { if line.starts_with("#EXTHTTP:") { let header: HashMap = serde_json::from_str(&line[9..].trim()).ok()?; - return Some(M3uDirectiveResult { channel: Some(Channel { header: Some(header), ..Default::default() }), directives: None }); + return Some(M3uDirectiveResult { + channel: Some(Channel { + header: Some(header), + ..Default::default() + }), + directives: None, + }); } if line.starts_with("#EXTVLCOPT:http-user-agent=") { return Some(M3uDirectiveResult { - channel: Some(Channel { ua: Some(line[27..].trim().to_string()), ..Default::default() }), + channel: Some(Channel { + ua: Some(line[27..].trim().to_string()), + ..Default::default() + }), directives: None, }); } if line.starts_with("#EXTVLCOPT:http-referrer=") { return Some(M3uDirectiveResult { - channel: Some(Channel { referer: Some(line[25..].trim().to_string()), ..Default::default() }), + channel: Some(Channel { + referer: Some(line[25..].trim().to_string()), + ..Default::default() + }), directives: None, }); } if line.starts_with("#EXTVLCOPT:http-origin=") { return Some(M3uDirectiveResult { - channel: Some(Channel { origin: Some(line[22..].trim().to_string()), ..Default::default() }), + channel: Some(Channel { + origin: Some(line[22..].trim().to_string()), + ..Default::default() + }), directives: None, }); } @@ -550,27 +744,44 @@ fn parse_m3u_directive(line: &str) -> Option { } if let Some(dirs) = parse_directive(line) { - return Some(M3uDirectiveResult { channel: None, directives: Some(dirs) }); + return Some(M3uDirectiveResult { + channel: None, + directives: Some(dirs), + }); } None } fn parse_kodi_prop(prop: &str) -> M3uDirectiveResult { - let mut result = M3uDirectiveResult { channel: None, directives: None }; + let mut result = M3uDirectiveResult { + channel: None, + directives: None, + }; if prop.starts_with("inputstream.adaptive.license_type=") { let drm_type = prop[34..].trim(); result.channel = Some(Channel { - drm: Some(Drm { drm_type: drm_type.to_string(), key: String::new(), header: None }), + drm: Some(Drm { + drm_type: drm_type.to_string(), + key: String::new(), + header: None, + }), ..Default::default() }); } else if prop.starts_with("inputstream.adaptive.license_key=") { let license_key = prop[33..].trim(); let pipe_idx = license_key.find('|'); - let key = pipe_idx.map(|i| &license_key[..i]).unwrap_or(license_key).to_string(); - - let mut drm = Drm { drm_type: String::new(), key, header: None }; + let key = pipe_idx + .map(|i| &license_key[..i]) + .unwrap_or(license_key) + .to_string(); + + let mut drm = Drm { + drm_type: String::new(), + key, + header: None, + }; if let Some(pi) = pipe_idx { let header_part = &license_key[pi + 1..]; let mut header = HashMap::new(); @@ -588,11 +799,16 @@ fn parse_kodi_prop(prop: &str) -> M3uDirectiveResult { if let Some(ref mut ch) = result.channel { if let Some(ref mut d) = ch.drm { d.key = drm.key.clone(); - if drm.header.is_some() { d.header = drm.header.clone(); } + if drm.header.is_some() { + d.header = drm.header.clone(); + } } } } else { - result.channel = Some(Channel { drm: Some(drm), ..Default::default() }); + result.channel = Some(Channel { + drm: Some(drm), + ..Default::default() + }); } } else if prop.starts_with("inputstream.adaptive.drm_legacy=") { let legacy = prop[32..].trim(); @@ -609,7 +825,10 @@ fn parse_kodi_prop(prop: &str) -> M3uDirectiveResult { } } else if prop.starts_with("inputstream.adaptive.manifest_type=") { let manifest_type = map_format(prop[35..].trim()); - result.directives = Some(Directives { format: Some(manifest_type), ..Default::default() }); + result.directives = Some(Directives { + format: Some(manifest_type), + ..Default::default() + }); } else if prop.starts_with("inputstream.adaptive.stream_headers=") || prop.starts_with("inputstream.adaptive.common_headers=") { @@ -627,14 +846,20 @@ fn parse_kodi_prop(prop: &str) -> M3uDirectiveResult { match k.as_str() { "drmScheme" => drm_type = Some(v), "drmLicense" => drm_key = Some(v), - _ => { header.insert(k, v); } + _ => { + header.insert(k, v); + } } } } } - let mut ch = Channel { ..Default::default() }; - if !header.is_empty() { ch.header = Some(header); } + let mut ch = Channel { + ..Default::default() + }; + if !header.is_empty() { + ch.header = Some(header); + } if drm_type.is_some() || drm_key.is_some() { ch.drm = Some(Drm { drm_type: drm_type.unwrap_or_default(), @@ -663,7 +888,9 @@ const GET_PREFERRED_EXTENSIONS: &[&str] = &["m3u8", "m3u", "mpd", "xml", "json", fn should_prefer_get(url: &str) -> bool { let path = url.split('?').next().unwrap_or(url); - GET_PREFERRED_EXTENSIONS.iter().any(|ext| path.ends_with(ext)) + GET_PREFERRED_EXTENSIONS + .iter() + .any(|ext| path.ends_with(ext)) } /// 测试单个 URL 连通性 @@ -674,10 +901,21 @@ pub async fn test_url(url: &str, timeout_ms: u64) -> TestResult { .build() { Ok(c) => c, - Err(e) => return TestResult { url: url.to_string(), alive: false, latency: -1, error: Some(e.to_string()) }, + Err(e) => { + return TestResult { + url: url.to_string(), + alive: false, + latency: -1, + error: Some(e.to_string()), + } + } }; - let method = if should_prefer_get(url) { "GET" } else { "HEAD" }; + let method = if should_prefer_get(url) { + "GET" + } else { + "HEAD" + }; let result = if method == "GET" { client.get(url).send().await @@ -701,19 +939,25 @@ pub async fn test_url(url: &str, timeout_ms: u64) -> TestResult { url: url.to_string(), alive: status < 400, latency, - error: if status >= 400 { Some(format!("HTTP {}", status)) } else { None }, - } - } - Err(e) => { - TestResult { - url: url.to_string(), - alive: false, - latency: -1, - error: if e.is_timeout() { Some("Timeout".to_string()) } - else if e.is_connect() { Some(format!("Connect error: {}", e)) } - else { Some(e.to_string()) }, + error: if status >= 400 { + Some(format!("HTTP {}", status)) + } else { + None + }, } } + Err(e) => TestResult { + url: url.to_string(), + alive: false, + latency: -1, + error: if e.is_timeout() { + Some("Timeout".to_string()) + } else if e.is_connect() { + Some(format!("Connect error: {}", e)) + } else { + Some(e.to_string()) + }, + }, } } @@ -731,19 +975,32 @@ pub fn normalize_name(name: &str) -> String { pub fn dedup_channels(groups: Vec) -> Vec { let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - groups.into_iter().map(|mut group| { - group.channel = group.channel.into_iter() - .filter(|ch| seen.insert(normalize_name(&ch.name))) - .collect(); - group - }).filter(|g| !g.channel.is_empty()).collect() + groups + .into_iter() + .map(|mut group| { + group.channel = group + .channel + .into_iter() + .filter(|ch| seen.insert(normalize_name(&ch.name))) + .collect(); + group + }) + .filter(|g| !g.channel.is_empty()) + .collect() } // ==================== 分类器 ==================== /// 国家/地区代码 #[derive(Debug, Clone, Copy, PartialEq)] -pub enum Country { China, Uk, Us, Japan, Korea, Other } +pub enum Country { + China, + Uk, + Us, + Japan, + Korea, + Other, +} /// 频道分类 #[derive(Debug, Clone)] @@ -772,7 +1029,8 @@ pub fn classify_channel(name: &str) -> (Country, String, f64) { // 检测中国频道 (CCTV 系列) if lower.contains("cctv") || lower.starts_with("cctv") { - let num: f64 = lower.chars() + let num: f64 = lower + .chars() .skip_while(|c| !c.is_ascii_digit()) .take_while(|c| c.is_ascii_digit() || *c == '.') .collect::() @@ -787,7 +1045,8 @@ pub fn classify_channel(name: &str) -> (Country, String, f64) { // CCTV 子台 (CCTV-1, CCTV-5 等) if lower.starts_with("cctv") || lower.contains("cctv") { - let num: f64 = lower.trim_start_matches("cctv") + let num: f64 = lower + .trim_start_matches("cctv") .trim_start_matches('-') .trim_start_matches(' ') .chars() @@ -804,34 +1063,101 @@ pub fn classify_channel(name: &str) -> (Country, String, f64) { } // 地方台 - let local_keywords = ["上海", "北京", "广东", "深圳", "浙江", "江苏", "湖南", - "湖北", "四川", "山东", "福建", "天津", "重庆", "安徽", - "辽宁", "河南", "河北", "陕西", "云南", "贵州", "广西", - "江西", "山西", "吉林", "黑龙江", "内蒙古", "新疆", "甘肃", - "海南", "宁夏", "青海", "西藏", "channel", "本地"]; + let local_keywords = [ + "上海", + "北京", + "广东", + "深圳", + "浙江", + "江苏", + "湖南", + "湖北", + "四川", + "山东", + "福建", + "天津", + "重庆", + "安徽", + "辽宁", + "河南", + "河北", + "陕西", + "云南", + "贵州", + "广西", + "江西", + "山西", + "吉林", + "黑龙江", + "内蒙古", + "新疆", + "甘肃", + "海南", + "宁夏", + "青海", + "西藏", + "channel", + "本地", + ]; if local_keywords.iter().any(|k| lower.contains(k)) { return (Country::China, "地方".to_string(), 0.0); } // 英国频道 - let uk_keywords = ["bbc", "itv", "channel 4", "channel 5", "sky news", - "dave", "drama", "film4", "uktv", "british"]; + let uk_keywords = [ + "bbc", + "itv", + "channel 4", + "channel 5", + "sky news", + "dave", + "drama", + "film4", + "uktv", + "british", + ]; if uk_keywords.iter().any(|k| lower.contains(k)) || lower.ends_with(".uk") { return (Country::Uk, guess_category(&lower).to_string(), 0.0); } // 美国频道 - let us_keywords = ["cnn", "fox", "nbc", "abc", "cbs", "hbo", "discovery", - "national geographic", "history channel", "espn", "mtv", - "comedy central", "tlc", "usa network", "pbs", "nfl", - "nba", "mlb", "nhl"]; + let us_keywords = [ + "cnn", + "fox", + "nbc", + "abc", + "cbs", + "hbo", + "discovery", + "national geographic", + "history channel", + "espn", + "mtv", + "comedy central", + "tlc", + "usa network", + "pbs", + "nfl", + "nba", + "mlb", + "nhl", + ]; if us_keywords.iter().any(|k| lower.contains(k)) { return (Country::Us, guess_category(&lower).to_string(), 0.0); } // 日本频道 - let jp_keywords = ["nhk", "tv asahi", "fuji tv", "tbs", "tv tokyo", "ntv", - "japan", "東京", "テレビ"]; + let jp_keywords = [ + "nhk", + "tv asahi", + "fuji tv", + "tbs", + "tv tokyo", + "ntv", + "japan", + "東京", + "テレビ", + ]; if jp_keywords.iter().any(|k| lower.contains(k)) { return (Country::Japan, guess_category(&lower).to_string(), 0.0); } @@ -848,12 +1174,37 @@ pub fn classify_channel(name: &str) -> (Country, String, f64) { fn guess_category(name: &str) -> &str { let lower = name.to_lowercase(); let categories: &[(&[&str], &str)] = &[ - (&["news", "bbc", "cnn", "nbc", "abc", "cbs", "pbs", "itv", "sky", "新闻", "報導"], "News"), - (&["sport", "espn", "nfl", "nba", "mlb", "nhl", "体育", "運動", "赛事"], "Sports"), + ( + &[ + "news", "bbc", "cnn", "nbc", "abc", "cbs", "pbs", "itv", "sky", "新闻", "報導", + ], + "News", + ), + ( + &[ + "sport", "espn", "nfl", "nba", "mlb", "nhl", "体育", "運動", "赛事", + ], + "Sports", + ), (&["movie", "film", "hbo", "电影", "影院", "影视"], "Movies"), (&["music", "mtv", "音乐", "音樂", "mv"], "Music"), - (&["kids", "children", "disney", "cartoon", "儿童", "卡通", "少儿"], "Kids"), - (&["document", "discovery", "national geographic", "history", "纪录片", "纪实"], "Documentary"), + ( + &[ + "kids", "children", "disney", "cartoon", "儿童", "卡通", "少儿", + ], + "Kids", + ), + ( + &[ + "document", + "discovery", + "national geographic", + "history", + "纪录片", + "纪实", + ], + "Documentary", + ), (&["drama", "剧集", "电视剧", "连续剧"], "Drama"), (&["entertain", "综艺", "娱乐", "真人秀"], "Entertainment"), (&["education", "学习", "教育", "教学"], "Education"), @@ -1015,12 +1366,22 @@ mod tests { #[test] fn dedup_removes_duplicates() { - let groups = vec![ - Group { name: "CCTV".to_string(), pass: None, channel: vec![ - Channel { name: "CCTV-1".to_string(), urls: vec!["http://a.com".to_string()], ..Default::default() }, - Channel { name: "cctv 1".to_string(), urls: vec!["http://b.com".to_string()], ..Default::default() }, - ]}, - ]; + let groups = vec![Group { + name: "CCTV".to_string(), + pass: None, + channel: vec![ + Channel { + name: "CCTV-1".to_string(), + urls: vec!["http://a.com".to_string()], + ..Default::default() + }, + Channel { + name: "cctv 1".to_string(), + urls: vec!["http://b.com".to_string()], + ..Default::default() + }, + ], + }]; let result = dedup_channels(groups); assert_eq!(result[0].channel.len(), 1); } diff --git a/src-tauri/src/local_proxy.rs b/src-tauri/src/local_proxy.rs new file mode 100644 index 0000000..21399b2 --- /dev/null +++ b/src-tauri/src/local_proxy.rs @@ -0,0 +1,473 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; + +use crate::hls::rewrite_hls_playlist; + +const MAX_REQUEST_BYTES: usize = 16 * 1024; +const MAX_PROXY_ITEMS: usize = 10_000; + +static PROXY_RUNTIME: OnceLock> = OnceLock::new(); +static PROXY_CLIENT: OnceLock> = OnceLock::new(); +static PROXY_ITEMS: OnceLock>> = OnceLock::new(); +static PROXY_ITEM_COUNTER: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +struct ProxyItem { + url: String, + headers: HashMap, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalProxyInfo { + pub url: String, + pub token: String, +} + +pub fn start_local_proxy() -> Result { + let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?; + let addr = listener.local_addr().map_err(|error| error.to_string())?; + let token = make_token(); + let info = LocalProxyInfo { + url: format!("http://{}", addr), + token, + }; + let shared_info = Arc::new(info.clone()); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { + continue; + }; + let info = Arc::clone(&shared_info); + std::thread::spawn(move || { + let _ = handle_stream(stream, &info); + }); + } + }); + + Ok(info) +} + +fn make_token() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + format!("{:x}{:x}", nanos, std::process::id()) +} + +fn trace_enabled() -> bool { + std::env::var("IPTV_LOCAL_PROXY_TRACE").ok().as_deref() == Some("1") +} + +fn trace(message: impl AsRef) { + if trace_enabled() { + eprintln!("[local_proxy] {}", message.as_ref()); + } +} + +fn proxy_runtime() -> Result<&'static tokio::runtime::Runtime, String> { + PROXY_RUNTIME + .get_or_init(|| tokio::runtime::Runtime::new().map_err(|error| error.to_string())) + .as_ref() + .map_err(Clone::clone) +} + +fn proxy_client() -> Result { + PROXY_CLIENT + .get_or_init(|| crate::network::create_client().map_err(|error| error.to_string())) + .clone() +} + +fn proxy_items() -> &'static Mutex> { + PROXY_ITEMS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn register_proxy_item( + info: &LocalProxyInfo, + url: &str, + headers: &HashMap, +) -> String { + let id = PROXY_ITEM_COUNTER.fetch_add(1, Ordering::Relaxed).to_string(); + if let Ok(mut items) = proxy_items().lock() { + if items.len() >= MAX_PROXY_ITEMS { + let overflow = items.len() + 1 - MAX_PROXY_ITEMS; + let keys: Vec = items.keys().take(overflow).cloned().collect(); + for key in keys { + items.remove(&key); + } + } + items.insert( + id.clone(), + ProxyItem { + url: url.to_string(), + headers: headers.clone(), + }, + ); + } + format!( + "{}/stream?token={}&id={}", + info.url, + urlencoding::encode(&info.token), + urlencoding::encode(&id) + ) +} + +fn get_proxy_item(id: &str) -> Option { + proxy_items() + .lock() + .ok() + .and_then(|items| items.get(id).cloned()) +} + +fn handle_stream(mut stream: TcpStream, info: &LocalProxyInfo) -> Result<(), String> { + let mut buffer = [0_u8; MAX_REQUEST_BYTES]; + let size = stream + .read(&mut buffer) + .map_err(|error| error.to_string())?; + let request = String::from_utf8_lossy(&buffer[..size]); + let Some(first_line) = request.lines().next() else { + write_response( + &mut stream, + 400, + "text/plain; charset=utf-8", + b"Bad Request", + )?; + return Ok(()); + }; + + if first_line.starts_with("OPTIONS ") { + write_options_response(&mut stream)?; + return Ok(()); + } + + let mut parts = first_line.split_whitespace(); + let method = parts.next().unwrap_or_default(); + let target = parts.next().unwrap_or_default(); + if method != "GET" { + write_response( + &mut stream, + 405, + "text/plain; charset=utf-8", + b"Method Not Allowed", + )?; + return Ok(()); + } + + let (path, query) = target + .split_once('?') + .map(|(path, query)| (path, query)) + .unwrap_or((target, "")); + if path != "/stream" { + write_response(&mut stream, 404, "text/plain; charset=utf-8", b"Not Found")?; + return Ok(()); + } + + let request_headers = parse_request_headers(&request); + let query = parse_query(query); + if query.get("token").map(String::as_str) != Some(info.token.as_str()) { + write_response(&mut stream, 403, "text/plain; charset=utf-8", b"Forbidden")?; + return Ok(()); + } + + let query_headers = query + .get("header") + .and_then(|value| serde_json::from_str::>(value).ok()) + .unwrap_or_default(); + let (url, headers) = if let Some(id) = query.get("id") { + let Some(item) = get_proxy_item(id) else { + write_response( + &mut stream, + 404, + "text/plain; charset=utf-8", + b"Proxy item not found", + )?; + return Ok(()); + }; + (item.url, item.headers) + } else { + let Some(url) = query.get("url").filter(|url| is_allowed_media_url(url)) else { + write_response( + &mut stream, + 400, + "text/plain; charset=utf-8", + b"Invalid URL", + )?; + return Ok(()); + }; + (url.clone(), query_headers) + }; + trace(format!( + "{} {} range={}", + method, + url, + request_headers.get("range").map(String::as_str).unwrap_or("-") + )); + + if let Err(error) = proxy_media(&mut stream, info, &url, &headers, &request_headers) { + let message = format!("Proxy Error: {}", error); + write_response( + &mut stream, + 502, + "text/plain; charset=utf-8", + message.as_bytes(), + )?; + } + + Ok(()) +} + +fn proxy_media( + stream: &mut TcpStream, + info: &LocalProxyInfo, + url: &str, + headers: &HashMap, + request_headers: &HashMap, +) -> Result<(), String> { + let rt = proxy_runtime()?; + rt.block_on(async move { + let client = proxy_client()?; + let mut request = client.get(url); + for (key, value) in headers { + request = request.header(key, value); + } + if let Some(range) = request_headers.get("range") { + request = request.header(reqwest::header::RANGE, range); + } + if let Some(if_range) = request_headers.get("if-range") { + request = request.header(reqwest::header::IF_RANGE, if_range); + } + let response = request.send().await.map_err(|error| error.to_string())?; + let status = response.status(); + trace(format!("upstream {} -> HTTP {}", url, status.as_u16())); + if !status.is_success() { + return Err(format!("HTTP {}", status.as_u16())); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + let content_range = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()) + .map(String::from); + let accept_ranges = response + .headers() + .get(reqwest::header::ACCEPT_RANGES) + .and_then(|value| value.to_str().ok()) + .map(String::from); + let content_length = response.content_length(); + trace(format!( + "headers {} type={} length={} range={} accept_ranges={}", + url, + content_type, + content_length + .map(|length| length.to_string()) + .unwrap_or_else(|| "-".to_string()), + content_range.as_deref().unwrap_or("-"), + accept_ranges.as_deref().unwrap_or("-") + )); + let is_hls_playlist = url.to_lowercase().contains(".m3u8") + || content_type.contains("mpegurl") + || content_type.contains("vnd.apple"); + if is_hls_playlist { + let bytes = response + .bytes() + .await + .map_err(|error| error.to_string())? + .to_vec(); + let text = String::from_utf8_lossy(&bytes); + let body = rewrite_hls_playlist(&text, url, |item_url| { + register_proxy_item(info, item_url, headers) + }) + .into_bytes(); + trace(format!("playlist {} bytes={}", url, body.len())); + return write_response(stream, 200, &content_type, &body); + } + + write_media_headers( + stream, + status.as_u16(), + &content_type, + content_length, + content_range.as_deref(), + accept_ranges.as_deref(), + )?; + let mut response = response; + let mut streamed_bytes: u64 = 0; + while let Some(chunk) = response.chunk().await.map_err(|error| error.to_string())? { + streamed_bytes += chunk.len() as u64; + if content_length.is_some() { + stream.write_all(&chunk).map_err(|error| error.to_string())?; + } else { + write_chunk(stream, &chunk)?; + } + } + if content_length.is_none() { + stream + .write_all(b"0\r\n\r\n") + .map_err(|error| error.to_string())?; + } + trace(format!("media {} streamed_bytes={}", url, streamed_bytes)); + Ok(()) + }) +} + +fn parse_query(query: &str) -> HashMap { + url::form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect() +} + +fn parse_request_headers(request: &str) -> HashMap { + request + .lines() + .skip(1) + .take_while(|line| !line.trim().is_empty()) + .filter_map(|line| { + let (key, value) = line.split_once(':')?; + Some((key.trim().to_ascii_lowercase(), value.trim().to_string())) + }) + .collect() +} + +fn is_allowed_media_url(url: &str) -> bool { + url.starts_with("http://") || url.starts_with("https://") +} + +fn write_options_response(stream: &mut TcpStream) -> Result<(), String> { + stream + .write_all( + b"HTTP/1.1 204 No Content\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: GET, OPTIONS\r\n\ + Access-Control-Allow-Headers: *\r\n\ + Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges, Content-Type\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .map_err(|error| error.to_string()) +} + +fn write_response( + stream: &mut TcpStream, + status: u16, + content_type: &str, + body: &[u8], +) -> Result<(), String> { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 502 => "Bad Gateway", + _ => "Error", + }; + let header = format!( + "HTTP/1.1 {} {}\r\n\ + Content-Type: {}\r\n\ + Content-Length: {}\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges, Content-Type\r\n\ + Cache-Control: no-store\r\n\ + Connection: close\r\n\r\n", + status, + reason, + content_type, + body.len() + ); + stream + .write_all(header.as_bytes()) + .and_then(|_| stream.write_all(body)) + .map_err(|error| error.to_string()) +} + +fn write_media_headers( + stream: &mut TcpStream, + status: u16, + content_type: &str, + content_length: Option, + content_range: Option<&str>, + accept_ranges: Option<&str>, +) -> Result<(), String> { + let reason = match status { + 200 => "OK", + 206 => "Partial Content", + _ => "OK", + }; + let mut header = format!( + "HTTP/1.1 {} {}\r\n\ + Content-Type: {}\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges, Content-Type\r\n\ + Cache-Control: no-store\r\n", + status, + reason, + content_type + ); + if let Some(content_length) = content_length { + header.push_str(&format!("Content-Length: {}\r\n", content_length)); + } else { + header.push_str("Transfer-Encoding: chunked\r\n"); + } + if let Some(content_range) = content_range { + header.push_str(&format!("Content-Range: {}\r\n", content_range)); + } + if let Some(accept_ranges) = accept_ranges { + header.push_str(&format!("Accept-Ranges: {}\r\n", accept_ranges)); + } + header.push_str("Connection: close\r\n\r\n"); + stream + .write_all(header.as_bytes()) + .map_err(|error| error.to_string()) +} + +fn write_chunk(stream: &mut TcpStream, chunk: &[u8]) -> Result<(), String> { + write!(stream, "{:x}\r\n", chunk.len()).map_err(|error| error.to_string())?; + stream.write_all(chunk).map_err(|error| error.to_string())?; + stream.write_all(b"\r\n").map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{is_allowed_media_url, parse_query, parse_request_headers}; + + #[test] + fn parses_query_values() { + let query = parse_query("token=abc&url=https%3A%2F%2Fexample.com%2Fa.m3u8"); + assert_eq!(query.get("token").map(String::as_str), Some("abc")); + assert_eq!( + query.get("url").map(String::as_str), + Some("https://example.com/a.m3u8") + ); + } + + #[test] + fn only_allows_http_media_urls() { + assert!(is_allowed_media_url("https://example.com/a.m3u8")); + assert!(is_allowed_media_url("http://example.com/a.m3u8")); + assert!(!is_allowed_media_url("file:///tmp/a.m3u8")); + assert!(!is_allowed_media_url("data:text/plain,hello")); + } + + #[test] + fn parses_range_request_header() { + let request = + "GET /stream?token=abc HTTP/1.1\r\nHost: 127.0.0.1\r\nRange: bytes=10-99\r\n\r\n"; + let headers = parse_request_headers(request); + assert_eq!( + headers.get("range").map(String::as_str), + Some("bytes=10-99") + ); + } +} diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs index 60871d0..a93c286 100644 --- a/src-tauri/src/network.rs +++ b/src-tauri/src/network.rs @@ -22,19 +22,15 @@ pub fn create_client() -> Result { /// 带超时和大小限制的 GET 请求 pub async fn http_get(client: &Client, url: &str) -> Result { - let response = client - .get(url) - .send() - .await - .map_err(|e| { - if e.is_timeout() { - AppError::timeout(format!("请求超时: {}", url)) - } else if e.is_connect() { - AppError::network_error(format!("无法连接: {}", url)) - } else { - AppError::network_error(format!("请求失败: {}", url)).with_internal(e.to_string()) - } - })?; + let response = client.get(url).send().await.map_err(|e| { + if e.is_timeout() { + AppError::timeout(format!("请求超时: {}", url)) + } else if e.is_connect() { + AppError::network_error(format!("无法连接: {}", url)) + } else { + AppError::network_error(format!("请求失败: {}", url)).with_internal(e.to_string()) + } + })?; let status = response.status(); if !status.is_success() { @@ -64,9 +60,8 @@ pub async fn http_get(client: &Client, url: &str) -> Result { /// 带超时和大小限制的 GET 请求,返回 JSON pub async fn http_get_json(client: &Client, url: &str) -> Result { let text = http_get(client, url).await?; - serde_json::from_str(&text).map_err(|e| { - AppError::parse_error("响应不是合法 JSON").with_internal(e.to_string()) - }) + serde_json::from_str(&text) + .map_err(|e| AppError::parse_error("响应不是合法 JSON").with_internal(e.to_string())) } /// 带额外 header 的 GET 请求 @@ -195,8 +190,11 @@ pub fn safe_json_parse(text: &str) -> Result { // 清理后重试 let cleaned = clean_json_text(text); serde_json::from_str(&cleaned).map_err(|e| { - AppError::parse_error("JSON 格式错误,请检查内容是否完整") - .with_internal(format!("{}. cleaned_length={}", e, cleaned.len())) + AppError::parse_error("JSON 格式错误,请检查内容是否完整").with_internal(format!( + "{}. cleaned_length={}", + e, + cleaned.len() + )) }) } diff --git a/src-tauri/src/spider.rs b/src-tauri/src/spider.rs index a706dd9..dc6b5ce 100644 --- a/src-tauri/src/spider.rs +++ b/src-tauri/src/spider.rs @@ -76,6 +76,7 @@ pub struct PlayerResult { pub url: String, pub parse: i64, #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "playUrl")] pub play_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub click: Option, @@ -109,13 +110,19 @@ pub struct HttpSpider { impl HttpSpider { pub fn new(site: SiteConfig) -> Self { let api_url = site.api.clone(); - Self { api_url, site_type: site.site_type, site } + Self { + api_url, + site_type: site.site_type, + site, + } } async fn fetch_json(&self, url: &str) -> HttpResult { let client = crate::network::create_client()?; let timeout = self.site.timeout.unwrap_or(15) as u64; - let mut req = client.get(url).timeout(std::time::Duration::from_secs(timeout)); + let mut req = client + .get(url) + .timeout(std::time::Duration::from_secs(timeout)); if let Some(header) = &self.site.header { if let Some(obj) = header.as_object() { for (k, v) in obj { @@ -128,18 +135,20 @@ impl HttpSpider { let resp = req.send().await.map_err(|e| { AppError::network_error(format!("API 请求失败 [{}]: {}", self.site.key, e)) })?; - let text = resp.text().await.map_err(|e| { - AppError::network_error(format!("读取响应失败: {}", e)) - })?; - serde_json::from_str(&text).map_err(|e| { - AppError::parse_error(format!("API 返回非 JSON: {}", e)) - }) + let text = resp + .text() + .await + .map_err(|e| AppError::network_error(format!("读取响应失败: {}", e)))?; + serde_json::from_str(&text) + .map_err(|e| AppError::parse_error(format!("API 返回非 JSON: {}", e))) } async fn fetch_xml(&self, url: &str) -> HttpResult { let client = crate::network::create_client()?; let timeout = self.site.timeout.unwrap_or(15) as u64; - let mut req = client.get(url).timeout(std::time::Duration::from_secs(timeout)); + let mut req = client + .get(url) + .timeout(std::time::Duration::from_secs(timeout)); if let Some(header) = &self.site.header { if let Some(obj) = header.as_object() { for (k, v) in obj { @@ -152,9 +161,10 @@ impl HttpSpider { let resp = req.send().await.map_err(|e| { AppError::network_error(format!("XML API 请求失败 [{}]: {}", self.site.key, e)) })?; - let text = resp.text().await.map_err(|e| { - AppError::network_error(format!("读取 XML 失败: {}", e)) - })?; + let text = resp + .text() + .await + .map_err(|e| AppError::network_error(format!("读取 XML 失败: {}", e)))?; parse_xml_api_response(&text) } @@ -166,8 +176,12 @@ impl HttpSpider { for pair in qs.split('&') { if let Some(eq_idx) = pair.find('=') { if eq_idx > 0 { - let k = urlencoding::decode(&pair[..eq_idx]).unwrap_or_default().into_owned(); - let v = urlencoding::decode(&pair[eq_idx + 1..]).unwrap_or_default().into_owned(); + let k = urlencoding::decode(&pair[..eq_idx]) + .unwrap_or_default() + .into_owned(); + let v = urlencoding::decode(&pair[eq_idx + 1..]) + .unwrap_or_default() + .into_owned(); existing.insert(k, v); } } @@ -177,7 +191,8 @@ impl HttpSpider { api_url.clone() }; - let merged: Vec = existing.into_iter() + let merged: Vec = existing + .into_iter() .chain(params.clone()) .map(|(k, v)| format!("{}={}", urlencoding::encode(&k), urlencoding::encode(&v))) .collect(); @@ -187,18 +202,55 @@ impl HttpSpider { fn parse_vod_item(item: &Value) -> Vod { Vod { vod_id: value_to_string(item.get("vod_id")).unwrap_or_default(), - vod_name: item.get("vod_name").and_then(Value::as_str).unwrap_or("").to_string(), - vod_pic: item.get("vod_pic").and_then(Value::as_str).map(String::from), - vod_remarks: item.get("vod_remarks").and_then(Value::as_str).map(String::from), - type_name: item.get("type_name").and_then(Value::as_str).map(String::from), - vod_year: item.get("vod_year").and_then(Value::as_str).map(String::from), - vod_area: item.get("vod_area").and_then(Value::as_str).map(String::from), - vod_director: item.get("vod_director").and_then(Value::as_str).map(String::from), - vod_actor: item.get("vod_actor").and_then(Value::as_str).map(String::from), - vod_content: item.get("vod_content").and_then(Value::as_str).map(String::from), - vod_play_from: item.get("vod_play_from").and_then(Value::as_str).map(String::from), - vod_play_url: item.get("vod_play_url").and_then(Value::as_str).map(String::from), - vod_tag: item.get("vod_tag").and_then(Value::as_str).map(String::from), + vod_name: item + .get("vod_name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + vod_pic: item + .get("vod_pic") + .and_then(Value::as_str) + .map(String::from), + vod_remarks: item + .get("vod_remarks") + .and_then(Value::as_str) + .map(String::from), + type_name: item + .get("type_name") + .and_then(Value::as_str) + .map(String::from), + vod_year: item + .get("vod_year") + .and_then(Value::as_str) + .map(String::from), + vod_area: item + .get("vod_area") + .and_then(Value::as_str) + .map(String::from), + vod_director: item + .get("vod_director") + .and_then(Value::as_str) + .map(String::from), + vod_actor: item + .get("vod_actor") + .and_then(Value::as_str) + .map(String::from), + vod_content: item + .get("vod_content") + .and_then(Value::as_str) + .map(String::from), + vod_play_from: item + .get("vod_play_from") + .and_then(Value::as_str) + .map(String::from), + vod_play_url: item + .get("vod_play_url") + .and_then(Value::as_str) + .map(String::from), + vod_tag: item + .get("vod_tag") + .and_then(Value::as_str) + .map(String::from), } } @@ -218,31 +270,74 @@ impl HttpSpider { Some(v) => vec![v.clone()], None => vec![], }; - items.iter().map(|item| { - // XML parsed by quick-xml may have different structure, be flexible - let vod_id = item.get("vod_id").or_else(|| item.get("id")) - .and_then(|v| value_to_string(Some(v))).unwrap_or_default(); - let vod_name = item.get("vod_name").or_else(|| item.get("name")) - .and_then(Value::as_str).unwrap_or("").to_string(); - Vod { - vod_id, - vod_name, - vod_pic: item.get("vod_pic").or_else(|| item.get("pic")) - .and_then(Value::as_str).map(String::from), - vod_remarks: item.get("vod_remarks").or_else(|| item.get("remarks")) - .and_then(Value::as_str).map(String::from), - type_name: item.get("type_name").or_else(|| item.get("type")) - .and_then(Value::as_str).map(String::from), - vod_year: item.get("vod_year").and_then(Value::as_str).map(String::from), - vod_area: item.get("vod_area").and_then(Value::as_str).map(String::from), - vod_director: item.get("vod_director").and_then(Value::as_str).map(String::from), - vod_actor: item.get("vod_actor").and_then(Value::as_str).map(String::from), - vod_content: item.get("vod_content").and_then(Value::as_str).map(String::from), - vod_play_from: item.get("vod_play_from").and_then(Value::as_str).map(String::from), - vod_play_url: item.get("vod_play_url").and_then(Value::as_str).map(String::from), - vod_tag: item.get("vod_tag").and_then(Value::as_str).map(String::from), - } - }).collect() + items + .iter() + .map(|item| { + // XML parsed by quick-xml may have different structure, be flexible + let vod_id = item + .get("vod_id") + .or_else(|| item.get("id")) + .and_then(|v| value_to_string(Some(v))) + .unwrap_or_default(); + let vod_name = item + .get("vod_name") + .or_else(|| item.get("name")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + Vod { + vod_id, + vod_name, + vod_pic: item + .get("vod_pic") + .or_else(|| item.get("pic")) + .and_then(Value::as_str) + .map(String::from), + vod_remarks: item + .get("vod_remarks") + .or_else(|| item.get("remarks")) + .and_then(Value::as_str) + .map(String::from), + type_name: item + .get("type_name") + .or_else(|| item.get("type")) + .and_then(Value::as_str) + .map(String::from), + vod_year: item + .get("vod_year") + .and_then(Value::as_str) + .map(String::from), + vod_area: item + .get("vod_area") + .and_then(Value::as_str) + .map(String::from), + vod_director: item + .get("vod_director") + .and_then(Value::as_str) + .map(String::from), + vod_actor: item + .get("vod_actor") + .and_then(Value::as_str) + .map(String::from), + vod_content: item + .get("vod_content") + .and_then(Value::as_str) + .map(String::from), + vod_play_from: item + .get("vod_play_from") + .and_then(Value::as_str) + .map(String::from), + vod_play_url: item + .get("vod_play_url") + .and_then(Value::as_str) + .map(String::from), + vod_tag: item + .get("vod_tag") + .and_then(Value::as_str) + .map(String::from), + } + }) + .collect() } pub async fn home_content(&self, filter: bool) -> HttpResult { @@ -256,13 +351,22 @@ impl HttpSpider { let url = self.build_url(&HashMap::from([("ac".to_string(), "detail".to_string())])); let data = self.fetch_json(&url).await?; - let mut classes: Vec = data.get("class").and_then(Value::as_array) + let mut classes: Vec = data + .get("class") + .and_then(Value::as_array) .map(|arr| { - arr.iter().map(|c| VodClass { - type_id: value_to_string(c.get("type_id").or_else(|| c.get("id"))).unwrap_or_default(), - type_name: c.get("type_name").or_else(|| c.get("name")) - .and_then(Value::as_str).unwrap_or("").to_string(), - }).collect() + arr.iter() + .map(|c| VodClass { + type_id: value_to_string(c.get("type_id").or_else(|| c.get("id"))) + .unwrap_or_default(), + type_name: c + .get("type_name") + .or_else(|| c.get("name")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + }) + .collect() }) .unwrap_or_default(); @@ -279,34 +383,63 @@ impl HttpSpider { } } } - classes = seen.into_iter().map(|(type_id, type_name)| VodClass { type_id, type_name }).collect(); + classes = seen + .into_iter() + .map(|(type_id, type_name)| VodClass { type_id, type_name }) + .collect(); } let filters: Option = _filter.then(|| data.get("filters").cloned()).flatten(); - Ok(SpiderResult { class: Some(classes), filters, list: Some(list), pagecount: None }) + Ok(SpiderResult { + class: Some(classes), + filters, + list: Some(list), + pagecount: None, + }) } async fn home_content_xml(&self, _filter: bool) -> HttpResult { - let url = self.build_url(&HashMap::from([("ac".to_string(), "videolist".to_string())])); + let url = self.build_url(&HashMap::from([( + "ac".to_string(), + "videolist".to_string(), + )])); let data = self.fetch_xml(&url).await?; - let classes: Vec = data.get("class").and_then(Value::as_array) + let classes: Vec = data + .get("class") + .and_then(Value::as_array) .map(|arr| { - arr.iter().map(|c| VodClass { - type_id: value_to_string(c.get("type_id").or_else(|| c.get("id"))).unwrap_or_default(), - type_name: c.get("type_name").or_else(|| c.get("name")) - .and_then(Value::as_str).unwrap_or("").to_string(), - }).collect() + arr.iter() + .map(|c| VodClass { + type_id: value_to_string(c.get("type_id").or_else(|| c.get("id"))) + .unwrap_or_default(), + type_name: c + .get("type_name") + .or_else(|| c.get("name")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + }) + .collect() }) .unwrap_or_default(); let list = Self::parse_xml_vod_list(&data); - Ok(SpiderResult { class: Some(classes), filters: None, list: Some(list), pagecount: None }) + Ok(SpiderResult { + class: Some(classes), + filters: None, + list: Some(list), + pagecount: None, + }) } pub async fn category_content( - &self, tid: &str, pg: &str, filter: bool, extend: &HashMap, + &self, + tid: &str, + pg: &str, + filter: bool, + extend: &HashMap, ) -> HttpResult { let mut params = HashMap::new(); match self.site_type { @@ -317,7 +450,12 @@ impl HttpSpider { let data = self.fetch_xml(&self.build_url(¶ms)).await?; let list = Self::parse_xml_vod_list(&data); let pagecount = data.get("pagecount").and_then(Value::as_i64); - Ok(SpiderResult { class: None, filters: None, list: Some(list), pagecount }) + Ok(SpiderResult { + class: None, + filters: None, + list: Some(list), + pagecount, + }) } _ => { params.insert("ac".to_string(), "detail".to_string()); @@ -325,12 +463,20 @@ impl HttpSpider { params.insert("pg".to_string(), pg.to_string()); if filter && !extend.is_empty() { - params.insert("f".to_string(), serde_json::to_string(extend).unwrap_or_default()); + params.insert( + "f".to_string(), + serde_json::to_string(extend).unwrap_or_default(), + ); } let data = self.fetch_json(&self.build_url(¶ms)).await?; let list = Self::parse_json_vod_list(&data); let pagecount = data.get("pagecount").and_then(Value::as_i64); - Ok(SpiderResult { class: None, filters: None, list: Some(list), pagecount }) + Ok(SpiderResult { + class: None, + filters: None, + list: Some(list), + pagecount, + }) } } } @@ -343,38 +489,72 @@ impl HttpSpider { params.insert("ac".to_string(), "videolist".to_string()); params.insert("ids".to_string(), ids_str); let data = self.fetch_xml(&self.build_url(¶ms)).await?; - Ok(SpiderResult { class: None, filters: None, list: Some(Self::parse_xml_vod_list(&data)), pagecount: None }) + Ok(SpiderResult { + class: None, + filters: None, + list: Some(Self::parse_xml_vod_list(&data)), + pagecount: None, + }) } _ => { params.insert("ac".to_string(), "detail".to_string()); params.insert("ids".to_string(), ids_str); let data = self.fetch_json(&self.build_url(¶ms)).await?; - Ok(SpiderResult { class: None, filters: None, list: Some(Self::parse_json_vod_list(&data)), pagecount: None }) + Ok(SpiderResult { + class: None, + filters: None, + list: Some(Self::parse_json_vod_list(&data)), + pagecount: None, + }) } } } - pub async fn search_content(&self, keyword: &str, _quick: bool, pg: Option<&str>) -> HttpResult { + pub async fn search_content( + &self, + keyword: &str, + _quick: bool, + pg: Option<&str>, + ) -> HttpResult { let mut params = HashMap::new(); match self.site_type { 0 => { params.insert("ac".to_string(), "videolist".to_string()); params.insert("wd".to_string(), keyword.to_string()); - if let Some(p) = pg { params.insert("pg".to_string(), p.to_string()); } + if let Some(p) = pg { + params.insert("pg".to_string(), p.to_string()); + } let data = self.fetch_xml(&self.build_url(¶ms)).await?; - Ok(SpiderResult { class: None, filters: None, list: Some(Self::parse_xml_vod_list(&data)), pagecount: None }) + Ok(SpiderResult { + class: None, + filters: None, + list: Some(Self::parse_xml_vod_list(&data)), + pagecount: None, + }) } _ => { params.insert("ac".to_string(), "detail".to_string()); params.insert("wd".to_string(), keyword.to_string()); - if let Some(p) = pg { params.insert("pg".to_string(), p.to_string()); } + if let Some(p) = pg { + params.insert("pg".to_string(), p.to_string()); + } let data = self.fetch_json(&self.build_url(¶ms)).await?; - Ok(SpiderResult { class: None, filters: None, list: Some(Self::parse_json_vod_list(&data)), pagecount: None }) + Ok(SpiderResult { + class: None, + filters: None, + list: Some(Self::parse_json_vod_list(&data)), + pagecount: None, + }) } } } - pub async fn player_content(&self, _flag: &str, id: &str, _vip_flags: &[String]) -> HttpResult { + pub async fn player_content( + &self, + _flag: &str, + id: &str, + _vip_flags: &[String], + ) -> HttpResult { let is_direct = is_video_format(id); let has_play_url = self.site.play_url.is_some(); Ok(PlayerResult { @@ -401,9 +581,15 @@ pub fn is_video_format(url: &str) -> bool { return false; } let re = Regex::new(r"(?i)https?://[^\s]{12,}\.(?:m3u8|m3u|mp4|flv|hlv|f4v|mkv|avi|wmv|mov|webm|ts|m4s|mpd|aac|mp3|m4a)(?:\?.*)?$").unwrap(); - if re.is_match(url) { return true; } - if url.contains("video/tos") { return true; } - if url.starts_with("rtmp:") { return true; } + if re.is_match(url) { + return true; + } + if url.contains("video/tos") { + return true; + } + if url.starts_with("rtmp:") { + return true; + } false } @@ -418,126 +604,151 @@ pub fn need_parse(url: &str, play_url: Option<&str>) -> bool { // ==================== XML API 解析 ==================== /// 解析 XML API 响应 - fn parse_xml_api_response(xml: &str) -> HttpResult { - // Simple XML to JSON conversion using recursive approach - // Only handles the TV API XML format (no backreferences needed) - let xml = xml.trim(); - if xml.is_empty() { - return Ok(Value::Object(serde_json::Map::new())); +fn parse_xml_api_response(xml: &str) -> HttpResult { + // Simple XML to JSON conversion using recursive approach + // Only handles the TV API XML format (no backreferences needed) + let xml = xml.trim(); + if xml.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + + // Extract content within rss tag or use whole document + let content = if let Some(start) = xml.find("") { + let inner_start = xml[start..] + .find('>') + .map(|i| start + i + 1) + .unwrap_or(start); + &xml[inner_start..end] + } else { + xml } + } else { + xml + }; - // Extract content within rss tag or use whole document - let content = if let Some(start) = xml.find("") { - let inner_start = xml[start..].find('>').map(|i| start + i + 1).unwrap_or(start); - &xml[inner_start..end] - } else { xml } - } else { xml }; + Ok(parse_xml_elements(content)) +} - Ok(parse_xml_elements(content)) +fn parse_xml_elements(xml: &str) -> Value { + let xml = xml.trim(); + if xml.is_empty() { + return Value::Null; } - fn parse_xml_elements(xml: &str) -> Value { - let xml = xml.trim(); - if xml.is_empty() { - return Value::Null; - } + let mut map = serde_json::Map::new(); + let mut pos = 0; + let bytes = xml.as_bytes(); - let mut map = serde_json::Map::new(); - let mut pos = 0; - let bytes = xml.as_bytes(); + while pos < xml.len() { + // Find next + let tag_start = match xml[pos..].find('<') { + Some(i) => pos + i, + None => break, + }; - while pos < xml.len() { - // Find next - let tag_start = match xml[pos..].find('<') { - Some(i) => pos + i, + // Skip comments and processing instructions + if tag_start + 1 < xml.len() + && (bytes[tag_start + 1] == b'?' || bytes[tag_start + 1] == b'!') + { + let close = if bytes[tag_start + 1] == b'?' { + b'?' + } else { + b'>' + }; + let end_tag = match xml[tag_start..].find(close as char) { + Some(i) => tag_start + i + 1, None => break, }; + let end_close = xml[end_tag..] + .find('>') + .map(|i| end_tag + i + 1) + .unwrap_or(xml.len()); + pos = end_close; + continue; + } - // Skip comments and processing instructions - if tag_start + 1 < xml.len() && (bytes[tag_start + 1] == b'?' || bytes[tag_start + 1] == b'!') { - let close = if bytes[tag_start + 1] == b'?' { b'?' } else { b'>' }; - let end_tag = match xml[tag_start..].find(close as char) { - Some(i) => tag_start + i + 1, - None => break, - }; - let end_close = xml[end_tag..].find('>').map(|i| end_tag + i + 1).unwrap_or(xml.len()); - pos = end_close; - continue; - } + // Skip self-closing tags + if tag_start + 1 < xml.len() && bytes[tag_start + 1] == b'/' { + let end = xml[tag_start..] + .find('>') + .map(|i| tag_start + i + 1) + .unwrap_or(xml.len()); + pos = end; + continue; + } - // Skip self-closing tags - if tag_start + 1 < xml.len() && bytes[tag_start + 1] == b'/' { - let end = xml[tag_start..].find('>').map(|i| tag_start + i + 1).unwrap_or(xml.len()); - pos = end; - continue; - } + // Extract tag name + let tag_end = xml[tag_start..] + .find(|c: char| c.is_whitespace() || c == '>' || c == '/') + .map(|i| tag_start + i) + .unwrap_or(xml.len()); - // Extract tag name - let tag_end = xml[tag_start..].find(|c: char| c.is_whitespace() || c == '>' || c == '/') - .map(|i| tag_start + i) - .unwrap_or(xml.len()); + let tag_name = &xml[tag_start + 1..tag_end]; - let tag_name = &xml[tag_start + 1..tag_end]; + // Find end of opening tag + let open_end = match xml[tag_start..].find('>') { + Some(i) => tag_start + i + 1, + None => break, + }; - // Find end of opening tag - let open_end = match xml[tag_start..].find('>') { - Some(i) => tag_start + i + 1, - None => break, - }; + // Check if self-closing + if open_end > 1 && bytes[open_end - 2] == b'/' { + pos = open_end; + add_to_map(&mut map, tag_name, Value::Null); + continue; + } - // Check if self-closing - if open_end > 1 && bytes[open_end - 2] == b'/' { + // Find closing tag + let close_tag = format!("", tag_name); + let close_start = match xml[open_end..].find(&close_tag) { + Some(i) => open_end + i, + None => { pos = open_end; - add_to_map(&mut map, tag_name, Value::Null); continue; } + }; - // Find closing tag - let close_tag = format!("", tag_name); - let close_start = match xml[open_end..].find(&close_tag) { - Some(i) => open_end + i, - None => { - pos = open_end; - continue; - } - }; - - let inner = &xml[open_end..close_start]; - let child = parse_xml_elements(inner); + let inner = &xml[open_end..close_start]; + let child = parse_xml_elements(inner); - // Check if inner text is just whitespace (no tags) - let inner_trimmed = inner.trim(); - let is_leaf = !inner_trimmed.contains('<') && !inner_trimmed.contains('>'); + // Check if inner text is just whitespace (no tags) + let inner_trimmed = inner.trim(); + let is_leaf = !inner_trimmed.contains('<') && !inner_trimmed.contains('>'); - let value = if is_leaf && !inner_trimmed.is_empty() { - Value::String(inner_trimmed.to_string()) - } else if child.is_object() && child.as_object().map(|m| m.is_empty()).unwrap_or(true) && !inner_trimmed.is_empty() { - Value::String(inner_trimmed.to_string()) - } else { - child - }; - - add_to_map(&mut map, tag_name, value); - pos = close_start + close_tag.len(); - } + let value = if is_leaf && !inner_trimmed.is_empty() { + Value::String(inner_trimmed.to_string()) + } else if child.is_object() + && child.as_object().map(|m| m.is_empty()).unwrap_or(true) + && !inner_trimmed.is_empty() + { + Value::String(inner_trimmed.to_string()) + } else { + child + }; - Value::Object(map) + add_to_map(&mut map, tag_name, value); + pos = close_start + close_tag.len(); } - fn add_to_map(map: &mut serde_json::Map, key: &str, value: Value) { - match map.get_mut(key) { - Some(Value::Array(ref mut arr)) => arr.push(value), - Some(existing) => { - let old = std::mem::replace(existing, Value::Array(vec![])); - if let Value::Array(ref mut arr) = existing { - arr.push(old); - arr.push(value); - } + Value::Object(map) +} + +fn add_to_map(map: &mut serde_json::Map, key: &str, value: Value) { + match map.get_mut(key) { + Some(Value::Array(ref mut arr)) => arr.push(value), + Some(existing) => { + let old = std::mem::replace(existing, Value::Array(vec![])); + if let Value::Array(ref mut arr) = existing { + arr.push(old); + arr.push(value); } - None => { map.insert(key.to_string(), value); } + } + None => { + map.insert(key.to_string(), value); } } +} #[cfg(test)] mod tests { @@ -557,7 +768,10 @@ mod tests { assert!(!need_parse("https://example.com/v.mp4", None)); assert!(need_parse("https://example.com/page.html", None)); // With playUrl, needs parse even for direct video URLs - assert!(need_parse("https://example.com/v.mp4", Some("https://example.com/play.php"))); + assert!(need_parse( + "https://example.com/v.mp4", + Some("https://example.com/play.php") + )); } #[test] @@ -575,10 +789,20 @@ mod tests { let result = parse_xml_api_response(xml).unwrap(); let list = result.get("list").and_then(|v| v.as_object()); assert!(list.is_some(), "Should have 'list' key"); - let video = list.and_then(|m| m.get("video")).and_then(|v| v.as_object()); + let video = list + .and_then(|m| m.get("video")) + .and_then(|v| v.as_object()); assert!(video.is_some(), "Should have 'video' key"); - assert_eq!(video.and_then(|m| m.get("vod_id")).and_then(Value::as_str), Some("1")); - assert_eq!(video.and_then(|m| m.get("vod_name")).and_then(Value::as_str), Some("Test Movie")); + assert_eq!( + video.and_then(|m| m.get("vod_id")).and_then(Value::as_str), + Some("1") + ); + assert_eq!( + video + .and_then(|m| m.get("vod_name")) + .and_then(Value::as_str), + Some("Test Movie") + ); } #[test] @@ -589,15 +813,24 @@ mod tests { }); let list = HttpSpider::parse_json_vod_list(&data); assert_eq!(list[0].vod_id, "75220"); - assert_eq!(value_to_string(data["class"][0].get("type_id")), Some("22".to_string())); + assert_eq!( + value_to_string(data["class"][0].get("type_id")), + Some("22".to_string()) + ); } #[test] fn build_url_merges_params() { let spider = HttpSpider::new(SiteConfig { - key: "test".to_string(), name: "Test".to_string(), - site_type: 1, api: "https://api.example.com/api.php".to_string(), - ext: None, play_url: None, click: None, header: None, timeout: None, + key: "test".to_string(), + name: "Test".to_string(), + site_type: 1, + api: "https://api.example.com/api.php".to_string(), + ext: None, + play_url: None, + click: None, + header: None, + timeout: None, }); let url = spider.build_url(&HashMap::from([ ("ac".to_string(), "detail".to_string()), @@ -611,9 +844,15 @@ mod tests { #[test] fn build_url_preserves_existing_params() { let spider = HttpSpider::new(SiteConfig { - key: "test".to_string(), name: "Test".to_string(), - site_type: 1, api: "https://api.example.com/api.php?existing=1".to_string(), - ext: None, play_url: None, click: None, header: None, timeout: None, + key: "test".to_string(), + name: "Test".to_string(), + site_type: 1, + api: "https://api.example.com/api.php?existing=1".to_string(), + ext: None, + play_url: None, + click: None, + header: None, + timeout: None, }); let url = spider.build_url(&HashMap::from([("ac".to_string(), "detail".to_string())])); assert!(url.contains("existing=1")); diff --git a/src-tauri/src/super_parse.rs b/src-tauri/src/super_parse.rs index 4d00018..1b383c5 100644 --- a/src-tauri/src/super_parse.rs +++ b/src-tauri/src/super_parse.rs @@ -193,15 +193,6 @@ pub async fn super_parse( player_result: Option<&Value>, parses: Option<&Vec>, ) -> Result, AppError> { - // Level 0: 直链识别 - if spider::is_video_format(url) { - return Ok(Some(ParseResult { - url: url.to_string(), - header: None, - from: "direct".to_string(), - })); - } - let mut result_url = url.to_string(); let mut parse_flag = 0i64; let mut result_header: Option> = None; @@ -223,7 +214,11 @@ pub async fn super_parse( } result_header = Some(headers); } - play_url = pr.get("playUrl").and_then(Value::as_str).map(String::from); + play_url = pr + .get("playUrl") + .or_else(|| pr.get("play_url")) + .and_then(Value::as_str) + .map(String::from); } // Check if direct @@ -231,7 +226,11 @@ pub async fn super_parse( return Ok(Some(ParseResult { url: result_url, header: result_header, - from: "playerContent".to_string(), + from: if player_result.is_some() { + "playerContent".to_string() + } else { + "direct".to_string() + }, })); } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 2364e5b..4cb33f1 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -40,7 +40,11 @@ pub struct AppError { impl AppError { pub fn new(code: AppErrorCode, message: impl Into) -> Self { - Self { code, message: message.into(), internal: None } + Self { + code, + message: message.into(), + internal: None, + } } pub fn with_internal(mut self, internal: impl Into) -> Self { @@ -109,11 +113,19 @@ pub struct IpcResponse { impl IpcResponse { pub fn ok(data: T) -> Self { - Self { success: true, data: Some(data), error: None } + Self { + success: true, + data: Some(data), + error: None, + } } pub fn err(error: impl Into) -> Self { - Self { success: false, data: None, error: Some(error.into()) } + Self { + success: false, + data: None, + error: Some(error.into()), + } } } @@ -126,8 +138,18 @@ pub struct SimpleResponse { } impl SimpleResponse { - pub fn ok() -> Self { Self { success: true, error: None } } - pub fn err(error: impl Into) -> Self { Self { success: false, error: Some(error.into()) } } + pub fn ok() -> Self { + Self { + success: true, + error: None, + } + } + pub fn err(error: impl Into) -> Self { + Self { + success: false, + error: Some(error.into()), + } + } } // ==================== Config 类型 ==================== @@ -200,6 +222,18 @@ pub struct ConfigInspection { pub live_count: i64, pub parse_count: i64, pub has_spider: bool, + pub source_type: Option, + pub compatibility: String, + pub compatibility_label: String, + pub can_import: bool, + pub hidden_site_count: i64, + pub csp_site_count: i64, + pub missing_api_site_count: i64, + pub probe_inspected_site_count: i64, + pub probe_passed_site_count: i64, + pub probe_failed_site_count: i64, + pub probe_skipped_site_count: i64, + pub live_channel_count: i64, pub warnings: Vec, } @@ -251,6 +285,15 @@ pub struct HistoryItem { pub item_type: Option, pub source: Option, pub progress: Option, + pub episode_id: Option, + pub episode_name: Option, + pub episode_index: Option, + pub source_index: Option, + pub source_name: Option, + pub url_identifier: Option, + pub duration: Option, + pub position_seconds: Option, + pub completed: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e12f51b..c593f4a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Routes, Route, useLocation, useNavigate } from 'react-router-dom' import Sidebar from './components/Sidebar/Sidebar' import MiniPlayer from './components/MiniPlayer/MiniPlayer' @@ -10,8 +10,12 @@ import History from './pages/History/History' import Keep from './pages/Keep/Keep' import Settings from './pages/Settings/Settings' import Onboarding from './pages/Onboarding/Onboarding' +import AlphaPlaybackSmoke from './components/AlphaPlaybackSmoke/AlphaPlaybackSmoke' +import type { AlphaPlaybackSmokeConfig } from './components/AlphaPlaybackSmoke/AlphaPlaybackSmoke' +import BetaContinueSmoke from './components/BetaContinueSmoke/BetaContinueSmoke' +import type { BetaContinueSmokeConfig } from './components/BetaContinueSmoke/BetaContinueSmoke' import { useConfigStore } from './stores/useConfigStore' -import { configApi } from './utils/ipc' +import { configApi, settingsApi } from './utils/ipc' /** 检测是否为精简模式 */ function isMiniMode(): boolean { @@ -24,6 +28,8 @@ export default function App() { const navigate = useNavigate() const location = useLocation() const didAutoLoad = useRef(false) + const [smokeConfig, setSmokeConfig] = useState(null) + const [betaContinueSmokeConfig, setBetaContinueSmokeConfig] = useState(null) // 精简模式:只渲染 MiniPlayer if (isMiniMode()) { @@ -56,6 +62,32 @@ export default function App() { autoLoad() }, [loadConfig, location.pathname, navigate]) + useEffect(() => { + let cancelled = false + const loadSmokeConfig = async () => { + const value = await settingsApi.get('__alphaPlaybackSmoke') as AlphaPlaybackSmokeConfig | null + if (!cancelled && value?.enabled) { + setSmokeConfig(value) + } + const betaValue = await settingsApi.get('__betaContinueSmoke') as BetaContinueSmokeConfig | null + if (!cancelled && betaValue?.enabled) { + setBetaContinueSmokeConfig(betaValue) + } + } + void loadSmokeConfig() + return () => { + cancelled = true + } + }, []) + + if (smokeConfig?.enabled) { + return + } + + if (betaContinueSmokeConfig?.enabled) { + return + } + return (
diff --git a/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.tsx b/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.tsx new file mode 100644 index 0000000..26f3af3 --- /dev/null +++ b/src/renderer/src/components/AlphaPlaybackSmoke/AlphaPlaybackSmoke.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from 'react' +import VideoPlayer from '@/components/VideoPlayer/VideoPlayer' +import { usePlayerStore } from '@/stores/usePlayerStore' +import { settingsApi } from '@/utils/ipc' +import { getPlayableMediaUrl } from '@/utils/media' +import { clearPlaybackMetrics, getPlaybackMetricSummary } from '@/utils/playbackMetrics' + +export interface AlphaPlaybackSmokeConfig { + enabled: boolean + mediaUrl?: string + timeoutMs?: number +} + +interface AlphaPlaybackSmokeResult { + ok: boolean + at: string + elapsedMs?: number + phase: string + message: string + error?: string + diagnostic?: unknown + metrics: ReturnType + debug?: unknown + mediaUrl: string + userAgent: string +} + +const RESULT_KEY = '__alphaPlaybackSmokeResult' +const DEFAULT_MEDIA_URL = 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4' +const DEFAULT_TIMEOUT_MS = 20000 + +export default function AlphaPlaybackSmoke({ config }: { config: AlphaPlaybackSmokeConfig }) { + const wroteResultRef = useRef(false) + const [lastResult, setLastResult] = useState(null) + const { + playbackPhase, + playbackMessage, + playbackError, + playbackStartedAt, + playbackFirstFrameAt, + playbackDiagnostic, + setVod, + setVolume, + reset + } = usePlayerStore() + + const mediaUrl = config.mediaUrl || DEFAULT_MEDIA_URL + const timeoutMs = config.timeoutMs || DEFAULT_TIMEOUT_MS + + useEffect(() => { + let cancelled = false + clearPlaybackMetrics() + reset() + setVolume(0) + const startPlayback = async () => { + const playableUrl = await getPlayableMediaUrl(mediaUrl) + if (cancelled) return + setVod( + { + vod_id: 'alpha-smoke', + vod_name: 'Alpha 2.1 Playback Smoke', + vod_pic: '', + vod_remarks: '自动首帧验证' + }, + [{ name: '首帧验证', url: playableUrl }], + 0, + playableUrl, + undefined, + 'alpha-smoke' + ) + } + void startPlayback() + return () => { + cancelled = true + } + }, [mediaUrl, reset, setVod, setVolume]) + + useEffect(() => { + if (wroteResultRef.current) return + + const timer = window.setTimeout(() => { + if (!wroteResultRef.current) { + void writeResult(false, `首帧超时: ${timeoutMs}ms`) + } + }, timeoutMs) + + return () => window.clearTimeout(timer) + }, [timeoutMs]) + + useEffect(() => { + if (wroteResultRef.current) return + if (playbackFirstFrameAt > 0) { + const elapsedMs = playbackStartedAt ? Math.max(0, playbackFirstFrameAt - playbackStartedAt) : undefined + void writeResult(true, '首帧播放成功', elapsedMs) + } else if (playbackPhase === 'failed') { + void writeResult(false, playbackError || playbackMessage || '播放失败') + } + }, [playbackError, playbackFirstFrameAt, playbackMessage, playbackPhase, playbackStartedAt]) + + async function writeResult(ok: boolean, message: string, elapsedMs?: number) { + wroteResultRef.current = true + const result: AlphaPlaybackSmokeResult = { + ok, + at: new Date().toISOString(), + elapsedMs, + phase: usePlayerStore.getState().playbackPhase, + message, + error: usePlayerStore.getState().playbackError || undefined, + diagnostic: usePlayerStore.getState().playbackDiagnostic || undefined, + metrics: getPlaybackMetricSummary(), + debug: window.__alphaPlaybackDebug, + mediaUrl, + userAgent: navigator.userAgent + } + setLastResult(result) + await settingsApi.set(RESULT_KEY, result) + } + + return ( +
+ +
+
Alpha 2.1 Playback Smoke
+
phase={playbackPhase}
+
message={playbackMessage || '-'}
+
firstFrame={playbackFirstFrameAt ? 'yes' : 'no'}
+ {lastResult && ( +
+ {lastResult.ok ? 'PASS' : 'FAIL'} {lastResult.elapsedMs ? `${lastResult.elapsedMs}ms` : ''} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.tsx b/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.tsx new file mode 100644 index 0000000..193d15b --- /dev/null +++ b/src/renderer/src/components/BetaContinueSmoke/BetaContinueSmoke.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from 'react' +import { historyApi, settingsApi } from '@/utils/ipc' +import type { History as HistoryItem } from '@shared/types' + +export interface BetaContinueSmokeConfig { + enabled: boolean + phase: 'seed' | 'validate' + positionSeconds?: number + toleranceSeconds?: number +} + +interface BetaContinueSmokeResult { + ok: boolean + at: string + phase: 'seed' | 'validate' + message: string + expectedPositionSeconds: number + actualPositionSeconds?: number + toleranceSeconds: number + history?: HistoryItem +} + +const RESULT_KEY = '__betaContinueSmokeResult' +const SMOKE_SITE_KEY = 'beta-smoke-site' +const SMOKE_VOD_ID = 'beta-smoke-vod' +const DEFAULT_POSITION_SECONDS = 372 +const DEFAULT_TOLERANCE_SECONDS = 20 + +function isSmokeHistory(item: HistoryItem): boolean { + return item.siteKey === SMOKE_SITE_KEY && item.vodId === SMOKE_VOD_ID +} + +export default function BetaContinueSmoke({ config }: { config: BetaContinueSmokeConfig }) { + const wroteRef = useRef(false) + const [result, setResult] = useState(null) + const expectedPositionSeconds = config.positionSeconds || DEFAULT_POSITION_SECONDS + const toleranceSeconds = config.toleranceSeconds || DEFAULT_TOLERANCE_SECONDS + + useEffect(() => { + if (wroteRef.current) return + wroteRef.current = true + + const run = async () => { + if (config.phase === 'seed') { + await historyApi.add({ + siteKey: SMOKE_SITE_KEY, + vodId: SMOKE_VOD_ID, + vodName: 'Beta Continue Watching Smoke', + vodPic: '', + vodRemarks: '自动恢复验证', + source: '高清线路 · 第 3 集', + progress: 31, + episodeId: '2', + episodeName: '第 3 集', + episodeIndex: 2, + sourceIndex: 1, + sourceName: '高清线路', + urlIdentifier: 'smoke.example/video/index.m3u8', + duration: 1200, + positionSeconds: expectedPositionSeconds, + completed: false + }) + await writeResult({ + ok: true, + phase: 'seed', + message: 'seeded continue-watching history', + expectedPositionSeconds, + actualPositionSeconds: expectedPositionSeconds, + toleranceSeconds + }) + return + } + + const history = ((await historyApi.list()) as HistoryItem[]).find(isSmokeHistory) + if (!history) { + await writeResult({ + ok: false, + phase: 'validate', + message: 'history item missing after force restart', + expectedPositionSeconds, + toleranceSeconds + }) + return + } + + const actualPositionSeconds = history.positionSeconds || 0 + const positionDelta = Math.abs(actualPositionSeconds - expectedPositionSeconds) + const ok = positionDelta <= toleranceSeconds && + history.episodeIndex === 2 && + history.sourceIndex === 1 && + history.sourceName === '高清线路' && + history.completed === false + + await writeResult({ + ok, + phase: 'validate', + message: ok + ? 'continue-watching history survived force restart' + : `resume mismatch: delta=${positionDelta}s`, + expectedPositionSeconds, + actualPositionSeconds, + toleranceSeconds, + history + }) + } + + void run().catch((error) => { + void writeResult({ + ok: false, + phase: config.phase, + message: error instanceof Error ? error.message : String(error), + expectedPositionSeconds, + toleranceSeconds + }) + }) + }, [config.phase, expectedPositionSeconds, toleranceSeconds]) + + async function writeResult(next: Omit) { + const result = { + ...next, + at: new Date().toISOString() + } + setResult(result) + await settingsApi.set(RESULT_KEY, result) + } + + return ( +
+
+
Beta 1 Continue Watching Smoke
+
phase={config.phase}
+
expected={expectedPositionSeconds}s
+ {result && ( +
+ {result.ok ? 'PASS' : 'FAIL'} {result.message} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/VideoPlayer/VideoPlayer.tsx b/src/renderer/src/components/VideoPlayer/VideoPlayer.tsx index c88c561..e0fe367 100644 --- a/src/renderer/src/components/VideoPlayer/VideoPlayer.tsx +++ b/src/renderer/src/components/VideoPlayer/VideoPlayer.tsx @@ -23,12 +23,25 @@ import { RotateCw, Shuffle } from 'lucide-react' -import { windowApi } from '@/utils/ipc' +import { historyApi, windowApi } from '@/utils/ipc' import { redactHeaders, redactText } from '@/utils/redact' +import { getPlaybackMetricSummary, recordPlaybackMetric } from '@/utils/playbackMetrics' const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3] const MAX_HLS_NETWORK_RECOVERY_ATTEMPTS = 3 const MAX_HLS_MEDIA_RECOVERY_ATTEMPTS = 2 +const MAX_HLS_DEBUG_EVENTS = 20 +const HISTORY_SAVE_INTERVAL_MS = 15_000 + +declare global { + interface Window { + __alphaPlaybackDebug?: { + protocol?: string + url?: string + events: Array> + } + } +} interface StreamStats { bitrateKbps: number | null @@ -48,6 +61,40 @@ function formatThroughput(kbps: number | null): string { return `${Math.round(kbps)} Kbps` } +function inferProtocol(url: string): 'hls' | 'dash' | 'native' | 'unknown' { + if (!url) return 'unknown' + const candidates = [url] + try { + const nestedUrl = new URL(url).searchParams.get('url') + if (nestedUrl) candidates.unshift(nestedUrl) + } catch { + // Keep the original string fallback for non-URL values. + } + if (candidates.some((candidate) => /\.m3u8(?:$|[?&#])/i.test(candidate))) return 'hls' + if (candidates.some((candidate) => /\.mpd(?:$|[?&#])/i.test(candidate))) return 'dash' + if (candidates.some((candidate) => /\.(mp4|m4v|webm|mov|flv|ts)(?:$|[?&#])/i.test(candidate))) return 'native' + return 'unknown' +} + +function appendHlsDebugEvent(event: Record) { + if (typeof window === 'undefined') return + const debug = window.__alphaPlaybackDebug || { events: [] } + debug.events = [...debug.events, { at: Date.now(), ...event }].slice(-MAX_HLS_DEBUG_EVENTS) + window.__alphaPlaybackDebug = debug +} + +function formatUrlIdentifier(url: string): string { + if (!url) return '' + try { + const parsed = new URL(url) + const nested = parsed.searchParams.get('url') + const target = nested ? new URL(nested) : parsed + return `${target.hostname}${target.pathname}`.slice(0, 240) + } catch { + return url.split('?')[0].slice(0, 240) + } +} + export default function VideoPlayer() { const videoRef = useRef(null) const containerRef = useRef(null) @@ -68,6 +115,8 @@ export default function VideoPlayer() { const lastFragLoadedAtRef = useRef(0) /** stalled 防抖定时器 - 持续停滞超过阈值才显示覆盖层 */ const stalledTimerRef = useRef(0) + const pendingSeekRef = useRef(0) + const lastHistorySaveAtRef = useRef(0) // 用 ref 缓存 store 中的 siteKey/vodId,避免 reportPlayFailure 引用变化导致 effect 重跑 const currentSiteKeyRef = useRef('') @@ -75,9 +124,10 @@ export default function VideoPlayer() { const { isPlaying, currentUrl, playKey, currentTime, duration, speed, volume, - isDanmakuOn, isFullscreen, episodes, currentEpisodeIndex, + isDanmakuOn, isFullscreen, episodes, currentEpisodeIndex, currentSourceIndex, playHeader, currentSiteKey, currentVod, playbackPhase, playbackMessage, playbackError, - playbackStartedAt, playbackFirstFrameAt, playbackLastErrorAt, sourceSwitchState, sourceSwitchMessage, autoSwitchSource, + playbackStartedAt, playbackFirstFrameAt, playbackLastErrorAt, playbackDiagnostic, + alternativeSources, brokenSources, sourceSwitchState, sourceSwitchMessage, autoSwitchSource, setIsPlaying, setCurrentTime, setDuration, setSpeed, setVolume, toggleDanmaku, toggleFullscreen, nextEpisode, prevEpisode, setPlaybackPhase, markPlaybackFirstFrame, setAutoSwitchSource @@ -89,6 +139,12 @@ export default function VideoPlayer() { currentVodIdRef.current = currentVod?.vod_id || '' }, [currentSiteKey, currentVod?.vod_id]) + useEffect(() => { + const target = usePlayerStore.getState().currentTime + pendingSeekRef.current = target > 3 ? target : 0 + lastHistorySaveAtRef.current = 0 + }, [currentUrl, playKey]) + const [showControls, setShowControls] = useState(true) const [showSpeedMenu, setShowSpeedMenu] = useState(false) const [showDiagnostics, setShowDiagnostics] = useState(false) @@ -104,9 +160,48 @@ export default function VideoPlayer() { failureReportedRef.current = true const siteKey = currentSiteKeyRef.current const vodId = currentVodIdRef.current + const store = usePlayerStore.getState() + const elapsedMs = store.playbackStartedAt ? Math.max(0, Date.now() - store.playbackStartedAt) : undefined + const protocol = inferProtocol(store.currentUrl) + const lowerReason = reason.toLowerCase() + const stage = reason.includes('清单') || reason.includes('MANIFEST') || lowerReason.includes('manifest') + ? 'manifest' + : reason.includes('超时') || lowerReason.includes('timeout') + ? 'connect' + : reason.includes('媒体') || reason.includes('视频') + ? 'media' + : reason.includes('网络') || lowerReason.includes('network') + ? 'network' + : protocol === 'hls' + ? 'manifest' + : 'unknown' + const errorKind = reason.includes('超时') || lowerReason.includes('timeout') + ? 'timeout' + : protocol === 'hls' || lowerReason.includes('hls') + ? 'hls' + : reason.includes('媒体') || reason.includes('视频') + ? 'media' + : 'unknown' console.error('[VideoPlayer] 播放失败:', reason, 'siteKey:', siteKey, 'vodId:', vodId) - usePlayerStore.getState().setPlaybackError(reason) - const detail = { siteKey, vodId, reason, autoSwitch: true } + store.setPlaybackError(reason, { + stage, + errorKind, + protocol, + elapsedMs, + sourceId: siteKey && vodId ? `${siteKey}::${vodId}` : undefined, + nextAction: store.autoSwitchSource ? '自动尝试下一条可用线路' : '可手动重试或切换线路' + }) + recordPlaybackMetric({ + type: 'failure', + at: Date.now(), + elapsedMs, + phase: store.playbackPhase, + stage, + errorKind, + protocol, + sourceId: siteKey && vodId ? `${siteKey}::${vodId}` : undefined + }) + const detail = { siteKey, vodId, reason, autoSwitch: true, diagnostic: usePlayerStore.getState().playbackDiagnostic } window.dispatchEvent(new CustomEvent(siteKey || vodId ? 'vod:playFailed' : 'live:playFailed', { detail })) }) @@ -138,10 +233,84 @@ export default function VideoPlayer() { `size=${video?.videoWidth || 0}x${video?.videoHeight || 0}` ) markPlaybackFirstFrame() + const store = usePlayerStore.getState() + const elapsedMs = store.playbackStartedAt ? Math.max(0, Date.now() - store.playbackStartedAt) : undefined + recordPlaybackMetric({ + type: 'first_frame', + at: Date.now(), + elapsedMs, + phase: store.playbackPhase, + protocol: inferProtocol(store.currentUrl), + sourceId: store.currentSiteKey && store.currentVod ? `${store.currentSiteKey}::${store.currentVod.vod_id}` : undefined + }) clearLoadTimeout() clearStalledTimer() }, [clearLoadTimeout, clearStalledTimer, markPlaybackFirstFrame]) + const applyPendingSeek = useCallback(() => { + const video = videoRef.current + const target = pendingSeekRef.current + if (!video || target <= 3) return + if (!Number.isFinite(video.duration) || video.duration <= target + 2) return + video.currentTime = Math.min(target, Math.max(0, video.duration - 2)) + pendingSeekRef.current = 0 + }, []) + + const savePlaybackHistory = useCallback((force = false, completedOverride?: boolean) => { + const video = videoRef.current + const store = usePlayerStore.getState() + const vod = store.currentVod + if (!vod || !store.currentSiteKey || !store.currentUrl || store.currentUrl === '__resolving__') return + + const now = Date.now() + if (!force && now - lastHistorySaveAtRef.current < HISTORY_SAVE_INTERVAL_MS) return + + const rawDuration = video?.duration || store.duration || 0 + const durationSeconds = Number.isFinite(rawDuration) ? Math.max(0, Math.round(rawDuration)) : 0 + const rawPosition = video?.currentTime ?? store.currentTime + const positionSeconds = Number.isFinite(rawPosition) ? Math.max(0, Math.round(rawPosition)) : 0 + if (!force && positionSeconds < 5) return + + const completed = completedOverride ?? (durationSeconds > 0 && positionSeconds / durationSeconds >= 0.95) + const savedPosition = completed ? 0 : positionSeconds + const progress = durationSeconds > 0 + ? Math.max(0, Math.min(100, Math.round((positionSeconds / durationSeconds) * 100))) + : 0 + const episode = store.episodes[store.currentEpisodeIndex] + const sourceName = store.currentSourceName || `线路 ${store.currentSourceIndex + 1}` + const episodeName = episode?.name || `第 ${store.currentEpisodeIndex + 1} 集` + + lastHistorySaveAtRef.current = now + historyApi.add({ + siteKey: store.currentSiteKey, + vodId: vod.vod_id, + vodName: vod.vod_name, + vodPic: vod.vod_pic, + vodRemarks: vod.vod_remarks, + source: `${sourceName} · ${episodeName}`, + progress: completed ? 100 : progress, + episodeId: String(store.currentEpisodeIndex), + episodeName, + episodeIndex: store.currentEpisodeIndex, + sourceIndex: store.currentSourceIndex, + sourceName, + urlIdentifier: formatUrlIdentifier(store.currentUrl), + duration: durationSeconds, + positionSeconds: savedPosition, + completed + }).catch(() => {}) + }, []) + + useEffect(() => { + const handleFlushHistory = () => savePlaybackHistory(true) + window.addEventListener('player:flushHistory', handleFlushHistory) + return () => window.removeEventListener('player:flushHistory', handleFlushHistory) + }, [savePlaybackHistory]) + + useEffect(() => { + return () => savePlaybackHistory(true) + }, [savePlaybackHistory]) + // 进入精简模式 const handleEnterMiniMode = useCallback(async () => { if (!currentUrl) return @@ -156,6 +325,12 @@ export default function VideoPlayer() { useEffect(() => { const video = videoRef.current if (!video || !currentUrl) return + const currentProtocol = inferProtocol(currentUrl) + window.__alphaPlaybackDebug = { + protocol: currentProtocol, + url: currentUrl, + events: [] + } // 清理旧实例 hlsRef.current?.destroy() @@ -164,6 +339,9 @@ export default function VideoPlayer() { dashRef.current = null video.pause() video.removeAttribute('src') + video.muted = volume <= 0 + video.volume = volume + video.playbackRate = speed video.load() // 重置失败标记和播放标记 @@ -189,12 +367,12 @@ export default function VideoPlayer() { let hlsRecoveryTimer = 0 - if (currentUrl.includes('.mpd')) { + if (currentProtocol === 'dash') { // DASH const player = dashjs.MediaPlayer().create() player.initialize(video, currentUrl, true) dashRef.current = player - } else if (Hls.isSupported()) { + } else if (currentProtocol === 'hls' && Hls.isSupported()) { let networkRecoveryAttempts = 0 let mediaRecoveryAttempts = 0 @@ -202,6 +380,11 @@ export default function VideoPlayer() { const hls = new Hls({ // 桌面 WebView 的 CSP 不允许 blob worker;主线程解析可避免创建失败导致黑屏。 enableWorker: false, + testBandwidth: false, + startFragPrefetch: true, + startLevel: 0, + maxBufferLength: 10, + maxMaxBufferLength: 30, xhrSetup: (xhr, _url) => { if (playHeader) { for (const [key, value] of Object.entries(playHeader)) { @@ -212,12 +395,46 @@ export default function VideoPlayer() { }) hls.loadSource(currentUrl) hls.attachMedia(video) + hls.on(Hls.Events.MANIFEST_LOADED, (_event, data) => { + appendHlsDebugEvent({ + event: 'manifest_loaded', + levels: data.levels?.length, + audioTracks: data.audioTracks?.length, + subtitles: data.subtitles?.length + }) + }) hls.on(Hls.Events.MANIFEST_PARSED, () => { console.log('[VideoPlayer] HLS manifest 解析成功') + appendHlsDebugEvent({ event: 'manifest_parsed', levels: hls.levels.length }) setPlaybackPhase('buffering', '清单已加载,正在缓冲...') video.play().catch(() => {}) }) + hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => { + appendHlsDebugEvent({ + event: 'level_loaded', + level: data.level, + fragments: data.details?.fragments?.length, + live: data.details?.live, + targetduration: data.details?.targetduration + }) + }) + hls.on(Hls.Events.FRAG_LOADING, (_event, data) => { + appendHlsDebugEvent({ + event: 'frag_loading', + sn: data.frag?.sn, + level: data.frag?.level, + type: data.frag?.type, + duration: data.frag?.duration + }) + }) hls.on(Hls.Events.FRAG_LOADED, (_event, data) => { + appendHlsDebugEvent({ + event: 'frag_loaded', + sn: data.frag?.sn, + level: data.frag?.level, + type: data.frag?.type, + payloadBytes: data.payload?.byteLength || 0 + }) networkRecoveryAttempts = 0 const now = performance.now() const payloadBytes = data.payload?.byteLength || 0 @@ -242,6 +459,15 @@ export default function VideoPlayer() { }) hls.on(Hls.Events.ERROR, (_event, data) => { console.error('[VideoPlayer] HLS错误:', data.type, data.details, data.fatal) + appendHlsDebugEvent({ + event: 'hls_error', + type: data.type, + details: data.details, + fatal: data.fatal, + responseCode: data.response?.code, + responseText: data.response?.text, + reason: data.error?.message + }) if (failureReportedRef.current) return if ( @@ -249,8 +475,7 @@ export default function VideoPlayer() { video.currentTime <= 0 && ( data.details === Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT || - data.details === Hls.ErrorDetails.LEVEL_LOAD_ERROR || - data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR + data.details === Hls.ErrorDetails.LEVEL_LOAD_ERROR ) ) { hls.stopLoad() @@ -300,7 +525,7 @@ export default function VideoPlayer() { } }) hlsRef.current = hls - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + } else if (currentProtocol === 'hls' && video.canPlayType('application/vnd.apple.mpegurl')) { // Safari 原生 HLS video.src = currentUrl video.play().catch(() => {}) @@ -394,6 +619,7 @@ export default function VideoPlayer() { const video = videoRef.current if (!video) return video.volume = volume + video.muted = volume <= 0 video.playbackRate = speed }, [volume, speed]) @@ -405,12 +631,26 @@ export default function VideoPlayer() { const onTimeUpdate = () => { setCurrentTime(video.currentTime) if (video.currentTime > 0) markPlaybackStarted() + savePlaybackHistory(false) + } + const onDurationChange = () => { + setDuration(video.duration || 0) + applyPendingSeek() } - const onDurationChange = () => setDuration(video.duration || 0) const onPlay = () => setIsPlaying(true) - const onPause = () => setIsPlaying(false) + const onPause = () => { + setIsPlaying(false) + savePlaybackHistory(true) + } + const onLoadedMetadata = () => applyPendingSeek() + const onLoadedData = () => { + applyPendingSeek() + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + markPlaybackStarted() + } + } const onPlaying = () => { - if (video.currentTime > 0) { + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA || video.currentTime > 0) { markPlaybackStarted() // 已开始播放时隐藏 stalled/buffering 覆盖层 clearStalledTimer() @@ -420,6 +660,7 @@ export default function VideoPlayer() { } } const onEnded = () => { + savePlaybackHistory(true, true) if (currentEpisodeIndex < episodes.length - 1) nextEpisode() else setIsPlaying(false) } @@ -428,6 +669,8 @@ export default function VideoPlayer() { video.addEventListener('durationchange', onDurationChange) video.addEventListener('play', onPlay) video.addEventListener('pause', onPause) + video.addEventListener('loadedmetadata', onLoadedMetadata) + video.addEventListener('loadeddata', onLoadedData) video.addEventListener('playing', onPlaying) video.addEventListener('ended', onEnded) @@ -436,10 +679,22 @@ export default function VideoPlayer() { video.removeEventListener('durationchange', onDurationChange) video.removeEventListener('play', onPlay) video.removeEventListener('pause', onPause) + video.removeEventListener('loadedmetadata', onLoadedMetadata) + video.removeEventListener('loadeddata', onLoadedData) video.removeEventListener('playing', onPlaying) video.removeEventListener('ended', onEnded) } - }, [currentEpisodeIndex, episodes.length, markPlaybackStarted, nextEpisode, setDuration, setCurrentTime, setIsPlaying]) + }, [ + applyPendingSeek, + currentEpisodeIndex, + episodes.length, + markPlaybackStarted, + nextEpisode, + savePlaybackHistory, + setDuration, + setCurrentTime, + setIsPlaying + ]) // 控制栏自动隐藏 const resetHideTimer = useCallback(() => { @@ -565,19 +820,30 @@ export default function VideoPlayer() { const linkSpeedText = formatThroughput(streamStats.linkSpeedKbps) const buildDiagnosticsText = () => { + const metricSummary = getPlaybackMetricSummary() const lines = [ 'IPTV Mac 播放诊断', `阶段: ${playbackPhase}`, `状态: ${playbackMessage || '-'}`, `错误: ${playbackError || '-'}`, + `错误阶段: ${playbackDiagnostic?.stage || '-'}`, + `错误类型: ${playbackDiagnostic?.errorKind || '-'}`, + `协议: ${playbackDiagnostic?.protocol || inferProtocol(currentUrl)}`, `站点: ${currentSiteKey || '-'}`, `影片: ${currentVod?.vod_name || '-'}`, `集数: ${episodes[currentEpisodeIndex]?.name || currentEpisodeIndex + 1 || '-'}`, - `线路索引: ${currentEpisodeIndex + 1}/${episodes.length || 0}`, + `线路索引: ${currentSourceIndex + 1}`, + `已失败源: ${brokenSources.size}`, + `备选源: ${alternativeSources.length}`, `首帧耗时: ${firstFrameMs == null ? '-' : `${firstFrameMs}ms`}`, `当前耗时: ${elapsedMs}ms`, `失败时间: ${playbackLastErrorAt ? new Date(playbackLastErrorAt).toISOString() : '-'}`, `换源状态: ${sourceSwitchState}${sourceSwitchMessage ? ` - ${sourceSwitchMessage}` : ''}`, + `下一步: ${playbackDiagnostic?.nextAction || (autoSwitchSource ? '自动换源开启' : '等待手动操作')}`, + `首帧样本数: ${metricSummary.totalFirstFrameSamples}`, + `首帧P50: ${metricSummary.p50FirstFrameMs == null ? '-' : `${metricSummary.p50FirstFrameMs}ms`}`, + `首帧P90: ${metricSummary.p90FirstFrameMs == null ? '-' : `${metricSummary.p90FirstFrameMs}ms`}`, + `失败样本数: ${metricSummary.totalFailures}`, `URL: ${redactedUrl || '-'}`, `Headers: ${Object.keys(redactedHeaders).length ? JSON.stringify(redactedHeaders) : '-'}` ] @@ -623,7 +889,7 @@ export default function VideoPlayer() { onMouseLeave={() => isPlaying && setShowControls(false)} onDoubleClick={handleToggleFullscreen} > -
- 可导入 + + {inspection.compatibilityLabel} +
@@ -393,12 +441,12 @@ export default function Settings() {

{inspection.visibleSiteCount}/{inspection.siteCount}

-

可搜索

-

{inspection.searchableSiteCount}

+

抽样通过

+

{probeMetric(inspection)}

直播源

-

{inspection.liveCount}

+

{inspection.liveChannelCount > 0 ? `${inspection.liveCount}/${inspection.liveChannelCount}` : inspection.liveCount}

解析器

@@ -431,6 +479,8 @@ export default function Settings() {
{configs.map((config) => { const isActive = config.url === currentUrl + const isConfirmingDelete = deleteConfirmUrl === config.url + const isDeleting = deletingConfigUrl === config.url return (
{config.url}

- {editingConfigUrl === config.url ? ( - <> + {isConfirmingDelete ? ( +
+ 确认删除? - +
) : ( - - )} - - {!isActive && ( - - )} - {isActive && ( - 当前 + <> + {editingConfigUrl === config.url ? ( + <> + + + + ) : ( + + )} + + {!isActive && ( + + )} + {isActive && ( + 当前 + )} + + )} -
) })} diff --git a/src/renderer/src/pages/VodDetail/VodDetail.tsx b/src/renderer/src/pages/VodDetail/VodDetail.tsx index ffbcfeb..b78e835 100644 --- a/src/renderer/src/pages/VodDetail/VodDetail.tsx +++ b/src/renderer/src/pages/VodDetail/VodDetail.tsx @@ -11,6 +11,7 @@ import { import { historyApi, keepApi, siteApi } from '@/utils/ipc' import { getPlayableMediaUrl } from '@/utils/media' import VideoPlayer from '@/components/VideoPlayer/VideoPlayer' +import type { History as HistoryItem } from '@shared/types' /** 判断 URL 是否为视频流格式 */ function isVideoFormat(url: string): boolean { @@ -29,6 +30,19 @@ interface LineSource { isCurrent: boolean // 是否为当前页面加载的源 } +function formatResumeTime(seconds = 0): string { + const safeSeconds = Math.max(0, Math.floor(seconds)) + const h = Math.floor(safeSeconds / 3600) + const m = Math.floor((safeSeconds % 3600) / 60) + const s = safeSeconds % 60 + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return `${m}:${String(s).padStart(2, '0')}` +} + +function canResume(history: HistoryItem | null): history is HistoryItem { + return Boolean(history && !history.completed && (history.positionSeconds || 0) > 5) +} + export default function VodDetail() { const { siteKey, vodId } = useParams<{ siteKey: string; vodId: string }>() const { currentConfig } = useConfigStore() @@ -51,6 +65,7 @@ export default function VodDetail() { const [isResolving, setIsResolving] = useState(false) const [allSourcesExhausted, setAllSourcesExhausted] = useState(false) const [loadError, setLoadError] = useState(null) + const [resumeHistory, setResumeHistory] = useState(null) // 当前正在播放的源信息 const [currentPlaySource, setCurrentPlaySource] = useState<{ siteKey: string; vodId: string } | null>(null) @@ -71,6 +86,7 @@ export default function VodDetail() { setIsLoading(true) setLoadError(null) setAllSourcesExhausted(false) + setResumeHistory(null) failureHandledRef.current = false resetSourceSwitch() @@ -90,6 +106,14 @@ export default function VodDetail() { return } setDetail(vod) + let savedHistory: HistoryItem | null = null + try { + const historyList = (await historyApi.list()) as HistoryItem[] + savedHistory = historyList.find((item) => item.siteKey === sKey && item.vodId === vId) || null + setResumeHistory(savedHistory) + } catch { + setResumeHistory(null) + } // 解析播放源和集数 const playFroms = (vod.vod_play_from || '').split('$$$').filter(Boolean) @@ -143,7 +167,10 @@ export default function VodDetail() { const bestLineIdx = parsedLines.findIndex((l) => /m3u8|mp4|mpd|flv|ts/i.test(l.name) && l.episodes.length > 0 ) - setActiveLineIndex(bestLineIdx >= 0 ? bestLineIdx : 0) + const resumeLineIdx = canResume(savedHistory) + ? Math.min(Math.max(savedHistory.sourceIndex || 0, 0), Math.max(0, parsedLines.length - 1)) + : -1 + setActiveLineIndex(resumeLineIdx >= 0 ? resumeLineIdx : bestLineIdx >= 0 ? bestLineIdx : 0) setIsLoading(false) } catch (err: any) { setLoadError(err?.message || '加载失败') @@ -197,7 +224,6 @@ export default function VodDetail() { switchingRef.current = true try { setSourceSwitchState('switching', `正在加载「${source.siteName}」...`) - markCurrentSourceBroken(source.siteKey, source.vodId) // 在当前页面内加载新源的详情 try { @@ -205,6 +231,7 @@ export default function VodDetail() { const result = res.data || res const vod = result.list?.[0] if (!vod) { + markCurrentSourceBroken(source.siteKey, source.vodId) setSourceSwitchState('idle', '加载失败,尝试下一个...') setTimeout(() => { if (mountedRef.current) setSourceSwitchState('idle', '') }, 2000) // 尝试队列中的下一个 @@ -265,6 +292,7 @@ export default function VodDetail() { } if (newLines.length === 0) { + markCurrentSourceBroken(source.siteKey, source.vodId) setSourceSwitchState('idle', '该源无播放数据') setTimeout(() => { if (mountedRef.current) setSourceSwitchState('idle', '') }, 2000) return @@ -279,6 +307,7 @@ export default function VodDetail() { setSourceSwitchState('idle', `已切换到「${source.siteName}」`) setTimeout(() => { if (mountedRef.current) setSourceSwitchState('idle', '') }, 2000) } catch (err) { + markCurrentSourceBroken(source.siteKey, source.vodId) setSourceSwitchState('idle', '加载失败') setTimeout(() => { if (mountedRef.current) setSourceSwitchState('idle', '') }, 2000) } @@ -336,10 +365,11 @@ export default function VodDetail() { }, [activeLine, activeLineIndex, currentEpisodeIndex, switchToNextSource]) // 播放集数 - const handlePlay = async (lineIdx: number, epIdx: number) => { + const handlePlay = async (lineIdx: number, epIdx: number, startPositionSeconds = 0) => { const line = lineSources[lineIdx] if (!line || !line.episodes[epIdx]) return + window.dispatchEvent(new Event('player:flushHistory')) setIsResolving(true) setPlaybackPhase('resolving', '正在获取播放信息...') setCurrentSourceIndex(lineIdx) @@ -378,15 +408,15 @@ export default function VodDetail() { setPlaybackPhase('connecting', '正在连接播放地址...') const playableUrl = await getPlayableMediaUrl(initialUrl, initialHeader) setCurrentPlaySource({ siteKey: targetSiteKey, vodId: targetVodId }) - setVod(targetDetail!, line.episodes, lineIdx, playableUrl, initialHeader, targetSiteKey) - setCurrentEpisodeIndex(epIdx, playableUrl) + setVod(targetDetail!, line.episodes, lineIdx, playableUrl, initialHeader, targetSiteKey, line.name, startPositionSeconds) + setCurrentEpisodeIndex(epIdx, playableUrl, startPositionSeconds) setShowPlayer(true) } else { // 需要解析,先展示播放器加载态 setCurrentPlaySource({ siteKey: targetSiteKey, vodId: targetVodId }) setShowPlayer(true) setPlaybackPhase('resolving', '正在解析播放地址...') - setVod(targetDetail!, line.episodes, lineIdx, '__resolving__', initialHeader, targetSiteKey) + setVod(targetDetail!, line.episodes, lineIdx, '__resolving__', initialHeader, targetSiteKey, line.name, startPositionSeconds) // 异步解析 try { @@ -405,19 +435,32 @@ export default function VodDetail() { ) const header = parseRes.data.header || initialHeader const store = usePlayerStore.getState() - setCurrentEpisodeIndex(epIdx, url) + setCurrentEpisodeIndex(epIdx, url, startPositionSeconds) store.play(url) + if (startPositionSeconds > 0) store.setCurrentTime(startPositionSeconds) } else { // 所有解析都失败 - setPlaybackError('解析完全失败') + setPlaybackError(parseRes.error || '解析失败:未找到可播放地址', { + stage: 'parse', + errorKind: 'parse_failed', + protocol: 'unknown', + sourceId: `${targetSiteKey}::${targetVodId}`, + nextAction: autoSwitchSource ? '自动尝试下一个备选源' : '可手动切换线路或重试当前集' + }) window.dispatchEvent(new CustomEvent('vod:playFailed', { - detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析完全失败', autoSwitch: true } + detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析失败:未找到可播放地址', autoSwitch: true } })) } } catch { - setPlaybackError('解析异常') + setPlaybackError('解析异常:无法完成播放地址解析', { + stage: 'parse', + errorKind: 'parse_failed', + protocol: 'unknown', + sourceId: `${targetSiteKey}::${targetVodId}`, + nextAction: autoSwitchSource ? '自动尝试下一个备选源' : '可手动切换线路或重试当前集' + }) window.dispatchEvent(new CustomEvent('vod:playFailed', { - detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析异常', autoSwitch: true } + detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: '解析异常:无法完成播放地址解析', autoSwitch: true } })) } } @@ -432,13 +475,31 @@ export default function VodDetail() { siteKey: targetSiteKey, vodName: targetDetail?.vod_name || '', vodPic: targetDetail?.vod_pic, + vodRemarks: targetDetail?.vod_remarks, source: episode.name ? `${line.name} · ${episode.name}` : line.name, - progress: 0 + progress: startPositionSeconds > 0 && resumeHistory?.duration + ? Math.max(0, Math.min(100, Math.round((startPositionSeconds / resumeHistory.duration) * 100))) + : 0, + episodeId: String(epIdx), + episodeName: episode.name || `第 ${epIdx + 1} 集`, + episodeIndex: epIdx, + sourceIndex: lineIdx, + sourceName: line.name, + urlIdentifier: episode.url.split('?')[0].slice(0, 240), + duration: resumeHistory?.duration || 0, + positionSeconds: startPositionSeconds, + completed: false }).catch(() => {}) } catch (err: any) { setIsResolving(false) setLoadError(err?.message || '获取播放信息失败') - setPlaybackError(err?.message || '获取播放信息失败') + setPlaybackError(err?.message || '获取播放信息失败', { + stage: 'connect', + errorKind: 'unknown', + protocol: 'unknown', + sourceId: `${targetSiteKey}::${targetVodId}`, + nextAction: autoSwitchSource ? '自动尝试下一个备选源' : '可手动切换线路或重试当前集' + }) window.dispatchEvent(new CustomEvent('vod:playFailed', { detail: { siteKey: targetSiteKey, vodId: targetVodId, reason: 'playerContent 失败', autoSwitch: true } })) @@ -494,6 +555,19 @@ export default function VodDetail() { ) } + const resumeLineIndex = canResume(resumeHistory) + ? Math.min(Math.max(resumeHistory.sourceIndex || 0, 0), Math.max(0, lineSources.length - 1)) + : -1 + const resumeEpisodeIndex = canResume(resumeHistory) + ? Math.min( + Math.max(resumeHistory.episodeIndex || 0, 0), + Math.max(0, (lineSources[resumeLineIndex]?.episodes.length || 1) - 1) + ) + : -1 + const resumeEpisodeName = resumeEpisodeIndex >= 0 + ? lineSources[resumeLineIndex]?.episodes[resumeEpisodeIndex]?.name || resumeHistory?.episodeName + : '' + return (
{/* 播放器 */} @@ -572,12 +646,26 @@ export default function VodDetail() { {detail?.vod_actor &&

演员:{detail.vod_actor}

}
+ {canResume(resumeHistory) && resumeLineIndex >= 0 && resumeEpisodeIndex >= 0 && ( + + )}