package app import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strconv" "strings" "sync" "sync/atomic" "testing" "time" ) const danmakuTestSeries = "7615465407347952664" const danmakuTestVideo = "7615470191303986238" func danmakuTestRow(video, id, text string, offset any) map[string]any { return map[string]any{"comment": map[string]any{ "comment_id": id, "common": map[string]any{"group_id": video, "status": 1, "content": map[string]any{"text": text}}, "expand": map[string]any{"offset_time": offset}, }} } func danmakuTestResult(next int, rows []any) map[string]any { return map[string]any{"code": 0, "data": map[string]any{ "data_list": rows, "extra": map[string]any{"next_query_danmaku_list_time": next}, "common_list_info": map[string]any{"cursor": `{"danmaku_count":250}`, "has_more": false, "total": len(rows)}, }} } func danmakuTestJSON(next int, rows ...any) string { if rows == nil { rows = []any{} } raw, _ := json.Marshal(danmakuTestResult(next, rows)) return string(raw) } func TestHongguoDanmakuPreservesSourceTimeline(t *testing.T) { row := func(id string, offset any) map[string]any { return danmakuTestRow(danmakuTestVideo, id, "文字", offset) } rows := []any{row("later", 15000), danmakuTestRow(danmakuTestVideo, "zero", "文字\n后一行", 0), row("zero", 1), row("missing", nil), row("negative", -1), row("past-end", 160000), row("decimal", 1.2), danmakuTestRow("999999999", "wrong-episode", "串集", 0), nil, row("hidden", 5)} nestedMap(rows[len(rows)-1].(map[string]any), "comment", "common")["status"] = 2 page, err := parseHongguoDanmaku(danmakuTestResult(30000, rows), danmakuTestVideo, 0, 160000) if err != nil || len(page.Items) != 2 || page.Items[0].ID != "zero" || page.Items[0].TimeMS != 0 || page.Items[1].TimeMS != 15000 { t.Fatalf("source timeline or episode identity lost: %+v %v", page, err) } if page.Items[0].Text != "文字 后一行" || page.Total != 250 || page.NextMS != 30000 { t.Fatalf("text or source count was changed: %+v", page) } empty, err := parseHongguoDanmaku(danmakuTestResult(60000, []any{}), danmakuTestVideo, 30000, 160000) if err != nil || empty.Items == nil || len(empty.Items) != 0 || empty.NextMS != 60000 || empty.Total != 250 { t.Fatalf("empty window lost continuation: %+v %v", empty, err) } last, err := parseHongguoDanmaku(danmakuTestResult(180000, []any{}), danmakuTestVideo, 150000, 160000) if err != nil || last.NextMS != 160000 { t.Fatal("last window must end at episode duration") } } func TestHongguoDanmakuRejectsMalformedWindowsAndBoundsText(t *testing.T) { for _, result := range []map[string]any{nil, {"data": map[string]any{"data_list": []any{}}}, danmakuTestResult(0, []any{}), danmakuTestResult(danmakuMaxDurationMS+1, []any{})} { if _, err := parseHongguoDanmaku(result, danmakuTestVideo, 0, 160000); err == nil { t.Fatal("malformed response became a successful empty page") } } rows := []any{} for i := 0; i < 110; i++ { rows = append(rows, danmakuTestRow(danmakuTestVideo, strconv.Itoa(i), strings.Repeat("长", 500), i)) } page, err := parseHongguoDanmaku(danmakuTestResult(30000, rows), danmakuTestVideo, 0, 160000) if err != nil || len(page.Items) != 90 || len([]rune(page.Items[0].Text)) != 181 { t.Fatal("unbounded danmaku") } } func TestHongguoDanmakuCacheAndConcurrentRequests(t *testing.T) { var calls atomic.Int32 started, release := make(chan struct{}), make(chan struct{}) d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { count := calls.Add(1) if request.URL.Path == "/novel/player/video_detail/v1/" { if request.Header.Get("X-Argus") != "" || request.Header.Get("X-Ladon") != "" || request.Header.Get("Comment-Source") != "" { t.Error("comment signatures leaked into the regular player request") } return rankingHTTPResponse(request, 200, `{"code":0}`), nil } if request.URL.Path != "/novel/commentapi/comment/list/"+danmakuTestVideo+"/v1/" || request.Method != http.MethodPost { t.Error("wrong danmaku path") } if request.Context().Value(backgroundCatalogKey{}) != true || request.Header.Get("Comment-Source") != "601" || request.Header.Get("Server-Channel") != "1000" || request.Header.Get("Cookie") != "" { t.Error("danmaku must be anonymous and low priority") } for _, name := range []string{"X-Argus", "X-Ladon", "X-Gorgon", "X-Khronos"} { if request.Header.Get(name) == "" { t.Errorf("missing comment signature: %s", name) } } var body struct { GroupID string `json:"group_id"` Type int `json:"comment_type"` Business struct { BookID string `json:"book_id"` Start int `json:"start_offset_time"` Duration int `json:"playlet_item_duration"` } `json:"business_param"` } if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.GroupID != danmakuTestVideo || body.Type != 20 || body.Business.BookID != danmakuTestSeries || body.Business.Duration != 160000 { t.Error("wrong source episode request") } if count == 1 { close(started) <-release } return rankingHTTPResponse(request, 200, danmakuTestJSON(body.Business.Start+30000, danmakuTestRow(danmakuTestVideo, "first", "原弹幕", body.Business.Start))), nil }) results := make(chan error, 8) var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() page, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000) if err == nil && (len(page.Items) != 1 || page.Items[0].Text != "原弹幕") { err = errors.New("wrong shared page") } if len(page.Items) > 0 { page.Items[0].Text = "caller mutation" } results <- err }() } <-started canceled, cancel := context.WithCancel(context.Background()) cancel() if _, err := d.hongguoDanmaku(canceled, danmakuTestSeries, danmakuTestVideo, 0, 160000); !errors.Is(err, context.Canceled) { t.Error("a waiting reader could not cancel") } close(release) wg.Wait() close(results) for err := range results { if err != nil { t.Error(err) } } if calls.Load() != 1 { t.Fatal("concurrent identical windows were not coalesced") } page, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000) if err != nil || page.Items[0].Text != "原弹幕" || calls.Load() != 1 { t.Fatal("cache was not isolated from callers") } if _, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 30000, 160000); err != nil || calls.Load() != 2 { t.Fatal("next window reused the wrong cached page") } if _, err := d.hongguoAppRequest(context.Background(), http.MethodPost, "/novel/player/video_detail/v1/", nil, map[string]string{"series_id": danmakuTestSeries}); err != nil { t.Fatal(err) } } func TestHongguoDanmakuCanceledLoadCanBeRetried(t *testing.T) { var calls atomic.Int32 started := make(chan struct{}) d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { if calls.Add(1) == 1 { close(started) <-request.Context().Done() return nil, request.Context().Err() } return rankingHTTPResponse(request, 200, danmakuTestJSON(30000)), nil }) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := d.hongguoDanmaku(ctx, danmakuTestSeries, danmakuTestVideo, 0, 160000); done <- err }() <-started cancel() if err := <-done; !errors.Is(err, context.Canceled) { t.Fatal(err) } if _, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000); err != nil || calls.Load() != 2 { t.Fatal("canceled optional load poisoned the cache") } } func TestPlaybackDanmakuValidationAndStreamIsolation(t *testing.T) { started := make(chan struct{}) var calls, streamCanceled atomic.Int32 d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { calls.Add(1) close(started) <-request.Context().Done() return nil, request.Context().Err() }) task := Task{DramaID: "hongguo:" + danmakuTestSeries, Chapter: Chapter{Source: sourceHongguo, VideoURL: "hongguo-cenc://" + danmakuTestVideo}} timer := time.NewTimer(time.Hour) defer timer.Stop() session := &playbackSession{id: "current", tasks: []Task{task}, state: "streaming", run: 7, duration: 160, timer: timer, cancel: func() { streamCanceled.Add(1) }} app := &UIApp{cfg: d.cfg, downloader: d, playbacks: map[string]*playbackSession{"current": session}} session.viewer = fixtureViewer(app) query := "?session=current&episode=1&start=0&duration=160000" for _, tc := range []struct { query, method, origin string status int }{ {query, http.MethodPost, "", 405}, {query, http.MethodGet, "https://other.example", 403}, {strings.Replace(query, "episode=1", "episode=0", 1), http.MethodGet, "", 400}, {strings.Replace(query, "episode=1", "episode=2", 1), http.MethodGet, "", 400}, {strings.Replace(query, "start=0", "start=-1", 1), http.MethodGet, "", 400}, {strings.Replace(query, "duration=160000", "duration=9999999999", 1), http.MethodGet, "", 400}, {strings.Replace(query, "current", "expired", 1), http.MethodGet, "", 410}, } { request := httptest.NewRequest(tc.method, "http://localhost/api/ui/playback/danmaku"+tc.query, nil) request.Header.Set("Origin", tc.origin) writer := httptest.NewRecorder() app.handlePlaybackDanmaku(writer, viewerFixtureRequest(app, request)) if writer.Code != tc.status { t.Errorf("%s: got %d, want %d", tc.query, writer.Code, tc.status) } } for _, video := range []string{"https://example.invalid/video.mp4", "hongguo-cenc://invalid", "hongguo-cenc://123?url=https://example.invalid"} { bad := task bad.Chapter.VideoURL = video if _, _, ok := hongguoPlaybackIDs(bad); ok { t.Fatal("accepted an arbitrary source URL") } } bad := task bad.Chapter.Source = "other" app.playbacks["unsupported"] = &playbackSession{viewer: session.viewer, tasks: []Task{bad}} writer := httptest.NewRecorder() app.handlePlaybackDanmaku(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/danmaku"+strings.Replace(query, "current", "unsupported", 1), nil))) if writer.Code != 404 || calls.Load() != 0 { t.Fatal("invalid requests reached the source") } ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan struct{}) go func() { defer close(done) app.handlePlaybackDanmaku(httptest.NewRecorder(), viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/danmaku"+query, nil).WithContext(ctx))) }() <-started view, found := app.playbackStatus("current", false) if !found || view.State != "streaming" || view.Run != 7 || view.Duration != 160 || streamCanceled.Load() != 0 { t.Fatal("waiting for danmaku changed or blocked playback") } cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("optional request did not cancel promptly") } if streamCanceled.Load() != 0 || session.state != "streaming" || session.error != "" { t.Fatal("danmaku cancellation affected the stream") } } func TestHongguoDanmakuFailureBackoff(t *testing.T) { var calls atomic.Int32 d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { calls.Add(1) return nil, fmt.Errorf("optional service offline") }) for i := 0; i < 3; i++ { if _, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000); err == nil { t.Fatal("failure masked as empty success") } } if calls.Load() != 1 { t.Fatal("an unavailable optional service was hammered") } }