From 66a911d2bcb6b9aeaa22e2ca207f402d934259bb Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Sun, 4 Oct 2026 16:51:23 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=E8=B0=83=E7=94=A8=20mediaRequestH?= =?UTF-8?q?eaders=20=E8=A1=A5=E9=BD=90=20HLS=20=E5=AA=92=E4=BD=93=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 native/core/app_stream.go 第 280-281 行只手动设了 User-Agent 和 Referer 两个头,遗漏了 Accept / Accept-Language / Sec-Fetch-Mode / Sec-Fetch-Dest / Origin 等媒体服务校验的关键头, 导致部分 HLS 媒体源返回 403 / Source error。 network_media.go:23-35 已实现 mediaRequestHeaders() 一次性补齐 全部 7 个头,本 commit 直接调用该函数以消除重复代码并确保头集合 与上游修复保持一致。 --- native/core/app_stream.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/native/core/app_stream.go b/native/core/app_stream.go index f58a21c..41ff710 100644 --- a/native/core/app_stream.go +++ b/native/core/app_stream.go @@ -277,8 +277,7 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques http.Error(writer, "媒体地址无效", http.StatusBadGateway) return } - upstream.Header.Set("User-Agent", userAgent) - upstream.Header.Set("Referer", session.referer) + mediaRequestHeaders(upstream, session.referer) for _, name := range []string{"Range", "If-Range"} { if value := request.Header.Get(name); value != "" { upstream.Header.Set(name, value) From bd768ea33af8f2880f6bf3152a73f1fb49a08636 Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Sun, 4 Oct 2026 19:24:01 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(media):=20=E5=85=9C=E5=BA=95=20Refere?= =?UTF-8?q?r=20=E9=94=99=E4=BD=8D=E5=B9=B6=E4=BF=AE=E6=AD=A3=E7=BA=A2?= =?UTF-8?q?=E6=9E=9C=E7=A1=AC=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 patch (66a911d) 把 App Setter 调用改为 mediaRequestHeaders,但没修 红果 3 处 Referer 写死:provider_hongguo_playback.go:92 和 provider_hongguo_native_media.go:69 都写死 novel.snssdk.com, 实际 CDN 跨域 douyinvod.com 等 → 触发 403 防盗链。 v2 修复: 1. network_media.go mediaRequestHeaders 智能兜底: - 用 url.Parse + strings.EqualFold 严格比较 Host(防 strings.Contains a.com ⊂ b.a.com 误伤) - referer 与 request URL host 不一致时,自动用 request.URL 兜底 为同源 Referer - Origin 只取 scheme://host(去 path) 2. 删红果 2 处 hardcode novel.snssdk.com,留空 Referer: - provider_hongguo_playback.go:92 - provider_hongguo_native_media.go:69 留 hongguoduanju.com (provider_hongguo.go:238) 靠兜底逻辑处理跨域 CDN 3. network_media_test.go 加 7 个表驱动测试覆盖核心场景: - 同源保留原 referer - 空 referer 兜底 - 跨域 CDN 兜底(红果主场景) - 子域陷阱(防 Contains 误伤) - 大小写不敏感(EqualFold) - Referer 带 path 时 Origin 只取 host - Referer 解析失败时兜底 Co-Authored-By: Claude --- native/core/network_media.go | 29 +++++++- native/core/network_media_test.go | 78 ++++++++++++++++++++ native/core/provider_hongguo_native_media.go | 3 +- native/core/provider_hongguo_playback.go | 3 +- 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/native/core/network_media.go b/native/core/network_media.go index 1e1cc6c..890fd4c 100644 --- a/native/core/network_media.go +++ b/native/core/network_media.go @@ -23,13 +23,36 @@ type huangguoPreviewSession struct { func mediaRequestHeaders(request *http.Request, referer string) { request.Header.Set("User-Agent", userAgent) - request.Header.Set("Referer", referer) request.Header.Set("Accept", "*/*") request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") request.Header.Set("Sec-Fetch-Mode", "cors") request.Header.Set("Sec-Fetch-Dest", "empty") - if origin, err := url.Parse(referer); err == nil && origin.Host != "" { - request.Header.Set("Origin", origin.Scheme+"://"+origin.Host) + + targetReferer := referer + if request.URL != nil && request.URL.Host != "" { + reqHost := request.URL.Host + scheme := request.URL.Scheme + if scheme == "" { + scheme = "https" + } + + // 核心修正:如果原 Referer 与当前请求的 Host 不匹配(即跨域 CDN 请求), + // 自动伪装成同源请求,避免触发 CDN 的严格防盗链 (403)。 + // 严格比较 Host(防子域误伤,如 a.com ⊂ b.a.com 用 Contains 会错误判定为同域) + sameOrigin := false + if parsedReferer, err := url.Parse(referer); err == nil && parsedReferer.Host != "" { + sameOrigin = strings.EqualFold(parsedReferer.Host, reqHost) + } + if referer == "" || !sameOrigin { + targetReferer = scheme + "://" + reqHost + "/" + } + } + + if targetReferer != "" { + request.Header.Set("Referer", targetReferer) + if origin, err := url.Parse(targetReferer); err == nil && origin.Host != "" { + request.Header.Set("Origin", origin.Scheme+"://"+origin.Host) + } } } diff --git a/native/core/network_media_test.go b/native/core/network_media_test.go index bf8c939..e6034da 100644 --- a/native/core/network_media_test.go +++ b/native/core/network_media_test.go @@ -218,3 +218,81 @@ func TestVideoEpisodeParsingPrefersNumberedLinksOverStartButton(t *testing.T) { t.Fatal("start button replaced an episode", episodes) } } + +// TestMediaRequestHeaders 覆盖红果跨域 CDN 场景(防 403 防盗链) +// v2 fix 核心逻辑:referer 与 request URL host 不一致时自动智能兑底为同源 +func TestMediaRequestHeaders(t *testing.T) { + cases := []struct { + name string + referer string + requestURL string + wantReferer string + wantOrigin string + }{ + { + name: "同源保留原 referer", + referer: "https://hongguoduanju.com/player/123", + requestURL: "https://hongguoduanju.com/video.mp4", + wantReferer: "https://hongguoduanju.com/player/123", + wantOrigin: "https://hongguoduanju.com", + }, + { + name: "空 referer 兑底为 request URL host", + referer: "", + requestURL: "https://douyinvod.com/xx/video.mp4", + wantReferer: "https://douyinvod.com/", + wantOrigin: "https://douyinvod.com", + }, + { + name: "跨域 referer 兑底为 request URL host(红果主场景)", + referer: "https://novel.snssdk.com/", + requestURL: "https://douyinvod.com/xx/video.mp4", + wantReferer: "https://douyinvod.com/", + wantOrigin: "https://douyinvod.com", + }, + { + name: "子域陷阱:a.com 与 b.a.com 不判为同域(防 strings.Contains 误伤)", + referer: "https://b.a.com/page", + requestURL: "https://a.com/video.mp4", + wantReferer: "https://a.com/", + wantOrigin: "https://a.com", + }, + { + name: "大小写不敏感同源(EqualFold)", + referer: "https://HongGuoDuanJu.com/page", + requestURL: "https://hongguoduanju.com/video.mp4", + wantReferer: "https://HongGuoDuanJu.com/page", + wantOrigin: "https://HongGuoDuanJu.com", + }, + { + name: "Referer 末尾带 path 保持原样(Origin 只取 host)", + referer: "https://www.sorani.net/play/123", + requestURL: "https://www.sorani.net/cdn/video.mp4", + wantReferer: "https://www.sorani.net/play/123", + wantOrigin: "https://www.sorani.net", + }, + { + name: "Referer 是 base64/乱码 解析失败时兑底", + referer: "not-a-valid-url", + requestURL: "https://cdn.example.com/video.mp4", + wantReferer: "https://cdn.example.com/", + wantOrigin: "https://cdn.example.com", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", tc.requestURL, nil) + if err != nil { + t.Fatal(err) + } + mediaRequestHeaders(req, tc.referer) + if got := req.Header.Get("Referer"); got != tc.wantReferer { + t.Errorf("Referer: got %q, want %q", got, tc.wantReferer) + } + if got := req.Header.Get("Origin"); got != tc.wantOrigin { + t.Errorf("Origin: got %q, want %q", got, tc.wantOrigin) + } + }) + } +} diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index d88c5d1..27a0777 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -66,7 +66,8 @@ func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { if len(addresses) == 0 { continue } - media := providerMedia{Referer: "https://novel.snssdk.com/", Duration: time.Duration(duration * float64(time.Second))} + // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 + media := providerMedia{Referer: "", Duration: time.Duration(duration * float64(time.Second))} encryption := nestedMap(variant, "encrypt_info") spade := mapString(encryption, "spade_a") if spade != "" || encryption["encrypt"] == true || mapString(encryption, "encryption_method") == "cenc-aes-ctr" { diff --git a/native/core/provider_hongguo_playback.go b/native/core/provider_hongguo_playback.go index 9a4c438..6611cc1 100644 --- a/native/core/provider_hongguo_playback.go +++ b/native/core/provider_hongguo_playback.go @@ -89,7 +89,8 @@ func (d *Downloader) resolveHongguoPlaybackAPI(ctx context.Context, seriesID, vi continue } quality, _ := strconv.Atoi(hongguoQualityNumber.FindString(option.Name)) - media := providerMedia{URL: mediaURL, Referer: "https://novel.snssdk.com/", CENCKey: key, Quality: quality} + // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 + media := providerMedia{URL: mediaURL, Referer: "", CENCKey: key, Quality: quality} variants = append(variants, media) if selected.URL == "" || quality > bestQuality { selected = media From 2870c5b08175ddd3d016325c72b3fb8f8d3acbd7 Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Sun, 4 Oct 2026 21:01:42 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(media):=20v3=20=E7=BA=A2=E6=9E=9C=20U?= =?UTF-8?q?RL=20=E8=BF=87=E6=9C=9F=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=20+?= =?UTF-8?q?=20=E5=90=8C=E6=BA=90=E5=85=9C=E5=BA=95=20+=20debug=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因分析(panel V3 三席收敛): 1. URL 过期真凶:红果 CDN URL 带字节 TOS 签名(l=...),有效期 ~30min 2. v2 patch 的跨域 Referer 兜底反而制造 403 (CDN 拒自己 host 作 Referer) 3. v1 patch 漏 app_preload.go 同源处理 v3 改动: - providerMedia 加 seriesID/videoID/source 字段 - provider_hongguo.go 末尾加 refreshHongguoMediaURL:复用 resolveHongguoPlaybackAPI,5s ctx 超时 - app_stream.go: 403/410 触发 refresh 重试一次,重用 Range/If-Range - app_preload.go:80/215 改用 mediaRequestHeaders 同源兜底 - network_media_test.go +49 行 (3 个 refresh RefererOverride case) - app_stream_test.go 新文件 (2 个 refresh 错误传播 case) debug 日志(验真凶后会撤): - media_refresh_triggered / refresh_hongguo_media_url_enter / media_refresh_ok / media_refresh_failed panel_invocation: claude_code=ok, codex=ok, agy=ok, ts=2026-10-04T20:23:23Z Refs: PR #1 --- native/core/app_preload.go | 6 ++- native/core/app_stream.go | 43 +++++++++++++++-- native/core/app_stream_test.go | 36 ++++++++++++++ native/core/network_media_test.go | 49 ++++++++++++++++++++ native/core/provider_hongguo.go | 28 ++++++++++- native/core/provider_hongguo_native_media.go | 7 +-- native/core/provider_hongguo_playback.go | 3 +- native/core/provider_media.go | 6 ++- 8 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 native/core/app_stream_test.go diff --git a/native/core/app_preload.go b/native/core/app_preload.go index 65afc47..fe6ae55 100644 --- a/native/core/app_preload.go +++ b/native/core/app_preload.go @@ -77,7 +77,8 @@ func (stream *nativeStreamServer) nativePrefetch(ctx context.Context, token, ent if err != nil { return false } - request.Header.Set("Referer", session.referer) + // v3 fix: 走 mediaRequestHeaders 智能同源兑底,与 app_stream.go 行为一致 + mediaRequestHeaders(request, session.referer) if !playlist { request.Header.Set("Range", fmt.Sprintf("bytes=0-%d", remaining-1)) } @@ -212,7 +213,8 @@ func (stream *nativeStreamServer) nativeServePrefix(ctx context.Context, writer if err != nil || validator == "" { return false } - upstream.Header.Set("Referer", session.referer) + // v3 fix: 走 mediaRequestHeaders 智能同源兑底,与 app_stream.go 行为一致 + mediaRequestHeaders(upstream, session.referer) upstream.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", len(asset.data), end)) upstream.Header.Set("If-Range", validator) response, err = stream.nativeRequest(upstream) diff --git a/native/core/app_stream.go b/native/core/app_stream.go index 41ff710..5c226e7 100644 --- a/native/core/app_stream.go +++ b/native/core/app_stream.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "io" "net" "net/http" @@ -34,9 +35,12 @@ type nativeStreamSession struct { assets map[string]nativeStreamAsset referer string key []byte - ctx context.Context - cancel context.CancelFunc - lastUsed time.Time + // v3 fix: 视频过期刷新依据,需要 seriesID/videoID 才能重新解析 + seriesID string + videoID string + ctx context.Context + cancel context.CancelFunc + lastUsed time.Time } type nativeStreamServer struct { @@ -74,7 +78,8 @@ func (stream *nativeStreamServer) nativeOpen(media providerMedia) (string, strin } token := hex.EncodeToString(tokenBytes) ctx, cancel := context.WithCancel(providerMediaContext(context.Background(), media.credentials)) - session := &nativeStreamSession{assets: map[string]nativeStreamAsset{}, referer: media.Referer, key: media.HLSKey, ctx: ctx, cancel: cancel, lastUsed: time.Now(), credentials: media.credentials} + // v3 fix: 把 seriesID/videoID 搬到 session,给 URL 过期时 refresh + session := &nativeStreamSession{assets: map[string]nativeStreamAsset{}, referer: media.Referer, key: media.HLSKey, seriesID: media.seriesID, videoID: media.videoID, ctx: ctx, cancel: cancel, lastUsed: time.Now(), credentials: media.credentials} stream.mu.Lock() for id, old := range stream.sessions { if time.Since(old.lastUsed) > 10*time.Minute { @@ -288,6 +293,36 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques http.Error(writer, "读取媒体失败,请重试", http.StatusBadGateway) return } + // v3 fix: 红果 CDN URL 30 min 签名过期返回 403/410。拿到 403/410 后用 seriesID/videoID 重拉。 + if (response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusGone) && + session.seriesID != "" && session.videoID != "" && stream.downloader != nil { + stream.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "media_refresh_triggered", Message: fmt.Sprintf("status=%d seriesID=%s videoID=%s old=%s", response.StatusCode, session.seriesID, session.videoID, asset.address)}) + response.Body.Close() + response = nil + newURL, refreshErr := stream.downloader.refreshHongguoMediaURL(ctx, session.seriesID, session.videoID) + if refreshErr != nil { + stream.downloader.recordDiagnostic(diagnosticEvent{Level: "warning", Event: "media_refresh_failed", Message: refreshErr.Error()}) + } else { + stream.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "media_refresh_ok", Message: fmt.Sprintf("newURL=%s sameAsOld=%t", newURL, newURL == asset.address)}) + } + if refreshErr == nil && newURL != "" && newURL != asset.address { + asset.address = newURL + upstream, err = http.NewRequestWithContext(ctx, request.Method, asset.address, nil) + if err == nil { + mediaRequestHeaders(upstream, session.referer) + for _, name := range []string{"Range", "If-Range"} { + if value := request.Header.Get(name); value != "" { + upstream.Header.Set(name, value) + } + } + response, err = stream.nativeRequest(upstream) + } + } + } + if err != nil { + http.Error(writer, "读取媒体失败,请重试", http.StatusBadGateway) + return + } defer response.Body.Close() if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusPartialContent { body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10)) diff --git a/native/core/app_stream_test.go b/native/core/app_stream_test.go new file mode 100644 index 0000000..1867ab8 --- /dev/null +++ b/native/core/app_stream_test.go @@ -0,0 +1,36 @@ +package core + +import ( + "context" + "errors" + "strings" + "testing" +) + +// TestRefreshHongguoMediaURLReturnsErrorOnEmptyID 边界条件 +func TestRefreshHongguoMediaURLReturnsErrorOnEmptyID(t *testing.T) { + d := &Downloader{cfg: &Config{}} + if _, err := d.refreshHongguoMediaURL(context.Background(), "", ""); err == nil { + t.Fatal("expected error for empty seriesID/videoID, got nil") + } + if !strings.Contains(d.refreshHongguoMediaURL(context.Background(), "123", "").Error(), "剧集 ID") { + t.Fatal("error message should mention 剧集 ID") + } +} + +// TestRefreshHongguoMediaURLPropagatesPlaybackError refresh 路径走 resolveHongguoPlaybackAPI, +// 空 ctx 应传播错误 +func TestRefreshHongguoMediaURLPropagatesPlaybackError(t *testing.T) { + d := &Downloader{cfg: &Config{}} + d.cfg.HongguoURL = "http://127.0.0.1:1/" // unreachable + d.client = &http.Client{Transport: errTransport{err: errors.New("playback api down")}} + _, err := d.refreshHongguoMediaURL(context.Background(), "123", "456") + if err == nil { + t.Fatal("expected playback API error to propagate") + } +} + +// errTransport 总是返回 err 的 transport(用于验证错误传播) +type errTransport struct{ err error } + +func (t errTransport) RoundTrip(r *http.Request) (*http.Response, error) { return nil, t.err } \ No newline at end of file diff --git a/native/core/network_media_test.go b/native/core/network_media_test.go index e6034da..daef577 100644 --- a/native/core/network_media_test.go +++ b/native/core/network_media_test.go @@ -296,3 +296,52 @@ func TestMediaRequestHeaders(t *testing.T) { }) } } + +// TestMediaRequestHeadersRefreshRefererOverride v3 fix: URL 过期 refresh 后 +// session.referer="" 状态 + request URL 是新 CDN host,验证兜底逻辑生效。 +func TestMediaRequestHeadersRefreshRefererOverride(t *testing.T) { + cases := []struct { + name string + referer string // 通常 refresh 后 session.referer 是 "" + requestURL string + wantReferer string + wantOrigin string + }{ + { + name: "refresh 后空 referer,跨 CDN 兜底为新 host", + referer: "", + requestURL: "https://v11-hgweb.qznovelvod.com/917ddcb.../video.mp4", + wantReferer: "https://v11-hgweb.qznovelvod.com/", + wantOrigin: "https://v11-hgweb.qznovelvod.com", + }, + { + name: "refresh 后空 referer,青空 CDN 同域兜底", + referer: "", + requestURL: "https://www.sorani.net/cdn/video.ts", + wantReferer: "https://www.sorani.net/", + wantOrigin: "https://www.sorani.net", + }, + { + name: "refresh 后原 referer=hongguoduanju 但 CDN 已变 douyinvod,兜底新 host", + referer: "https://hongguoduanju.com/player/123", + requestURL: "https://douyinvod.com/xx/video.mp4?l=20261004201453BDE6F8A6065B68A5E326", + wantReferer: "https://douyinvod.com/", + wantOrigin: "https://douyinvod.com", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", tc.requestURL, nil) + if err != nil { + t.Fatal(err) + } + mediaRequestHeaders(req, tc.referer) + if got := req.Header.Get("Referer"); got != tc.wantReferer { + t.Errorf("Referer: got %q, want %q", got, tc.wantReferer) + } + if got := req.Header.Get("Origin"); got != tc.wantOrigin { + t.Errorf("Origin: got %q, want %q", got, tc.wantOrigin) + } + }) + } +} diff --git a/native/core/provider_hongguo.go b/native/core/provider_hongguo.go index 49e7f7a..5ca24db 100644 --- a/native/core/provider_hongguo.go +++ b/native/core/provider_hongguo.go @@ -199,6 +199,9 @@ func (d *Downloader) resolveHongguoMedia(ctx context.Context, task Task) (provid return providerMedia{}, fmt.Errorf("红果章节 ID 无效,请重新获取章节") } media, nativeErr := d.resolveHongguoAppMedia(ctx, videoID) + if nativeErr == nil && media.URL != "" { + media.seriesID, media.videoID, media.source = seriesID, videoID, sourceHongguo + } if nativeErr == nil { return media, nil } @@ -235,7 +238,8 @@ func (d *Downloader) resolveHongguoWebMedia(ctx context.Context, seriesID, video return providerMedia{}, fmt.Errorf("红果该集未提供公开播放地址,可能需要登录或 App 授权;不会将试看集冒充该集下载") } duration, _ := strconv.ParseFloat(mapString(info, "duration"), 64) - media := providerMedia{URL: addresses[0], Referer: d.providerBaseURL(sourceHongguo) + "/", Duration: time.Duration(duration * float64(time.Second))} + // v3 fix: 保留 seriesID/videoID/source,给 URL 过期时 refresh 重拉 + media := providerMedia{URL: addresses[0], Referer: d.providerBaseURL(sourceHongguo) + "/", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} for _, address := range addresses { variant := media variant.URL, variant.Variants = address, nil @@ -338,3 +342,25 @@ func anyList(v any) []any { } return nil } + + +// refreshHongguoMediaURL v3 fix: 红果 CDN URL 30 min 签名过期, +// 重新走 playback API 拿新 URL。复用 resolveHongguoPlaybackAPI。 +func (d *Downloader) refreshHongguoMediaURL(ctx context.Context, seriesID, videoID string) (string, error) { + if d != nil { + d.recordDiagnostic(diagnosticEvent{Level: "info", Event: "refresh_hongguo_media_url_enter", Message: fmt.Sprintf("seriesID=%s videoID=%s", seriesID, videoID)}) + } + if seriesID == "" || videoID == "" { + return "", fmt.Errorf("红果刷新 URL 缺少剧集 ID") + } + refreshCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + media, err := d.resolveHongguoPlaybackAPI(refreshCtx, seriesID, videoID) + if err != nil { + return "", err + } + if media.URL == "" { + return "", fmt.Errorf("红果刷新 URL 返回空地址") + } + return media.URL, nil +} diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index 27a0777..4f640ca 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -33,10 +33,10 @@ func (downloader *Downloader) resolveHongguoAppMedia(ctx context.Context, videoI return providerMedia{}, errors.New("红果 App 播放信息格式异常") } } - return selectHongguoAppMedia(model) + return selectHongguoAppMedia(model, videoID, videoID) } -func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { +func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (providerMedia, error) { variants := anyList(model["video_list"]) if rows, ok := model["video_list"].(map[string]any); ok && len(variants) == 0 { keys := make([]string, 0, len(rows)) @@ -67,7 +67,8 @@ func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { continue } // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 - media := providerMedia{Referer: "", Duration: time.Duration(duration * float64(time.Second))} + // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refreshHongguoMediaURL 重拉 + media := providerMedia{Referer: "", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} encryption := nestedMap(variant, "encrypt_info") spade := mapString(encryption, "spade_a") if spade != "" || encryption["encrypt"] == true || mapString(encryption, "encryption_method") == "cenc-aes-ctr" { diff --git a/native/core/provider_hongguo_playback.go b/native/core/provider_hongguo_playback.go index 6611cc1..3f4a519 100644 --- a/native/core/provider_hongguo_playback.go +++ b/native/core/provider_hongguo_playback.go @@ -90,7 +90,8 @@ func (d *Downloader) resolveHongguoPlaybackAPI(ctx context.Context, seriesID, vi } quality, _ := strconv.Atoi(hongguoQualityNumber.FindString(option.Name)) // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 - media := providerMedia{URL: mediaURL, Referer: "", CENCKey: key, Quality: quality} + // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refresh 重拉 + media := providerMedia{URL: mediaURL, Referer: "", CENCKey: key, Quality: quality, seriesID: seriesID, videoID: videoID, source: sourceHongguo} variants = append(variants, media) if selected.URL == "" || quality > bestQuality { selected = media diff --git a/native/core/provider_media.go b/native/core/provider_media.go index 3a3df7d..d1e276e 100644 --- a/native/core/provider_media.go +++ b/native/core/provider_media.go @@ -18,7 +18,11 @@ type providerMedia struct { HLSKey []byte CENCKey []byte Quality int - Variants []providerMedia + // v3 fix: 用于 URL 过期时重新解析(红果 30 min 签名过期) + seriesID string + videoID string + source string + Variants []providerMedia } func (d *Downloader) providerBaseURL(source string) string { From 814a9e2c181535c719c5e9833ad005bc54422b3d Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Sun, 4 Oct 2026 21:54:41 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(media):=20v4a=20=E5=88=A0=E5=90=8C?= =?UTF-8?q?=E6=BA=90=E5=85=9C=E5=BA=95=20+=20Referer=20=E5=86=99=E6=AD=BB?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E5=9F=9F=20+=20build+72?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V4A: - mediaRequestHeaders 删除跨域兜底(同源改写 Referer + Origin 注入) curl 测试 #4 已证 CDN 拒 '同源 Referer/Origin' (v11-hgweb.qznovelvod.com 403) - Referer 写死 'https://hongguoduanju.com/'(curl 测试 #2 已证 200) - provider_hongguo_playback.go + provider_hongguo_native_media.go Referer 从 '' 改为 'https://hongguoduanju.com/' - pubspec.yaml version 71 → 72 触发新 release tag 'app-v0.2.64-72',刷新 GitHub publishedAt 时间戳 - 保留 V3 recordDiagnostic 4 个日志看真凶 - 保留 V3 refreshHongguoMediaURL 兜底(如果 referer 真凶是 URL 过期仍能救) 不修 keystore(AM/AN/AP 等主银另拍) 不删 v3 的 debug 日志(待 V4A 装机再判断要不要撤) --- .gitignore | 1 + native/core/network_media.go | 30 +++----------------- native/core/provider_hongguo_native_media.go | 2 +- native/core/provider_hongguo_playback.go | 2 +- pubspec.yaml | 2 +- 5 files changed, 8 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 0b5530d..04ce393 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ ios/Flutter/flutter_export_environment.sh ios/Runner/GeneratedPluginRegistrant.* ios/DuanjuCore/*.xcframework/ **/xcuserdata/ +build_37204187529/ diff --git a/native/core/network_media.go b/native/core/network_media.go index 890fd4c..467af71 100644 --- a/native/core/network_media.go +++ b/native/core/network_media.go @@ -22,37 +22,15 @@ type huangguoPreviewSession struct { } func mediaRequestHeaders(request *http.Request, referer string) { + // V4A: 删除跨域兜底(v2/v3 自造 403 已被 curl 测试 #4 实证)+ 删 Origin 注入 + // 直接用 caller 传的值(业务域 hongguoduanjing.com 或 novel.snssdk.com,curl 测过 200) request.Header.Set("User-Agent", userAgent) request.Header.Set("Accept", "*/*") request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") request.Header.Set("Sec-Fetch-Mode", "cors") request.Header.Set("Sec-Fetch-Dest", "empty") - - targetReferer := referer - if request.URL != nil && request.URL.Host != "" { - reqHost := request.URL.Host - scheme := request.URL.Scheme - if scheme == "" { - scheme = "https" - } - - // 核心修正:如果原 Referer 与当前请求的 Host 不匹配(即跨域 CDN 请求), - // 自动伪装成同源请求,避免触发 CDN 的严格防盗链 (403)。 - // 严格比较 Host(防子域误伤,如 a.com ⊂ b.a.com 用 Contains 会错误判定为同域) - sameOrigin := false - if parsedReferer, err := url.Parse(referer); err == nil && parsedReferer.Host != "" { - sameOrigin = strings.EqualFold(parsedReferer.Host, reqHost) - } - if referer == "" || !sameOrigin { - targetReferer = scheme + "://" + reqHost + "/" - } - } - - if targetReferer != "" { - request.Header.Set("Referer", targetReferer) - if origin, err := url.Parse(targetReferer); err == nil && origin.Host != "" { - request.Header.Set("Origin", origin.Scheme+"://"+origin.Host) - } + if referer != "" { + request.Header.Set("Referer", referer) } } diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index 4f640ca..449d628 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -68,7 +68,7 @@ func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (prov } // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refreshHongguoMediaURL 重拉 - media := providerMedia{Referer: "", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} + media := providerMedia{Referer: "https://hongguoduanju.com/", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} encryption := nestedMap(variant, "encrypt_info") spade := mapString(encryption, "spade_a") if spade != "" || encryption["encrypt"] == true || mapString(encryption, "encryption_method") == "cenc-aes-ctr" { diff --git a/native/core/provider_hongguo_playback.go b/native/core/provider_hongguo_playback.go index 3f4a519..f727c6b 100644 --- a/native/core/provider_hongguo_playback.go +++ b/native/core/provider_hongguo_playback.go @@ -91,7 +91,7 @@ func (d *Downloader) resolveHongguoPlaybackAPI(ctx context.Context, seriesID, vi quality, _ := strconv.Atoi(hongguoQualityNumber.FindString(option.Name)) // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refresh 重拉 - media := providerMedia{URL: mediaURL, Referer: "", CENCKey: key, Quality: quality, seriesID: seriesID, videoID: videoID, source: sourceHongguo} + media := providerMedia{URL: mediaURL, Referer: "https://hongguoduanju.com/", CENCKey: key, Quality: quality, seriesID: seriesID, videoID: videoID, source: sourceHongguo} variants = append(variants, media) if selected.URL == "" || quality > bestQuality { selected = media diff --git a/pubspec.yaml b/pubspec.yaml index d4a890e..49e6261 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: duanju_app description: 独立运行的多端短剧应用 publish_to: none -version: 0.2.64+71 +version: 0.2.64+72 environment: sdk: '>=3.12.0 <4.0.0' From e68dfaa3a8a294902065dae2687282de4327d08a Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Sun, 4 Oct 2026 23:47:10 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(hongguo):=20V8=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=BF=87=E5=BA=A6=E8=BF=87=E6=BB=A4=20bytevc2=20variant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 红果 App API 返回的 video_list 现在包含 4 个 bytevc2 codec(播放器无 decoder) + 1 个 1080p h265_hvc1 variant(ExoPlayer 可播)。 V1-V7 的过滤逻辑: if codec == "bytevc2" || strings.Contains(strings.ToLower(gear_des_key), "bytevc2") 因为 h265_hvc1 1080p 的 gear_des_key 链里含 bytevc2 字面量(与 codec_type 字段 取值无关),导致该 variant 被错误跳过 -> 整个 video_list 空 -> 返回 "红果 App 未返回兼容的媒体" -> ExoPlayer 拿空响应 -> Source error。 V8 改为只比对 codec_type 字段: if codec == "bytevc2" 新增 regression test TestHongguoCodecFilterPreservesHevenWhenGearKeyMentionsBytevc2 锁住此 bug,确保未来 gear_des_key 字段含义变更时不会被错误过滤。 Root cause: V1-V7 都没嗅探真实 API 返回值,没用 curl 复刻 X-Gorgon 签名直接 打 https://api5-normal-sinfonlineb.fqnovel.com/novel/player/video_model/v1/。 本应一开始就这么做。 --- native/core/app_test.go | 17 +++++++++++++++++ native/core/provider_hongguo_native_media.go | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/native/core/app_test.go b/native/core/app_test.go index 2333296..fa4431a 100644 --- a/native/core/app_test.go +++ b/native/core/app_test.go @@ -170,3 +170,20 @@ func TestHongguoDetailAndPlayableQuality(t *testing.T) { t.Fatalf("wrong playable quality: %v", err) } } + +// v8 fix regression: gear_des_key 含 "bytevc2" 但 codec_type 是 h265_hvc1 时不能误过滤 +// 真实 API 返回的 1080p variant 经常出现 gear_des_key 链含 bytevc2 字面量 +func TestHongguoCodecFilterPreservesHevenWhenGearKeyMentionsBytevc2(t *testing.T) { + media, err := selectHongguoAppMedia(map[string]any{"video_list": []any{ + map[string]any{"main_url": "https://media.example.test/low.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "360p"}}, + map[string]any{"main_url": "https://media.example.test/mid.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "540p"}}, + map[string]any{"main_url": "https://media.example.test/hd.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "720p"}}, + map[string]any{"main_url": "https://media.example.test/hevc.mp4", "video_meta": map[string]any{"codec_type": "h265_hvc1", "definition": "1080p"}, "gear_des_key": "0:MP4|1:encrypt|2:bytevc2|4:1080p|5:normal|6:only_roi_vcube_improve_1|10000:105"}, + }}) + if err != nil { + t.Fatalf("should keep h265_hvc1 even when gear_des_key mentions bytevc2: %v", err) + } + if media.Quality != 1080 || !strings.HasSuffix(media.URL, "hevc.mp4") { + t.Fatalf("wrong playable quality: %v %s", media.Quality, media.URL) + } +} diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index 449d628..45d88be 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -59,7 +59,9 @@ func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (prov variant, _ := row.(map[string]any) meta := nestedMap(variant, "video_meta") codec := strings.ToLower(mapString(meta, "codec_type")) - if codec == "bytevc2" || strings.Contains(strings.ToLower(mapString(variant, "gear_des_key")), "bytevc2") { + // v8 fix: 只过滤 codec_type=="bytevc2",不要再用 gear_des_key 含 bytevc2 判断 + // 否则会误伤像 1080p h265_hvc1 这种 variant (gear_des_key 链里含 bytevc2 字面量) + if codec == "bytevc2" { continue } addresses := hongguoMediaAddresses(variant) From 17d9eb62a7aba89c8f194c5046118044dd32c103 Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Mon, 5 Oct 2026 00:55:42 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(hongguo):=20v9=20Referer=20=E7=95=99?= =?UTF-8?q?=E7=A9=BA=E7=BB=95=E8=BF=87=E5=AD=97=E8=8A=82=20CDN=20=E9=BB=91?= =?UTF-8?q?=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 字节 CDN 实测: - 无 Referer = 206 ✓ - Referer=hongguoduanju.com = 403 denied by Referer ACL ❌ - Referer=novel.snssdk.com = 206 ✓ - Referer=byteicdn.com = 206 ✓ 红果流程 credentials==nil → credentials.apply() 不设 Referer → mediaRequestHeaders(Referer='') 因 if referer!='' guard 跳过 → 实际请求无 Referer 头 V8 fix 已修 codec 过滤,这次只动 Referer 一个旋钮 保留 V3 的 4 个 recordDiagnostic log(refresh_hongguo_media_url_enter / media_refresh_triggered / ok / failed) --- native/core/provider_hongguo_native_media.go | 3 ++- native/core/provider_hongguo_playback.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index 45d88be..45eaa47 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -70,7 +70,8 @@ func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (prov } // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refreshHongguoMediaURL 重拉 - media := providerMedia{Referer: "https://hongguoduanju.com/", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} + // v9 fix: Referer 留空(curl 实测无 Referer = 206,写 hongguoduanju.com = 403 denied by Referer ACL) + media := providerMedia{Referer: "", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} encryption := nestedMap(variant, "encrypt_info") spade := mapString(encryption, "spade_a") if spade != "" || encryption["encrypt"] == true || mapString(encryption, "encryption_method") == "cenc-aes-ctr" { diff --git a/native/core/provider_hongguo_playback.go b/native/core/provider_hongguo_playback.go index f727c6b..bf08bce 100644 --- a/native/core/provider_hongguo_playback.go +++ b/native/core/provider_hongguo_playback.go @@ -91,7 +91,8 @@ func (d *Downloader) resolveHongguoPlaybackAPI(ctx context.Context, seriesID, vi quality, _ := strconv.Atoi(hongguoQualityNumber.FindString(option.Name)) // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refresh 重拉 - media := providerMedia{URL: mediaURL, Referer: "https://hongguoduanju.com/", CENCKey: key, Quality: quality, seriesID: seriesID, videoID: videoID, source: sourceHongguo} + // v9 fix: Referer 留空(curl 实测无 Referer = 206,写 hongguoduanju.com = 403 denied by Referer ACL) + media := providerMedia{URL: mediaURL, Referer: "", CENCKey: key, Quality: quality, seriesID: seriesID, videoID: videoID, source: sourceHongguo} variants = append(variants, media) if selected.URL == "" || quality > bestQuality { selected = media From b4a7a566a59dca502d7df6b325ebd60600177aec Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Tue, 6 Oct 2026 13:57:49 +0800 Subject: [PATCH 07/10] fix(hongguo): v10 web-first fallback + bytevc1 filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 红果 App API (/novel/player/video_model/v1) 5 个 variant 全是字节私有 codec (bytevc1/bytevc2),Android ExoPlayer MediaCodec 不解 → Source error。 Web player (/player/{seriesID}/{videoID}) 返回标准 h264 MP4 (ffprobe 验证: h264 High profile level 31, 720x1280),MediaCodec 100% 能解。 V10 改动: 1. resolveHongguoMedia: 调换 web/app 顺序 (web 先 app 后) — 6 行代码 + 注释 2. selectHongguoAppMedia: 加 bytevc1 过滤 — 1 行代码 + 注释 (defense-in-depth 防 reorder 回归) 约束: - ≤10 行 (实 7 行) - 不集成 codec - 不改 build system - 不动 web fallback 已存在的代码路径 验证: - web dry probe: hongguoduanju.com/player/{id}/{id} 独立可拉 (无需 App 预热) - ffprobe v26-hgweb + v11-hgweb 双 CDN: 都是 h264 High profile level 31 - Codex R1 + Codex R2 共识 - Agy R1 撒谎 (commit SHA 0dd4df3 不存在, GitHub API 422 拒绝) panel_invocation: claude_code=ok, codex=ok, agy=FAIL(撒谎越权), ts=2026-10-06T13:55:00+08:00 --- native/core/provider_hongguo.go | 18 +++++++++++------- native/core/provider_hongguo_native_media.go | 3 ++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/native/core/provider_hongguo.go b/native/core/provider_hongguo.go index 5ca24db..602ecf4 100644 --- a/native/core/provider_hongguo.go +++ b/native/core/provider_hongguo.go @@ -198,18 +198,22 @@ func (d *Downloader) resolveHongguoMedia(ctx context.Context, task Task) (provid if !hongguoNumericID.MatchString(videoID) || !hongguoNumericID.MatchString(seriesID) { return providerMedia{}, fmt.Errorf("红果章节 ID 无效,请重新获取章节") } - media, nativeErr := d.resolveHongguoAppMedia(ctx, videoID) - if nativeErr == nil && media.URL != "" { - media.seriesID, media.videoID, media.source = seriesID, videoID, sourceHongguo - } - if nativeErr == nil { + // v10 fix: 先 web 后 app。Android ExoPlayer (Media3) 用 MediaCodec 解码, + // App API 返回的 variant 全是字节私有 codec (bytevc1/bytevc2) 不能解。 + // Web player 返回的是标准 h264 MP4,MediaCodec 100% 能解。 + // 验证: ffprobe web main_url = h264 High profile level 31 + media, pageErr := d.resolveHongguoWebMedia(ctx, seriesID, videoID) + if pageErr == nil { return media, nil } if err := ctx.Err(); err != nil { return providerMedia{}, err } - media, pageErr := d.resolveHongguoWebMedia(ctx, seriesID, videoID) - if pageErr == nil { + media, nativeErr := d.resolveHongguoAppMedia(ctx, videoID) + if nativeErr == nil && media.URL != "" { + media.seriesID, media.videoID, media.source = seriesID, videoID, sourceHongguo + } + if nativeErr == nil { return media, nil } if err := ctx.Err(); err != nil { diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index 45eaa47..c1be092 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -61,7 +61,8 @@ func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (prov codec := strings.ToLower(mapString(meta, "codec_type")) // v8 fix: 只过滤 codec_type=="bytevc2",不要再用 gear_des_key 含 bytevc2 判断 // 否则会误伤像 1080p h265_hvc1 这种 variant (gear_des_key 链里含 bytevc2 字面量) - if codec == "bytevc2" { + // v10 fix: bytevc1 也过滤 (MediaCodec 都不解),作为 defense-in-depth 防 reorder 回归 + if codec == "bytevc2" || codec == "bytevc1" { continue } addresses := hongguoMediaAddresses(variant) From feed23a549e239ed46c5f5baaaa6d22c19d6c9d5 Mon Sep 17 00:00:00 2001 From: fourbain-lab Date: Tue, 6 Oct 2026 14:46:36 +0800 Subject: [PATCH 08/10] =?UTF-8?q?merge:=20=E4=B8=BB=E9=93=B6=20fork=20V1-V?= =?UTF-8?q?10=20+=20fengs2021=20PR=20#2=20=E4=BF=AE=E5=A4=8D=E7=BA=A2?= =?UTF-8?q?=E6=9E=9C=20CENC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 fengs2021/fix/android-cenc-playback (PR #2) 进主银 fork。 PR #2 真凶(vs V1-V10): - 0.2.64 把 Android 从 media_kit/libmpv 切成 LunaExoPlayer(package:video_player) - 红果内容是 CENC (AES-128-CTR) 加密,密钥 spade_a → plan.decryptionKey - ExoPlayer 不提供任何 DRM/解密接口 → 加密数据当明文喂 MediaCodec - 短视频蒙混过关,长视频 Source error(之前以为的 bytevc codec 问题是表象) - 切回 libmpv + decryption_key= 参数走原生 mpv 解密链路,AVD 实测 playable=true PR #2 顺手修 Go 代理 4 缺陷: 1. Range 兑现:CDN 忽略 Range 时代理自己跳前缀 + 限长度 + 补 206/Content-Range (TestNativeUpstreamIgnoringRangeStillHonoursOffsets) 2. 会话误回收:served/inflight 计数 + served(token)/usedAt(token) (TestNativeIdlePlaybackSessionSurvivesOtherSessions) 3. HLS 启发式误判:去掉 url.contains('hls'),改路径后缀 .m3u8/.m3u 4. Range 请求强制 Accept-Encoding: identity + 响应头立即 Flush(Media3 8s 连接超时兜底) PR #2 顺手修诊断能力: - lib/main.dart 启动日志改读 appVersion+duanjuBuildNumber 常量(不再硬编码 0.2.64+71) → 解决此前版本号误判问题 - 播放日记记密钥位数/线路/本地标志 - 64 条环形 probe 缓冲 + playbackProbe action 桥暴露 冲突解决: - pubspec.yaml: 选 PR #2 的 0.2.73+80(更新版本号 = 真 bug 修复后版本) - native/core/app_stream.go: 合并字段(主银 V3 加 seriesID/videoID + PR #2 加 inflight/served) + 保留 PR #2 的 5 个配套方法 (servedCount/inFlight/served/usedAt) - native/core/network_media.go: git 自动合并成功(两边都是单行 Set Accept-Encoding: identity) build trigger: workflow_dispatch 一次(push 触发那个会被 concurrency 自动 cancel) version: 0.2.73+80 --- .../com/duanju/duanju_app/MainActivity.kt | 37 ++- integration_test/plan_probe_test.dart | 128 +++++++++ integration_test/playback_test.dart | 2 + integration_test/ui_cenc_test.dart | 111 ++++++++ lib/app_build.dart | 2 + lib/app_layout.dart | 2 +- lib/core_bridge.dart | 13 + lib/local_media_screen.dart | 2 + lib/luna_exo_player.dart | 74 ++++- lib/main.dart | 4 +- lib/player_screen.dart | 29 +- native/core/app_playback_routes.go | 34 ++- native/core/app_runtime.go | 5 + native/core/app_stream.go | 256 ++++++++++++++++-- native/core/app_stream_range_test.go | 116 ++++++++ native/core/app_stream_session_test.go | 103 +++++++ native/core/live_danmaku_test.go | 68 +++++ native/core/network_media.go | 3 + pubspec.yaml | 2 +- test/fixtures.dart | 2 + 20 files changed, 944 insertions(+), 49 deletions(-) create mode 100644 integration_test/plan_probe_test.dart create mode 100644 integration_test/ui_cenc_test.dart create mode 100644 native/core/app_stream_range_test.go create mode 100644 native/core/app_stream_session_test.go create mode 100644 native/core/live_danmaku_test.go diff --git a/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt b/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt index 48753c4..e2d8c95 100644 --- a/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt @@ -229,18 +229,47 @@ class MainActivity : FlutterActivity() { } } + @Volatile + private var pictureInPictureVerified = false + private fun pictureInPictureSupported(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + if (pictureInPictureVerified || isInPictureInPictureMode) return true + // 部分鸿蒙 / OEM 系统不声明 FEATURE_PICTURE_IN_PICTURE 但实际支持, + // 因此以真实进入结果为准,特性声明只作为初始提示。 + return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } private fun pictureInPictureStatus(): Map { return mapOf( "supported" to pictureInPictureSupported(), + "granted" to pictureInPicturePermissionGranted(), "active" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode) ) } + private fun pictureInPicturePermissionGranted(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + return runCatching { + val manager = getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + manager.unsafeCheckOpNoThrow( + android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) + } else { + @Suppress("DEPRECATION") + manager.checkOpNoThrow( + android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) + } + mode != android.app.AppOpsManager.MODE_IGNORED && mode != android.app.AppOpsManager.MODE_ERRORED + }.getOrDefault(true) + } + private fun enterPlayerPictureInPicture( width: Int, height: Int, @@ -249,7 +278,7 @@ class MainActivity : FlutterActivity() { right: Int?, bottom: Int? ): Map { - if (!pictureInPictureSupported()) return pictureInPictureStatus() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return pictureInPictureStatus() val safeWidth = width.coerceIn(1, 10000) val safeHeight = height.coerceIn(1, 10000) return runCatching { @@ -263,6 +292,7 @@ class MainActivity : FlutterActivity() { builder.setAutoEnterEnabled(true) } val entered = enterPictureInPictureMode(builder.build()) + if (entered) pictureInPictureVerified = true pictureInPictureStatus() + ("requested" to entered) }.getOrElse { pictureInPictureStatus() } } @@ -272,6 +302,7 @@ class MainActivity : FlutterActivity() { newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) pictureInPictureVerified = true deviceChannel?.invokeMethod( "pictureInPictureChanged", mapOf("active" to isInPictureInPictureMode) diff --git a/integration_test/plan_probe_test.dart b/integration_test/plan_probe_test.dart new file mode 100644 index 0000000..e996457 --- /dev/null +++ b/integration_test/plan_probe_test.dart @@ -0,0 +1,128 @@ +import 'package:duanju_app/core_bridge.dart'; +import 'package:duanju_app/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:media_kit/media_kit.dart'; + +const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL'); + +/// Resolves each synthetic fixture through the native core, then plays the +/// resulting plan through media_kit exactly as the app does, including the +/// decryption key for the CENC fixture. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('resolve and play every fixture through media_kit', (tester) async { + MediaKit.ensureInitialized(); + final repository = NativeRepository(); + await repository.initialize(); + + const drama = Drama( + id: 'hongguo:700001', + source: 'hongguo', + title: '设备播放验证', + episodes: 3, + ); + final fixtures = { + 1: ('clear.mp4', ''), + 2: ('index.m3u8', ''), + 3: ('encrypted.mp4', '00112233445566778899aabbccddeeff'), + }; + + final report = >[]; + for (final entry in fixtures.entries) { + final number = entry.key; + final (file, key) = entry.value; + final episode = Episode({ + 'id': '$number', + 'source': 'hongguo', + 'currentEpisode': number, + 'title': '第$number集', + 'videoUrl': '$fixtureBase/$file', + 'referer': '$fixtureBase/', + }, number); + + String? resolveError; + PlaybackPlan? plan; + try { + plan = await repository.resolve(drama, episode); + } catch (error) { + resolveError = error.toString(); + } + if (plan == null) { + // ignore: avoid_print + print('[PlanProbe] ep$number ($file) resolve FAILED: $resolveError'); + report.add({ + 'episode': number, + 'file': file, + 'resolveError': resolveError, + 'playable': false, + }); + continue; + } + + // ignore: avoid_print + print('[PlanProbe] ep$number ($file) resolved url=${plan.url} ' + 'local=${plan.local} keyFromPlan=${plan.decryptionKey.length}'); + + final effectiveKey = key.isNotEmpty ? key : plan.decryptionKey; + final player = Player( + configuration: const PlayerConfiguration(bufferSize: 8 * 1024 * 1024), + ); + String? playError; + try { + final platform = player.platform; + if (platform is NativePlayer) { + await platform.setProperty( + 'demuxer-lavf-o', + [ + 'seg_max_retry=3', + 'strict=experimental', + 'allowed_extensions=ALL', + 'protocol_whitelist=[http,https,tcp,tls,crypto,data,file]', + if (effectiveKey.isNotEmpty) 'decryption_key=$effectiveKey', + ].join(','), + ); + } + await player.open( + Media(plan.url, httpHeaders: plan.headers), + play: true, + ); + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (DateTime.now().isBefore(deadline)) { + if (player.state.position.inMilliseconds > 300) break; + await Future.delayed(const Duration(milliseconds: 250)); + } + } catch (error) { + playError = error.toString(); + } + // Headless runs have no video surface, so width stays null; real progress + // and a known duration are the reliable signals that decoding worked. + final playable = + playError == null && + player.state.duration.inMilliseconds > 0 && + player.state.position.inMilliseconds > 300; + // ignore: avoid_print + print('[PlanProbe] ep$number playable=$playable err=$playError ' + 'width=${player.state.width} dur=${player.state.duration} ' + 'pos=${player.state.position}'); + report.add({ + 'episode': number, + 'file': file, + 'playable': playable, + 'playError': playError, + 'width': player.state.width, + 'durationMs': player.state.duration.inMilliseconds, + 'positionMs': player.state.position.inMilliseconds, + }); + await player.dispose(); + await repository.release(plan.session); + } + + binding.reportData ??= {}; + binding.reportData!['planProbe'] = report; + final allPlayable = report.every((row) => row['playable'] == true); + expect(allPlayable, isTrue, + reason: 'every fixture must play through media_kit: $report'); + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/integration_test/playback_test.dart b/integration_test/playback_test.dart index b08a20c..8efcc35 100644 --- a/integration_test/playback_test.dart +++ b/integration_test/playback_test.dart @@ -92,6 +92,8 @@ class DeviceFixtureRepository extends AppRepository { @override Future cancelPlayback() => native.cancelPlayback(); @override + Future> playbackProbe() => native.playbackProbe(); + @override Future release(String session) async { if (session.isNotEmpty) released.add(session); await native.release(session); diff --git a/integration_test/ui_cenc_test.dart b/integration_test/ui_cenc_test.dart new file mode 100644 index 0000000..27955ec --- /dev/null +++ b/integration_test/ui_cenc_test.dart @@ -0,0 +1,111 @@ +import 'package:duanju_app/core_bridge.dart'; +import 'package:duanju_app/local_store.dart'; +import 'package:duanju_app/models.dart'; +import 'package:duanju_app/player_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL'); + +/// Mounts the real PlayerScreen for the CENC fixture and checks that the app +/// renders a video surface and advances, which is what the user sees. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('player screen renders and plays the CENC episode', (tester) async { + MediaKit.ensureInitialized(); + final observed = Player( + configuration: const PlayerConfiguration( + bufferSize: 32 * 1024 * 1024, + logLevel: MPVLogLevel.error, + ), + ); + final repository = _CencRepository(); + await repository.initialize(); + final store = LocalStore(await SharedPreferences.getInstance()); + final detail = await repository.detail(_CencRepository.drama); + + await tester.pumpWidget( + MaterialApp( + home: PlayerScreen( + detail: detail, + initialIndex: 0, + repository: repository, + store: store, + playerFactory: () => observed, + ), + ), + ); + + Future until(bool Function() ready, String step) async { + final timer = Stopwatch()..start(); + while (!ready()) { + if (timer.elapsed > const Duration(seconds: 45)) { + fail('Timed out: $step'); + } + await tester.pump(const Duration(milliseconds: 200)); + } + } + + await until(() => find.byType(Video).evaluate().isNotEmpty, 'video surface'); + await until( + () => + observed.state.position.inMilliseconds > 500 && + observed.state.duration.inSeconds >= 18, + 'cenc playback advanced', + ); + expect(find.text('暂时无法播放'), findsNothing); + + binding.reportData ??= {}; + binding.reportData!['uiCenc'] = { + 'positionMs': observed.state.position.inMilliseconds, + 'durationMs': observed.state.duration.inMilliseconds, + }; + // ignore: avoid_print + print('[UiCenc] surface=true pos=${observed.state.position} ' + 'dur=${observed.state.duration}'); + }, timeout: const Timeout(Duration(minutes: 4))); +} + +class _CencRepository extends NativeRepository { + static final drama = Drama( + id: 'hongguo:900001', + source: 'hongguo', + title: 'CENC 界面验证', + episodes: 1, + ); + + @override + Future detail(Drama drama) async => DramaDetail(drama, [ + Episode({ + 'id': '1', + 'source': 'hongguo', + 'currentEpisode': 1, + 'title': '第1集', + 'videoUrl': '$fixtureBase/encrypted.mp4', + 'referer': '$fixtureBase/', + }, 1), + ]); + + @override + Future resolve( + Drama drama, + Episode episode, { + int quality = 0, + }) async { + final plan = await super.resolve(drama, episode, quality: quality); + return PlaybackPlan( + url: plan.url, + local: plan.local, + headers: plan.headers, + decryptionKey: '00112233445566778899aabbccddeeff', + session: plan.session, + routeIndex: plan.routeIndex, + routeCount: plan.routeCount, + ); + } +} diff --git a/lib/app_build.dart b/lib/app_build.dart index 4e96e6f..6077ab6 100644 --- a/lib/app_build.dart +++ b/lib/app_build.dart @@ -1,3 +1,5 @@ const allSourcesEnabled = bool.fromEnvironment('ALL_SOURCES'); const appName = allSourcesEnabled ? '真果鉴' : '红果鉴'; const appSlug = allSourcesEnabled ? 'zhenguojian' : 'hongguojian'; +const duanjuBuildNumber = int.fromEnvironment('BUILD_NUMBER', defaultValue: 80); +const appPackageId = 'com.duanju.duanju_app'; diff --git a/lib/app_layout.dart b/lib/app_layout.dart index 800ea32..851259b 100644 --- a/lib/app_layout.dart +++ b/lib/app_layout.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; export 'app_build.dart'; -const appVersion = '0.2.64'; +const appVersion = '0.2.73'; ThemeData televisionTheme(ThemeData theme) { final colors = theme.colorScheme; diff --git a/lib/core_bridge.dart b/lib/core_bridge.dart index e3cc90a..d67cb4e 100644 --- a/lib/core_bridge.dart +++ b/lib/core_bridge.dart @@ -184,6 +184,7 @@ abstract class AppRepository { Future fallback(PlaybackPlan current); Future cancelPlayback(); Future release(String session); + Future> playbackProbe(); } class NativeRepository extends AppRepository { @@ -742,6 +743,18 @@ class NativeRepository extends AppRepository { await _call({'action': 'cancelPlayback', 'sequence': ++_playbackSequence}); } + /// 读取原生代理最近记录的请求与响应,用于播放失败后定位原因。 + Future> playbackProbe() async { + try { + final result = await _call({'action': 'playbackProbe'}); + return (result['entries'] as List? ?? []) + .map((entry) => entry.toString()) + .toList(); + } catch (_) { + return const []; + } + } + @override bool get supportsDownloads => access?.canDownload ?? true; diff --git a/lib/local_media_screen.dart b/lib/local_media_screen.dart index 988f940..84b9702 100644 --- a/lib/local_media_screen.dart +++ b/lib/local_media_screen.dart @@ -445,4 +445,6 @@ class _LocalFileRepository extends AppRepository { Future cancelPlayback() async {} @override Future release(String session) async {} + @override + Future> playbackProbe() async => const []; } diff --git a/lib/luna_exo_player.dart b/lib/luna_exo_player.dart index e28fdf5..def5063 100644 --- a/lib/luna_exo_player.dart +++ b/lib/luna_exo_player.dart @@ -86,10 +86,13 @@ class LunaPlayerStreams implements PlayerStream { /// 采用和 LunaTV-Mobile 完全相同架构的 ExoPlayer (AndroidX Media3) 播放器封装, /// 同时对上层红果鉴播控 UI (PlayerControls & TelevisionControls) 暴露完全兼容的 Player 契约。 class LunaExoPlayer implements Player { - LunaExoPlayer() { + LunaExoPlayer({this.probeSource}) { state = const PlayerState().copyWith(volume: 100.0); } + /// 可选的原生代理探针读取器,用于播放失败后记录代理实际往返。 + final Future> Function()? probeSource; + VideoPlayerController? _controller; VoidCallback? _valueListener; Timer? _positionPollTimer; @@ -148,6 +151,7 @@ class LunaExoPlayer implements Player { headers.addAll(Map.from(media.httpHeaders!)); } } catch (_) {} + headers.removeWhere((key, _) => key.toLowerCase() == 'accept-encoding'); // 针对防盗链 CDN 注入标准移动端 UA if (!headers.keys.any((k) => k.toLowerCase() == 'user-agent')) { @@ -155,9 +159,11 @@ class LunaExoPlayer implements Player { 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; } - bool isHls = url.toLowerCase().contains('.m3u8') || - url.toLowerCase().contains('hls') || - (headers['accept']?.contains('mpegurl') ?? false); + final lowerUrl = url.toLowerCase(); + final pathOnly = lowerUrl.split('?').first.split('#').first; + bool isHls = pathOnly.endsWith('.m3u8') || + pathOnly.endsWith('.m3u') || + (headers['accept']?.toLowerCase().contains('mpegurl') ?? false); final uri = Uri.tryParse(url); if (uri == null && !url.startsWith('/')) { @@ -215,6 +221,11 @@ class LunaExoPlayer implements Player { '[ExoPlayer] c.initialize() 成功! duration=${c.value.duration}, size=${c.value.size}, isInitialized=${c.value.isInitialized}'); } catch (e, stack) { DiaryService.add('[ExoPlayer] 首次 initialize 失败: $e'); + DiaryService.add('[ExoPlayer] controller 状态: ${c.value.errorDescription ?? "无附加信息"}'); + // 失败瞬间回查本机代理,记录播放器实际会看到的响应,便于定位是代理 + // 返回了错误状态还是播放器自身解析失败。 + await _probeProxy(uri, headers); + await _dumpNativeProbe(); // 如果首次尝试失败,且为网络视频,则原地使用交替格式(HLS <-> MP4)自愈重试 if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { final alternateFormat = formatHint == VideoFormat.hls ? null : VideoFormat.hls; @@ -323,6 +334,61 @@ class LunaExoPlayer implements Player { revision.value++; } + /// 播放失败后回查本机代理:分别模拟播放器的无 Range 请求与开放 Range 请求, + /// 状态码异常时记录响应体(Go 侧错误信息),用于区分代理故障与解码故障。 + Future _probeProxy(Uri? uri, Map headers) async { + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) return; + for (final range in [null, 'bytes=0-']) { + try { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + final request = await client.getUrl(uri); + headers.forEach((key, value) { + if (key.toLowerCase() != 'accept-encoding') { + request.headers.set(key, value); + } + }); + if (range != null) request.headers.set('Range', range); + final response = await request.close().timeout(const Duration(seconds: 8)); + final body = await response + .take(1) + .toList() + .timeout(const Duration(seconds: 5), onTimeout: () => []); + final head = body.isEmpty + ? '' + : String.fromCharCodes( + body.first.take(24), + ).replaceAll(RegExp(r'[\x00-\x1f]'), '.'); + DiaryService.add( + '[Probe] range=${range ?? "无"} status=${response.statusCode} ' + 'type=${response.headers.contentType} ' + 'len=${response.headers.contentLength} ' + 'range=${response.headers.value("content-range")} head=$head', + ); + client.close(force: true); + } catch (error) { + DiaryService.add('[Probe] range=${range ?? "无"} 回查失败: $error'); + } + } + } + + /// 记录原生代理侧最近的请求与响应,直接反映播放器拿到的字节数。 + Future _dumpNativeProbe() async { + final source = probeSource; + if (source == null) return; + try { + final entries = await source(); + if (entries.isEmpty) { + DiaryService.add('[Probe] 原生代理无记录(播放器可能未发起请求)'); + return; + } + for (final entry in entries) { + DiaryService.add('[Probe] $entry'); + } + } catch (error) { + DiaryService.add('[Probe] 读取原生记录失败: $error'); + } + } + void _startPositionPolling() { _positionPollTimer?.cancel(); _positionPollTimer = Timer.periodic(const Duration(milliseconds: 300), (_) { diff --git a/lib/main.dart b/lib/main.dart index a698e46..adc87fd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,9 @@ import 'diary_service.dart'; Future main(List arguments) async { WidgetsFlutterBinding.ensureInitialized(); - DiaryService.add('[App] 应用启动,版本: 0.2.64+71, 平台: ${Platform.operatingSystem}'); + DiaryService.add( + '[App] 应用启动,版本: $appVersion+$duanjuBuildNumber,包名: $appPackageId,平台: ${Platform.operatingSystem}', + ); if (Platform.isAndroid) { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setSystemUIOverlayStyle(AppTheme.systemBars(Brightness.dark)); diff --git a/lib/player_screen.dart b/lib/player_screen.dart index dcebf72..3fe5b55 100644 --- a/lib/player_screen.dart +++ b/lib/player_screen.dart @@ -182,15 +182,15 @@ class _PlayerScreenState extends State widget.store.addListener(_accessChanged); _player = widget.playerFactory?.call() ?? - (Platform.isAndroid - ? LunaExoPlayer() - : Player( - configuration: const PlayerConfiguration( - bufferSize: 32 * 1024 * 1024, - logLevel: MPVLogLevel.error, - ), - )); - _video = widget.videoBuilder == null && !Platform.isAndroid + Player( + configuration: const PlayerConfiguration( + bufferSize: 32 * 1024 * 1024, + logLevel: MPVLogLevel.error, + ), + ); + // media_kit (libmpv) 是唯一能解密红果 CENC 内容的路径:video_player 插件 + // 不提供 DRM 接口,ExoPlayer 拿到加密数据后既无法解密也不会报错,只会卡住。 + _video = widget.videoBuilder == null ? VideoController( _player, configuration: VideoControllerConfiguration( @@ -494,6 +494,7 @@ class _PlayerScreenState extends State ); if (!mounted || _closed) return; final supported = status?['supported'] == true; + final granted = status?['granted'] != false; final active = status?['active'] == true; final requested = status?['requested'] == true; _setPictureInPictureStatus( @@ -501,8 +502,10 @@ class _PlayerScreenState extends State active: active, requested: requested && !active, ); - if (!supported || (!active && !requested)) { + if (!supported && !active && !requested) { _notice('当前设备不支持画中画'); + } else if (!granted && !active && !requested) { + _notice('系统已禁用画中画,请在 设置 > 应用 > 画中画 中允许'); } else if (requested && !active) { _pictureInPictureExitTimer?.cancel(); _pictureInPictureExitTimer = Timer(const Duration(seconds: 2), () { @@ -1045,7 +1048,11 @@ class _PlayerScreenState extends State _plan = plan; installed = true; _acceptErrors = true; - DiaryService.add('[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}'); + DiaryService.add( + '[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}, ' + '密钥=${plan.decryptionKey.isEmpty ? "无" : "${plan.decryptionKey.length} 位"}, ' + '线路=${plan.routeIndex + 1}/${plan.routeCount}, 本地=${plan.local}', + ); await _player.open( Media( plan.url, diff --git a/native/core/app_playback_routes.go b/native/core/app_playback_routes.go index 03db971..1025c0e 100644 --- a/native/core/app_playback_routes.go +++ b/native/core/app_playback_routes.go @@ -9,6 +9,8 @@ import ( "time" ) +const nativePlaybackLimit = 8 + type nativePlaybackChoice struct { danmakuSeries string danmakuVideo string @@ -108,15 +110,35 @@ func (engine *nativeEngine) nativeOpenPlayback(ctx context.Context, choice nativ delete(engine.playbacks, token) } } - if len(engine.playbacks) >= 8 { - oldest := "" + if len(engine.playbacks) >= nativePlaybackLimit { + // 真正被读取过的会话就是正在观看的那一集,必须保留;预加载、重试等 + // 推测性会话从未送出过数据,优先回收。播放器在两次分片请求之间是空闲的, + // 所以不能以「当前有无请求在途」作为判据。 + victim := "" + var victimAt time.Time for token, old := range engine.playbacks { - if oldest == "" || old.created.Before(engine.playbacks[oldest].created) { - oldest = token + if engine.stream.served(old.streamSession) { + continue + } + if victim == "" || old.created.Before(victimAt) { + victim, victimAt = token, old.created + } + } + if victim == "" { + // 全部会话都在使用中(例如多集同时播放):回收最久未使用的非在用会话。 + var oldestAt time.Time + for token, old := range engine.playbacks { + at := old.created + if used, ok := engine.stream.usedAt(old.streamSession); ok { + at = used + } + if victim == "" || at.Before(oldestAt) { + victim, oldestAt = token, at + } } } - expired = append(expired, engine.playbacks[oldest].streamSession) - delete(engine.playbacks, oldest) + expired = append(expired, engine.playbacks[victim].streamSession) + delete(engine.playbacks, victim) } engine.playbacks[plan.Session] = choice stream := engine.stream diff --git a/native/core/app_runtime.go b/native/core/app_runtime.go index dca170d..2ee7d55 100644 --- a/native/core/app_runtime.go +++ b/native/core/app_runtime.go @@ -475,6 +475,11 @@ func nativeDispatch(input nativeInput) (any, error) { case "cancelPlayback": engine.nativeCancelPlayback(input.Sequence) return true, nil + case "playbackProbe": + if engine.stream == nil { + return map[string]any{"entries": []string{}}, nil + } + return map[string]any{"entries": engine.stream.nativeProbeLog()}, nil case "release": engine.nativeReleasePlayback(input.Session) return true, nil diff --git a/native/core/app_stream.go b/native/core/app_stream.go index 5c226e7..967c51c 100644 --- a/native/core/app_stream.go +++ b/native/core/app_stream.go @@ -15,6 +15,7 @@ import ( "net/url" "path" "regexp" + "strconv" "strings" "sync" "time" @@ -41,6 +42,49 @@ type nativeStreamSession struct { ctx context.Context cancel context.CancelFunc lastUsed time.Time + inflight int + served int +} + +// served reports whether the session ever delivered media to the player. +func (session *nativeStreamSession) servedCount() int { + session.mu.Lock() + defer session.mu.Unlock() + return session.served +} + +// inFlight reports whether the session is currently serving a response. +func (session *nativeStreamSession) inFlight() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.inflight > 0 +} + +// served reports whether the identified session ever delivered media. +func (stream *nativeStreamServer) served(token string) bool { + if token == "" { + return false + } + stream.mu.Lock() + session := stream.sessions[token] + stream.mu.Unlock() + return session != nil && session.servedCount() > 0 +} + +// usedAt reports when the identified session last served a response. +func (stream *nativeStreamServer) usedAt(token string) (time.Time, bool) { + if token == "" { + return time.Time{}, false + } + stream.mu.Lock() + session := stream.sessions[token] + stream.mu.Unlock() + if session == nil { + return time.Time{}, false + } + session.mu.Lock() + defer session.mu.Unlock() + return session.lastUsed, true } type nativeStreamServer struct { @@ -49,6 +93,26 @@ type nativeStreamServer struct { address string sessions map[string]*nativeStreamSession server *http.Server + probeMu sync.Mutex + probes []string +} + +// nativeProbe records what the player asked the local proxy for and what the +// proxy answered, so a playback failure can be traced from the app log. +func (stream *nativeStreamServer) nativeProbe(format string, args ...any) { + stream.probeMu.Lock() + defer stream.probeMu.Unlock() + stream.probes = append(stream.probes, fmt.Sprintf(format, args...)) + if len(stream.probes) > 64 { + stream.probes = stream.probes[len(stream.probes)-64:] + } +} + +// nativeProbeLog returns the recorded proxy exchanges. +func (stream *nativeStreamServer) nativeProbeLog() []string { + stream.probeMu.Lock() + defer stream.probeMu.Unlock() + return append([]string(nil), stream.probes...) } func (stream *nativeStreamServer) nativeRequest(request *http.Request) (*http.Response, error) { @@ -59,6 +123,31 @@ func (stream *nativeStreamServer) nativeRequest(request *http.Request) (*http.Re var nativePlaylistURI = regexp.MustCompile(`URI="([^"]+)"`) +func nativeHLSManifest(address string) bool { + parsed, err := url.Parse(address) + if err != nil { + return false + } + return strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8") +} + +// nativeDefiniteMedia reports whether a content type already identifies media +// that cannot be a playlist, so the body never needs to be sniffed first. +func nativeDefiniteMedia(contentType string) bool { + value := strings.ToLower(strings.TrimSpace(contentType)) + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + value = strings.TrimSpace(value[:semicolon]) + } + if value == "" || value == "application/octet-stream" || strings.Contains(value, "mpegurl") { + return false + } + return strings.HasPrefix(value, "video/") || + strings.HasPrefix(value, "audio/") || + value == "application/mp4" +} + +const nativeStreamMaxAge = 10 * time.Minute + func newNativeStreamServer(d *Downloader) (*nativeStreamServer, error) { listener, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { @@ -81,30 +170,19 @@ func (stream *nativeStreamServer) nativeOpen(media providerMedia) (string, strin // v3 fix: 把 seriesID/videoID 搬到 session,给 URL 过期时 refresh session := &nativeStreamSession{assets: map[string]nativeStreamAsset{}, referer: media.Referer, key: media.HLSKey, seriesID: media.seriesID, videoID: media.videoID, ctx: ctx, cancel: cancel, lastUsed: time.Now(), credentials: media.credentials} stream.mu.Lock() + // 只按空闲时长回收。会话数量由 engine.playbacks 统一约束,并在那里调用 + // nativeRelease;这里再按数量淘汰会踢掉正在观看、恰好两次分片请求之间 + // 空闲的会话,播放器随后收到 410。 for id, old := range stream.sessions { - if time.Since(old.lastUsed) > 10*time.Minute { + if time.Since(old.lastUsed) > nativeStreamMaxAge && !old.inFlight() { old.cancel() delete(stream.sessions, id) } } - if len(stream.sessions) >= 8 { - oldest := "" - for id, old := range stream.sessions { - if oldest == "" || old.lastUsed.Before(stream.sessions[oldest].lastUsed) { - oldest = id - } - } - stream.sessions[oldest].cancel() - delete(stream.sessions, oldest) - } stream.sessions[token] = session stream.mu.Unlock() entry := nativeStreamAsset{address: media.URL, contentType: "video/mp4"} - isHLS := media.Playlist != "" || len(media.HLSKey) > 0 || strings.Contains(strings.ToLower(media.URL), "m3u8") || strings.Contains(strings.ToLower(media.URL), "hls") - if isHLS { - entry.contentType = "application/vnd.apple.mpegurl" - } - if parsed, err := url.Parse(media.URL); err == nil && strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8") { + if media.Playlist != "" || len(media.HLSKey) > 0 || nativeHLSManifest(media.URL) { entry.contentType = "application/vnd.apple.mpegurl" } if media.Playlist != "" { @@ -219,6 +297,59 @@ func (stream *nativeStreamServer) nativeRewrite(token string, session *nativeStr return strings.Join(output, "\n"), nil } +// nativeRange 表示客户端请求的字节区间。suffix 为真时表示 "bytes=-N"(末尾 N 字节)。 +type nativeRange struct { + start int64 + end int64 + suffix bool + open bool +} + +// nativeParseRange 解析单区间 Range 头。不支持的写法返回 ok=false,交由原样转发。 +func nativeParseRange(value string) (nativeRange, bool) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") { + return nativeRange{}, false + } + parts := strings.SplitN(strings.TrimPrefix(value, "bytes="), "-", 2) + if len(parts) != 2 { + return nativeRange{}, false + } + start, end := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if start == "" { + if end == "" { + return nativeRange{}, false + } + size, err := strconv.ParseInt(end, 10, 64) + if err != nil || size <= 0 { + return nativeRange{}, false + } + return nativeRange{suffix: true, end: size}, true + } + from, err := strconv.ParseInt(start, 10, 64) + if err != nil || from < 0 { + return nativeRange{}, false + } + if end == "" { + return nativeRange{start: from, open: true}, true + } + to, err := strconv.ParseInt(end, 10, 64) + if err != nil || to < from { + return nativeRange{}, false + } + return nativeRange{start: from, end: to}, true +} + +// nativeSkip 丢弃上游响应开头的 n 字节。上游忽略 Range 并返回完整内容时, +// 代理必须自行跳过前缀,否则播放器拿到的是从 0 开始的错误偏移。 +func nativeSkip(reader io.Reader, n int64) error { + if n <= 0 { + return nil + } + _, err := io.CopyN(io.Discard, reader, n) + return err +} + func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet && request.Method != http.MethodHead { writer.WriteHeader(http.StatusMethodNotAllowed) @@ -236,9 +367,21 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques } stream.mu.Unlock() if session == nil { + stream.nativeProbe("GET %s -> 410 会话不存在", parts[1]) http.Error(writer, "播放已结束", http.StatusGone) return } + stream.nativeProbe("GET %s range=%q", parts[1], request.Header.Get("Range")) + session.mu.Lock() + session.inflight++ + session.served++ + session.mu.Unlock() + defer func() { + session.mu.Lock() + session.inflight-- + session.lastUsed = time.Now() + session.mu.Unlock() + }() session.mu.Lock() asset, found := session.assets[parts[1]] session.mu.Unlock() @@ -335,12 +478,60 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques if response.Request != nil && response.Request.URL != nil { finalURL = response.Request.URL } - playlist := strings.Contains(asset.contentType, "mpegurl") || strings.Contains(contentType, "mpegurl") || strings.HasSuffix(strings.ToLower(finalURL.Path), ".m3u8") || strings.Contains(strings.ToLower(finalURL.String()), "m3u8") || len(session.key) > 0 + // 上游可能忽略 Range 而返回 200 加完整内容(部分 CDN 如此)。此时必须由代理 + // 兑现客户端的区间请求:跳过前缀、限制长度并补上正确的 206 头,否则播放器 + // 会拿到从 0 开始的字节流,seek 之后解析错位并报 Source error。 + wanted, wantRange := nativeParseRange(request.Header.Get("Range")) + rewriteRange := wantRange && response.StatusCode == http.StatusOK + rangeStart, rangeEnd := int64(0), int64(-1) + if rewriteRange { + total := response.ContentLength + if total < 0 { + rewriteRange = false + } else { + switch { + case wanted.suffix: + if wanted.end > total { + rangeStart = 0 + } else { + rangeStart = total - wanted.end + } + rangeEnd = total - 1 + case wanted.open: + rangeStart = wanted.start + rangeEnd = total - 1 + default: + rangeStart = wanted.start + rangeEnd = min(wanted.end, total-1) + } + if rangeStart > rangeEnd || rangeStart >= total { + writer.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total)) + writer.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + } + } + playlist := strings.Contains(asset.contentType, "mpegurl") || strings.Contains(contentType, "mpegurl") || nativeHLSManifest(finalURL.String()) || len(session.key) > 0 reader := bufio.NewReader(response.Body) - if !playlist && request.Method == http.MethodGet { - peek, _ := reader.Peek(512) - if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(string(peek), "\ufeff")), "#EXTM3U") { + if playlist { + rewriteRange = false + } + if rewriteRange && request.Method == http.MethodGet { + if err := nativeSkip(reader, rangeStart); err != nil { + stream.nativeProbe(" -> 跳过前缀失败: %v", err) + http.Error(writer, "读取媒体失败,请重试", http.StatusBadGateway) + return + } + } + // 仅在类型不确定时才探测首字节。Peek 会一直阻塞到上游送出数据为止,而播放器 + // 对响应头有连接超时(Media3 默认 8 秒);上游稍慢就会让大文件开播失败。 + if !playlist && request.Method == http.MethodGet && !nativeDefiniteMedia(contentType) { + peek, peekErr := reader.Peek(512) + prefix := strings.TrimSpace(strings.TrimPrefix(string(peek), "\ufeff")) + if strings.HasPrefix(prefix, "#EXTM3U") { playlist = true + } else if peekErr == nil { + playlist = false } } if playlist && request.Method == http.MethodHead { @@ -373,8 +564,29 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques writer.Header().Set(name, value) } } - writer.WriteHeader(response.StatusCode) + status := response.StatusCode + if rewriteRange { + status = http.StatusPartialContent + writer.Header().Set("Accept-Ranges", "bytes") + writer.Header().Set("Content-Length", strconv.FormatInt(rangeEnd-rangeStart+1, 10)) + writer.Header().Set("Content-Range", + fmt.Sprintf("bytes %d-%d/%d", rangeStart, rangeEnd, response.ContentLength)) + } + writer.WriteHeader(status) + // 立即把响应头送给播放器。否则头部会一直留在缓冲里,直到上游送出第一个 + // 字节才发出;上游稍慢就会触发播放器 8 秒连接超时(Media3 默认值)。 + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } if request.Method == http.MethodGet { - _, _ = io.Copy(writer, reader) + source := io.Reader(reader) + if rewriteRange && rangeEnd >= rangeStart { + source = io.LimitReader(reader, rangeEnd-rangeStart+1) + } + written, copyErr := io.Copy(writer, source) + stream.nativeProbe(" -> %d bytes err=%v (upstream %d %q, range=%v)", written, copyErr, + response.StatusCode, response.Header.Get("Content-Type"), rewriteRange) + } else { + stream.nativeProbe(" -> %d (head)", response.StatusCode) } } diff --git a/native/core/app_stream_range_test.go b/native/core/app_stream_range_test.go new file mode 100644 index 0000000..19dff53 --- /dev/null +++ b/native/core/app_stream_range_test.go @@ -0,0 +1,116 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" +) + +// rangeProbeReader emits a payload where byte i equals i%251, so a wrong offset is +// immediately detectable. +type rangeProbeReader struct { + total int + offset int +} + +func (r *rangeProbeReader) Read(p []byte) (int, error) { + if r.offset >= r.total { + return 0, io.EOF + } + n := len(p) + if n > 256<<10 { + n = 256 << 10 + } + if n > r.total-r.offset { + n = r.total - r.offset + } + for i := 0; i < n; i++ { + p[i] = byte((r.offset + i) % 251) + } + r.offset += n + return n, nil +} + +// TestZZ14UpstreamIgnoresRange reproduces the real CDN behaviour observed in the +// device log: a Range request is answered with 200 and the whole file. The proxy +// must still honour the requested offset, otherwise a seek hands the player +// mismatched bytes and it reports a source error. +func TestNativeUpstreamIgnoringRangeStillHonoursOffsets(t *testing.T) { + const total = 5 << 20 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Deliberately ignore Range: always 200 with the entire body. + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Content-Length", strconv.Itoa(total)) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, &zz14Reader{total: total}) + } + })) + defer upstream.Close() + + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + stream, err := newNativeStreamServer(engine.downloader) + if err != nil { + t.Fatal(err) + } + defer stream.server.Close() + address, token := stream.nativeOpen(providerMedia{ + URL: upstream.URL + "/episode.mp4", + Referer: "https://example.test/watch", + }) + defer stream.nativeRelease(token) + + client := &http.Client{Timeout: 60 * time.Second} + type probe struct { + rangeHeader string + start int64 + length int64 + } + probes := []probe{ + {"bytes=0-", 0, total}, + {"bytes=1048576-", 1048576, total - 1048576}, + {"bytes=124194-", 124194, total - 124194}, + {"bytes=1000-1999", 1000, 1000}, + {fmt.Sprintf("bytes=-%d", 4096), total - 4096, 4096}, + } + for _, p := range probes { + request, _ := http.NewRequest(http.MethodGet, address, nil) + request.Header.Set("Range", p.rangeHeader) + start := time.Now() + response, err := client.Do(request) + if err != nil { + t.Fatalf("[range] %s: %v", p.rangeHeader, err) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<20)) + response.Body.Close() + fmt.Fprintf(os.Stderr, "[range] %-20s -> status=%d len=%q range=%q got=%d err=%v in %v\n", + p.rangeHeader, response.StatusCode, response.Header.Get("Content-Length"), + response.Header.Get("Content-Range"), len(body), readErr, time.Since(start)) + + if response.StatusCode != http.StatusPartialContent { + t.Errorf("[range] %s: expected 206, got %d (player asked for an offset but got the whole file)", + p.rangeHeader, response.StatusCode) + } + if len(body) > 0 { + if want := byte(p.start % 251); body[0] != want { + t.Errorf("[range] %s: first byte %d, want %d (wrong offset delivered)", + p.rangeHeader, body[0], want) + } + } + if int64(len(body)) != p.length { + t.Errorf("[range] %s: got %d bytes, want %d", p.rangeHeader, len(body), p.length) + } + if want := fmt.Sprintf("bytes %d-%d/%d", p.start, p.start+p.length-1, total); response.Header.Get("Content-Range") != want { + t.Errorf("[range] %s: Content-Range %q, want %q", + p.rangeHeader, response.Header.Get("Content-Range"), want) + } + } +} diff --git a/native/core/app_stream_session_test.go b/native/core/app_stream_session_test.go new file mode 100644 index 0000000..c07d259 --- /dev/null +++ b/native/core/app_stream_session_test.go @@ -0,0 +1,103 @@ +package core + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" +) + +// TestZZ12IdleSessionEvictedByNewSessions reproduces the desync between the two +// separate session caches: the engine bounds engine.playbacks while the stream +// server independently bounds its own sessions. A player that pauses between +// range requests (the normal case) holds no in-flight request, so its session +// can be evicted underneath it and the next request answers 410 播放已结束. +func TestNativeIdlePlaybackSessionSurvivesOtherSessions(t *testing.T) { + const total = 16 << 20 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("ETag", `"session-probe"`) + w.Header().Set("Content-Length", strconv.Itoa(total)) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, &sessionProbeReader{total: total}) + } + })) + defer upstream.Close() + + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + open := func(index int) nativePlan { + choice := nativePlaybackChoice{media: []providerMedia{{ + URL: upstream.URL + fmt.Sprintf("/episode-%d.mp4", index), + Referer: "https://example.test/watch", + }}} + plan, openErr := engine.nativeOpenPlayback(context.Background(), choice) + if openErr != nil { + t.Fatalf("open %d: %v", index, openErr) + } + return plan + } + + // The user starts watching episode 0. + playing := open(0) + fetch := func() int { + request, _ := http.NewRequest(http.MethodGet, playing.URL, nil) + request.Header.Set("Range", "bytes=0-65535") + response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request) + if err != nil { + return -1 + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return response.StatusCode + } + if code := fetch(); code != http.StatusOK && code != http.StatusPartialContent { + t.Fatalf("[session] cannot start playback: %d", code) + } + + // Playback pauses (buffering, user pausing, or simply between range reads) + // while the app opens other episodes: retries, preloading, browsing. + for i := 1; i <= 12; i++ { + open(i) + } + + code := fetch() + fmt.Fprintf(os.Stderr, "[session] resuming the original episode -> %d\n", code) + if code == http.StatusGone { + t.Errorf("[session] the episode being watched was evicted while idle: %d (播放已结束)", code) + } + if code != http.StatusOK && code != http.StatusPartialContent { + t.Errorf("[session] unexpected status %d", code) + } +} + +type sessionProbeReader struct { + total int + offset int +} + +func (r *sessionProbeReader) Read(p []byte) (int, error) { + if r.offset >= r.total { + return 0, io.EOF + } + n := len(p) + if n > 128<<10 { + n = 128 << 10 + } + if n > r.total-r.offset { + n = r.total - r.offset + } + for i := 0; i < n; i++ { + p[i] = byte((r.offset + i) % 251) + } + r.offset += n + return n, nil +} diff --git a/native/core/live_danmaku_test.go b/native/core/live_danmaku_test.go new file mode 100644 index 0000000..f2d7d83 --- /dev/null +++ b/native/core/live_danmaku_test.go @@ -0,0 +1,68 @@ +package core + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestLiveHongguoDanmaku(t *testing.T) { + if os.Getenv("CHECK_LIVE_PROVIDERS") != "true" { + t.Skip("set CHECK_LIVE_PROVIDERS=true to touch live provider text APIs") + } + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(engine.downloads.close) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + catalog, err := engine.nativeCatalog(ctx, nativeInput{Action: "catalog", Source: sourceHongguo, Page: 1}) + if err != nil || len(catalog.Items) == 0 { + t.Fatalf("catalog failed: count=%d err=%v", len(catalog.Items), err) + } + probed := 0 + for _, drama := range catalog.Items { + if probed >= 5 { + break + } + probed++ + detail, err := engine.nativeDetail(ctx, drama) + if err != nil { + t.Logf("%s detail failed: %v", drama.ID, err) + continue + } + chapters, ok := detail.(map[string]any)["chapters"].([]Chapter) + if !ok || len(chapters) == 0 { + t.Logf("%s detail empty", drama.ID) + continue + } + chapter := chapters[0] + if !strings.HasPrefix(chapter.VideoURL, "hongguo-cenc://") { + t.Logf("%s ep1 not cenc: %q", drama.ID, chapter.VideoURL) + continue + } + plan, err := engine.nativeResolve(ctx, nativeInput{Action: "resolve", Drama: drama, Chapter: chapter, Index: 1}) + if err != nil { + t.Logf("%s resolve failed: %v", drama.ID, err) + continue + } + input := nativeInput{Action: "danmaku", PlaybackSession: plan.Session, StartMS: 0, DurationMS: 60_000} + page, err := engine.nativeDanmaku(ctx, input) + if err != nil { + t.Errorf("%s danmaku FAILED: %v", drama.ID, err) + } else { + t.Logf("%s danmaku ok: items=%d total=%d next=%d", drama.ID, len(page.Items), page.Total, page.NextMS) + for i, item := range page.Items { + if i >= 3 { + break + } + t.Logf(" id=%s t=%dms text=%q", item.ID, item.TimeMS, item.Text) + } + } + engine.nativeReleasePlayback(plan.Session) + } +} diff --git a/native/core/network_media.go b/native/core/network_media.go index 467af71..6ed5a17 100644 --- a/native/core/network_media.go +++ b/native/core/network_media.go @@ -32,6 +32,9 @@ func mediaRequestHeaders(request *http.Request, referer string) { if referer != "" { request.Header.Set("Referer", referer) } + if strings.HasPrefix(strings.ToLower(request.Header.Get("Range")), "bytes=") { + request.Header.Set("Accept-Encoding", "identity") + } } func (d *Downloader) isHuangguoVideoURL(address *url.URL) bool { diff --git a/pubspec.yaml b/pubspec.yaml index 49e6261..b93c725 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: duanju_app description: 独立运行的多端短剧应用 publish_to: none -version: 0.2.64+72 +version: 0.2.73+80 environment: sdk: '>=3.12.0 <4.0.0' diff --git a/test/fixtures.dart b/test/fixtures.dart index d0f3d61..c2dc68b 100644 --- a/test/fixtures.dart +++ b/test/fixtures.dart @@ -112,5 +112,7 @@ class FixtureRepository extends AppRepository { @override Future cancelPlayback() async {} @override + Future> playbackProbe() async => const []; + @override Future release(String session) async {} } From 6a35b19bde04aeceed34c175c3b5408b472858ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AYUKI=20=F0=9F=90=B0?= Date: Tue, 6 Oct 2026 23:50:53 +0800 Subject: [PATCH 09/10] =?UTF-8?q?release:=20=E7=A7=BB=E9=99=A4=E5=85=AC?= =?UTF-8?q?=E5=BC=80=20keystore=EF=BC=8C=E6=94=B9=E7=94=A8=20GitHub=20Secr?= =?UTF-8?q?ets=20=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - android/key.properties: 仓库级固定签名改由 Secrets 提供 - android/duanju_release.keystore: 公开 keystore 风险高,移除 - configure_signing.py 已优先用 secrets(任一 secrets 设置即接管) --- android/duanju_release.keystore | Bin 2640 -> 0 bytes android/key.properties | 4 ---- 2 files changed, 4 deletions(-) delete mode 100644 android/duanju_release.keystore delete mode 100644 android/key.properties diff --git a/android/duanju_release.keystore b/android/duanju_release.keystore deleted file mode 100644 index ccbe6905e4d2ad5027100b6c50157abc9e4284b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2640 zcmai$S2P@o7KW!YdYDm0=V0`iXi=iOM(1b|y?2M`CBj6B7C|^VAvy=q!qIz6f*{6( zFcOJgl4zN5bJo2pc{~q$?e+iP!+!hL#^9;+K|nADPX&U(MG|lcr?fx{U@0E{8G?r& zVDRuA3?8EX3&Be95UC%PBnSxjvCzK=5M%i>Kxr|$7~x-$2EzfNWLa2X);+tN0D&k0 z7#bMm|E_`HPymJ#26srn0bRimpa_Iv&zx7D0kL?x)njc$@{tjb!Grrhwh0c06O(wU z&SOWNmQw+>GbO@1H>hbv`wj+pwsNx_(q#;Fzm$DDJn#FhOom00LI><#>=`P!E?N6k z){kV#9LXL`Ts40NcF7innQ&z#Pr*%NET___k%)kFmqs(1uWx9r`vwvmXbbMf3w#l> zS7oR^^3itB5h**6f~#s*CW+{6+)ZsQz z{MNDlNoH%}xz8s`t~<4)ntdDDujCjkdDWndGp){luf%t?Q7aiSpNQbn;r!Gdn%qLxn} zhSf!QEZgQem}buCY#vF!IHq{QlmC8Sb=7q)L|8(2b9DHz)BT{Dno9KUv)Zv^sdVfJZ67oD@&(fnk`*XTzM)NXk{yN1)(5Lzc#@>^zsL%7$aF_@F{=S z_;(Z2Fb3KDUY1#O_U*pS;U_e{i%Ei|5n{!RNa@mN_ilYi_f@+X0OT%Eu*dDCs|-!W zeSpP}e_&17oDC1Dw@;V@3uNH@q4}Jc zHs^NSfPQe&R##Zw&|W`x_+g(`HLxRPl@Vjgo?*@L;YqKD5yTdqDwstR?bD~nzVkhN zGl8MA6wU4C79BTr;W9oOVfKoJh7qLU~ z)ECc_iYR*q`5q=PM>`vAfb!6`n5JY zult*Ws&f0BHt%KG)7q$#%ty%ZfkTw)sWfdEDiTn_HS6Yznznv@zUjNP zS>1_936A-z9_-*g+iZ68rYiF06}ZU4;D*-7SFaXafbmZ0=^{4{T7 zCd$aZT$c~kRX(y4e?juVMq)ou1b27`>H#knMo(T)P&Qf?Upk z6W5o|d?YI4%KIk?k~)HtWJvxFcIbGaso`0J4zm?j+pA6^#2wL(+RW^DISeY8I3MOn z998m~6EtDTE#(;J7`!>{g45e}ea%^v?krSuux5wyJOJBf0q5MZ-OnRV-U`ry7%#30 zS+!nLaATNyS+5uz;@l15>=%{JR_tO!%E=QEyPA8wV57$Dbx7T93fD5@=E5G@1b{Yo z=pFJ*boTD9aW^lbeFdQ~$Rs|o5mZsrTllz~#}sV<3gIjU^?eI*m9?U=_`vxPbaG(H z>o^rIoyScLku9DnXXKZMna*6o+Uhnk1LPxB?(tnr%}m}0Gh45yuhg5cdYB8H{yns( z5q#92? zlw{2J+zR_kAlQ?OVHpBzX%0)tY+^A;mzxzu(r2r;@c|j z+^I8>`(qHks_e{MVxC%M*NSj{mvVcIkJg8|VRVS^+CZOpLEopL$1GfDT-4S=gRwW! zLqz(%aihm`ycVCY8y~Hsjz7w#Z7!=W_wHCwFmf(Qxt-!d8k7zb5M-^D$|~JST6q_W z0Jui3i(X9hVT|LVYY>6V?e=1ceiZ?%2 zGTbMWcz3cabzB@V4;P(A*XVQ^sLMBArOUBs}yWL zE0jKgMs%7Ab`W#Dn3gs&M!Suj9qIe!0yoAUe80m3Kgl$UkVJRwHz@~~#Kcbop^KK7 z{u3IJyNbRH7j&cZ;B37iyEf>MA+~#*euF%cB36CRe)r9xu0P#w{D+;waC4tGkq3km z)1jVZxBH^>*C)$srWeT)iBf+qoDO}`ge5w+*#xbig04{r1P~e$!LelGU<}n$Ak%E} z?%p=xKB~yco3dCWgAmf~N&09It5ZQfClAdfCf+nr%aWAWdhncgr8@K!TBgbjS^9}YBE`VTwBft;f1qjCQ|AI&?GX#O?G#o4As_J9@ zGcR4?iLe93($o<~4THtd{`|#2Kqvshqv<-;*@i&*YqM_1@i?XGut}d}pE{M42L7hI W$n#>01fe9Rz0dhF#y9$phyM%fN3=-* diff --git a/android/key.properties b/android/key.properties deleted file mode 100644 index 81f3010..0000000 --- a/android/key.properties +++ /dev/null @@ -1,4 +0,0 @@ -storeFile=duanju_release.keystore -storePassword=duanjuapp_release_password_2026 -keyAlias=duanju -keyPassword=duanjuapp_release_password_2026 From 62fc0caea373ec7bc34ed473ff13c73b0fb94700 Mon Sep 17 00:00:00 2001 From: AYUKI Date: Wed, 7 Oct 2026 11:13:54 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(hongguo):=20v11=20=E9=99=8D=E6=9D=83?= =?UTF-8?q?=20bytevc=20+=20=E9=99=8D=20UA=20+=20=E9=85=8D=E5=A5=97?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: 红果服务端对最新 UA 全吐 bytevc1/bytevc2;web /player 前 3 集后强制 SSR 404。 诊断文档: /tmp/guoapp_v2/BYTEVC_DIAGNOSIS.md (双剧 8 集实测 + yt-dlp #9575 参考) D1: selectHongguoAppMedia 仿 yt-dlp preference=-100 降权 bytevc - 不再 continue 让 bytevc 完全消失 - bytevc Quality=0 + score -= 100000 → 永远垫底 - 当 App 服务端只返 bytevc 时,保留 last-resort variant 让 ExoPlayer 尝试 - TestH1 / TestH4 / TestH5 全 PASS (test 期望更新加 bytevc URL) D2: UA 降版本 (yt-dlp 备选方案) - 旧 UA: com.phoenix.read/73532 (Android 16 最新) - 新 UA: com.phoenix.read/70000 (Android 12 老版本) - 目标: 让服务端可能返回 h264 等老 codec - 风险: 服务端可能直接 403;失败则回滚 73532 测试修复 (D1 配套): - app_stream_range_test.go: zz14Reader → rangeProbeReader (refs PR#2 TestNativeUpstreamIgnoringRangeStillHonoursOffsets) - app_stream_test.go: newHongguoRefreshTestDownloader() 加 limiter 防空指针 - app_test.go: selectHongguoAppMedia 改 3 参数调用 - app_playback_routes_test.go: TestHongguoBackupAddressesAndCodecAlternatives 加 bytevc URL 到 want - provider_sources_parity_test.go: selectHongguoAppMedia 改 3 参数调用 catalog 持久化 3 FAIL (TestNativeHongguoCursorRestartsPerCatalog/RecoversSessionsAfterRestart/StalledCursorKeepsPartialItems) 不在本 commit scope,是独立 session/cursor bug。 --- native/core/app_playback_routes_test.go | 4 +++- native/core/app_stream_range_test.go | 2 +- native/core/app_stream_test.go | 21 ++++++++++++++++---- native/core/app_test.go | 4 ++-- native/core/provider_hongguo_app.go | 9 ++++++++- native/core/provider_hongguo_native_media.go | 18 +++++++++++++---- native/core/provider_sources_parity_test.go | 2 +- 7 files changed, 46 insertions(+), 14 deletions(-) diff --git a/native/core/app_playback_routes_test.go b/native/core/app_playback_routes_test.go index b1ebf91..1f89895 100644 --- a/native/core/app_playback_routes_test.go +++ b/native/core/app_playback_routes_test.go @@ -22,7 +22,7 @@ func TestHongguoBackupAddressesAndCodecAlternatives(t *testing.T) { }, map[string]any{"main_url": "https://media.test/low.mp4", "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}}, map[string]any{"main_url": "https://media.test/unsupported.mp4", "backup_url": "https://backup.test/unsupported.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "2160p"}}, - }}) + }}, "", "") if err != nil { t.Fatal(err) } @@ -34,6 +34,8 @@ func TestHongguoBackupAddressesAndCodecAlternatives(t *testing.T) { want := []string{ "https://media.test/main.mp4?token=a%2Fb", "https://backup.test/main.mp4?token=unchanged", "https://third.test/main.mp4", "https://media.test/hevc.mp4", "https://media.test/low.mp4", + // v11: bytevc 降权为 last-resort,不再 ban;保留到 Variants 末尾 + "https://media.test/unsupported.mp4", "https://backup.test/unsupported.mp4", } if !reflect.DeepEqual(addresses, want) || !reflect.DeepEqual(choices.qualities, []int{1080, 720}) { t.Fatalf("wrong fallback order: %+v; qualities=%v", addresses, choices.qualities) diff --git a/native/core/app_stream_range_test.go b/native/core/app_stream_range_test.go index 19dff53..7d75131 100644 --- a/native/core/app_stream_range_test.go +++ b/native/core/app_stream_range_test.go @@ -48,7 +48,7 @@ func TestNativeUpstreamIgnoringRangeStillHonoursOffsets(t *testing.T) { w.Header().Set("Content-Length", strconv.Itoa(total)) w.WriteHeader(http.StatusOK) if r.Method != http.MethodHead { - _, _ = io.Copy(w, &zz14Reader{total: total}) + _, _ = io.Copy(w, &rangeProbeReader{total: total}) } })) defer upstream.Close() diff --git a/native/core/app_stream_test.go b/native/core/app_stream_test.go index 1867ab8..9e605f1 100644 --- a/native/core/app_stream_test.go +++ b/native/core/app_stream_test.go @@ -3,17 +3,30 @@ package core import ( "context" "errors" + "net/http" "strings" "testing" + "time" ) +// newHongguoRefreshTestDownloader 造一个带真实限流器的 Downloader。 +// 生产构造函数在 app_runtime.go 里总会设置 limiter,测试必须对齐, +// 否则 refresh 路径会在 requestLimiter.acquire 上空指针崩溃。 +func newHongguoRefreshTestDownloader() *Downloader { + return &Downloader{ + cfg: Config{}, + limiter: newRequestLimiter(3, 250*time.Millisecond), + } +} + // TestRefreshHongguoMediaURLReturnsErrorOnEmptyID 边界条件 func TestRefreshHongguoMediaURLReturnsErrorOnEmptyID(t *testing.T) { - d := &Downloader{cfg: &Config{}} + d := newHongguoRefreshTestDownloader() if _, err := d.refreshHongguoMediaURL(context.Background(), "", ""); err == nil { t.Fatal("expected error for empty seriesID/videoID, got nil") } - if !strings.Contains(d.refreshHongguoMediaURL(context.Background(), "123", "").Error(), "剧集 ID") { + _, err := d.refreshHongguoMediaURL(context.Background(), "123", "") + if err == nil || !strings.Contains(err.Error(), "剧集 ID") { t.Fatal("error message should mention 剧集 ID") } } @@ -21,7 +34,7 @@ func TestRefreshHongguoMediaURLReturnsErrorOnEmptyID(t *testing.T) { // TestRefreshHongguoMediaURLPropagatesPlaybackError refresh 路径走 resolveHongguoPlaybackAPI, // 空 ctx 应传播错误 func TestRefreshHongguoMediaURLPropagatesPlaybackError(t *testing.T) { - d := &Downloader{cfg: &Config{}} + d := newHongguoRefreshTestDownloader() d.cfg.HongguoURL = "http://127.0.0.1:1/" // unreachable d.client = &http.Client{Transport: errTransport{err: errors.New("playback api down")}} _, err := d.refreshHongguoMediaURL(context.Background(), "123", "456") @@ -33,4 +46,4 @@ func TestRefreshHongguoMediaURLPropagatesPlaybackError(t *testing.T) { // errTransport 总是返回 err 的 transport(用于验证错误传播) type errTransport struct{ err error } -func (t errTransport) RoundTrip(r *http.Request) (*http.Response, error) { return nil, t.err } \ No newline at end of file +func (t errTransport) RoundTrip(r *http.Request) (*http.Response, error) { return nil, t.err } diff --git a/native/core/app_test.go b/native/core/app_test.go index fa4431a..1b755f7 100644 --- a/native/core/app_test.go +++ b/native/core/app_test.go @@ -165,7 +165,7 @@ func TestHongguoDetailAndPlayableQuality(t *testing.T) { map[string]any{"main_url": "https://media.example.test/new.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "2160p"}}, map[string]any{"main_url": "https://media.example.test/low.mp4", "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}}, map[string]any{"main_url": "https://media.example.test/high.mp4", "video_meta": map[string]any{"codec_type": "hevc", "definition": "1080p"}}, - }}) + }}, "", "") if err != nil || media.Quality != 1080 || !strings.HasSuffix(media.URL, "high.mp4") { t.Fatalf("wrong playable quality: %v", err) } @@ -179,7 +179,7 @@ func TestHongguoCodecFilterPreservesHevenWhenGearKeyMentionsBytevc2(t *testing.T map[string]any{"main_url": "https://media.example.test/mid.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "540p"}}, map[string]any{"main_url": "https://media.example.test/hd.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "720p"}}, map[string]any{"main_url": "https://media.example.test/hevc.mp4", "video_meta": map[string]any{"codec_type": "h265_hvc1", "definition": "1080p"}, "gear_des_key": "0:MP4|1:encrypt|2:bytevc2|4:1080p|5:normal|6:only_roi_vcube_improve_1|10000:105"}, - }}) + }}, "", "") if err != nil { t.Fatalf("should keep h265_hvc1 even when gear_des_key mentions bytevc2: %v", err) } diff --git a/native/core/provider_hongguo_app.go b/native/core/provider_hongguo_app.go index 5a28515..61f75d9 100644 --- a/native/core/provider_hongguo_app.go +++ b/native/core/provider_hongguo_app.go @@ -17,7 +17,14 @@ import ( const hongguoAppBaseURL = "https://api5-normal-sinfonlineb.fqnovel.com" -const hongguoAppUserAgent = "com.phoenix.read/73532 (Linux; U; Android 16; zh_CN; 25053RT47C; Build/BP2A.250605.031.A3; Cronet/TTNetVersion:04657795 2026-01-23 QuicVersion:c67e9834 2025-09-08)" +// v11 fix (2026-10-07): UA 降版本(yt-dlp 警告的备选方案) +// 旧 UA: com.phoenix.read/73532 + Android 16 (最新) +// 新 UA: com.phoenix.read/70000 + Android 12 (老版本) +// 原因: 红果服务端疑似对最新 UA 全吐 bytevc1/bytevc2(付费墙策略) +// 老 UA 可能让服务端返回 h264 等可解码编码 +// 风险: 需实测服务端是否吐 h264;如果服务端鉴权老 UA,可能直接 403 +// 诊断: /tmp/guoapp_v2/BYTEVC_DIAGNOSIS.md (web 前3集后4集起SSR 404 + 全剧 bytevc) +const hongguoAppUserAgent = "com.phoenix.read/70000 (Linux; U; Android 12; zh_CN; PFZM10; Build/SP1A.210812.016; Cronet/TTNetVersion:04270129 2024-01-15 QuicVersion:5e92d3a0 2023-08-23)" type hongguoCatalogCursor struct { Offset int `json:"offset"` diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index c1be092..3371edf 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -62,9 +62,12 @@ func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (prov // v8 fix: 只过滤 codec_type=="bytevc2",不要再用 gear_des_key 含 bytevc2 判断 // 否则会误伤像 1080p h265_hvc1 这种 variant (gear_des_key 链里含 bytevc2 字面量) // v10 fix: bytevc1 也过滤 (MediaCodec 都不解),作为 defense-in-depth 防 reorder 回归 - if codec == "bytevc2" || codec == "bytevc1" { - continue - } + // v11 fix (2026-10-07): 仿 yt-dlp #9575 — bytevc 不直接 ban,降权到 -10000 + // 原因:fork 部署在 Intel Mac 上无安卓 codec,但 fork 也跑在用户手机/树莓派等 Android 端 + // 用户设备上的 MediaCodec 也许能解(部分版本 bytevc1 = h265 重编码),降权保留最后尝试 + // h264 score = height*10 + 1 (max ~1921) → bytevc score -= 10000 保证永远垫底 + // 关键诊断文件:/tmp/guoapp_v2/BYTEVC_DIAGNOSIS.md (双剧8集实测,web 前3集后4集起SSR 404) + isBytevc := codec == "bytevc2" || codec == "bytevc1" addresses := hongguoMediaAddresses(variant) if len(addresses) == 0 { continue @@ -94,6 +97,13 @@ func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (prov if codec == "h264" || codec == "avc1" { quality++ } + // v11 fix: bytevc 降权(h264 最高 ~43201 8K, bytevc 仅作 last-resort) + // Quality=0 让 nativePlaybackChoices 重新按 Quality 排序时 bytevc 也排最后 + // score 也降权 -100000 让 selectHongguoAppMedia 内部选择 score 最高时也排最后 + if isBytevc { + media.Quality = 0 + quality -= 100000 + } for _, address := range addresses { media.URL = address choices = append(choices, scoredMedia{media: media, score: quality}) @@ -110,7 +120,7 @@ func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (prov if keyErr != nil { return providerMedia{}, fmt.Errorf("红果 App 媒体密钥不可用: %w", keyErr) } - return providerMedia{}, errors.New("红果 App 未返回兼容的媒体,已跳过不支持的编码") + return providerMedia{}, errors.New("红果 App 仅返回 bytevc1/bytevc2 (fork 不解码),请等待服务端恢复或装正版 App") } func hongguoMediaAddresses(info map[string]any) []string { diff --git a/native/core/provider_sources_parity_test.go b/native/core/provider_sources_parity_test.go index 74c1a60..18a17da 100644 --- a/native/core/provider_sources_parity_test.go +++ b/native/core/provider_sources_parity_test.go @@ -290,7 +290,7 @@ func TestProviderMediaCredentialsLimitOriginAndUseBackupAddresses(t *testing.T) "main_url": "invalid", "backup_url": base64.StdEncoding.EncodeToString([]byte(primary)), "backup_urls": []any{backup}, "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}, }}} - media, err := selectHongguoAppMedia(model) + media, err := selectHongguoAppMedia(model, "", "") if err != nil || media.URL != primary || len(media.Variants) != 2 || media.Variants[1].URL != backup || media.Duration != 6*time.Second { t.Fatalf("hongguo backup media fields were lost: %+v %v", media, err) }