package app import ( "context" "encoding/json" "errors" "fmt" "net/http" "sort" "strconv" "strings" "time" "unicode" ) const danmakuWindowMS = 30_000 const danmakuMaxDurationMS = 24 * 60 * 60 * 1000 type hongguoDanmakuItem struct { ID string `json:"id"` Text string `json:"text"` TimeMS int64 `json:"timeMs"` } type hongguoDanmakuPage struct { Items []hongguoDanmakuItem `json:"items"` StartMS int64 `json:"startMs"` NextMS int64 `json:"nextMs"` Total int64 `json:"total"` } type hongguoDanmakuCacheEntry struct { page hongguoDanmakuPage err error expires time.Time } type hongguoDanmakuCall struct { done chan struct{} entry hongguoDanmakuCacheEntry } func hongguoPlaybackIDs(task Task) (string, string, bool) { source, seriesID, valid := splitProviderDramaID(task.DramaID) if !valid || source != sourceHongguo || task.Chapter.Source != sourceHongguo || !strings.HasPrefix(task.Chapter.VideoURL, "hongguo-cenc://") { return "", "", false } videoID := strings.TrimPrefix(task.Chapter.VideoURL, "hongguo-cenc://") return seriesID, videoID, hongguoNumericID.MatchString(seriesID) && hongguoNumericID.MatchString(videoID) } func (app *UIApp) handlePlaybackDanmaku(writer http.ResponseWriter, request *http.Request) { if !playbackRequestAllowed(writer, request, http.MethodGet) { return } query := request.URL.Query() index, indexErr := strconv.Atoi(query.Get("episode")) start, startErr := strconv.ParseInt(query.Get("start"), 10, 64) duration, durationErr := strconv.ParseInt(query.Get("duration"), 10, 64) if indexErr != nil || index < 1 || startErr != nil || durationErr != nil || start < 0 || duration <= 0 || duration > danmakuMaxDurationMS || start >= duration { writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "弹幕分集或时间范围无效"}) return } app.playbackMu.Lock() session := app.playbacks[query.Get("session")] if !viewerOwnsPlayback(request.Context(), session) { app.playbackMu.Unlock() writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期"}) return } if index > len(session.tasks) { app.playbackMu.Unlock() writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "分集不存在"}) return } seriesID, videoID, supported := hongguoPlaybackIDs(session.tasks[index-1]) app.playbackMu.Unlock() if !supported { writeJSON(writer, http.StatusNotFound, map[string]string{"error": "本集暂不支持弹幕"}) return } ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second) defer cancel() page, err := app.downloader.hongguoDanmaku(ctx, seriesID, videoID, start, duration) if err != nil { writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "弹幕暂不可用,可稍后重新开启弹幕重试"}) return } writeJSON(writer, http.StatusOK, page) } func (d *Downloader) hongguoDanmaku(ctx context.Context, seriesID, videoID string, start, duration int64) (hongguoDanmakuPage, error) { if !hongguoNumericID.MatchString(seriesID) || !hongguoNumericID.MatchString(videoID) || start < 0 || duration <= start || duration > danmakuMaxDurationMS { return hongguoDanmakuPage{}, errors.New("弹幕请求参数无效") } key := fmt.Sprintf("%s:%s:%d:%d", seriesID, videoID, start, duration) client := d.hongguoClient() client.mu.Lock() if entry, found := client.danmaku[key]; found && time.Now().Before(entry.expires) { client.mu.Unlock() return cloneDanmakuPage(entry.page), entry.err } if pending := client.danmakuPending[key]; pending != nil { client.mu.Unlock() select { case <-ctx.Done(): return hongguoDanmakuPage{}, ctx.Err() case <-pending.done: if ctx.Err() == nil && (errors.Is(pending.entry.err, context.Canceled) || errors.Is(pending.entry.err, context.DeadlineExceeded)) { return d.hongguoDanmaku(ctx, seriesID, videoID, start, duration) } return cloneDanmakuPage(pending.entry.page), pending.entry.err } } if client.danmakuPending == nil { client.danmakuPending = map[string]*hongguoDanmakuCall{} } if len(client.danmakuPending) >= 16 { client.mu.Unlock() return hongguoDanmakuPage{}, errors.New("弹幕请求繁忙") } pending := &hongguoDanmakuCall{done: make(chan struct{})} client.danmakuPending[key] = pending client.mu.Unlock() ctx = context.WithValue(ctx, backgroundCatalogKey{}, true) ctx = context.WithValue(ctx, hongguoCommentKey{}, true) body := map[string]any{ "comment_source": 601, "server_channel": 1000, "group_id": videoID, "group_type": 30, "comment_type": 20, "sort": 1, "count": 90, "cursor": "", "aid": 8662, "compliance_status": 0, "business_param": map[string]any{"book_id": seriesID, "start_offset_time": start, "playlet_item_duration": duration, "need_danmaku_guide_type": []int{1, 3, 4, 2}}, } result, err := d.hongguoAppRequest(ctx, http.MethodPost, "/novel/commentapi/comment/list/"+videoID+"/v1/", nil, body) var page hongguoDanmakuPage if err == nil { page, err = parseHongguoDanmaku(result, videoID, start, duration) } entry := hongguoDanmakuCacheEntry{page: page, err: err, expires: time.Now().Add(5 * time.Minute)} if err != nil { entry.expires = time.Now().Add(15 * time.Second) } client.mu.Lock() if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { if client.danmaku == nil || len(client.danmaku) >= 128 { client.danmaku = map[string]hongguoDanmakuCacheEntry{} } client.danmaku[key] = entry } pending.entry = entry delete(client.danmakuPending, key) close(pending.done) client.mu.Unlock() return cloneDanmakuPage(page), err } func cloneDanmakuPage(page hongguoDanmakuPage) hongguoDanmakuPage { page.Items = append([]hongguoDanmakuItem{}, page.Items...) return page } func parseHongguoDanmaku(result map[string]any, videoID string, start, duration int64) (hongguoDanmakuPage, error) { data := nestedMap(result, "data") rows, valid := data["data_list"].([]any) next, err := strconv.ParseInt(mapString(nestedMap(data, "extra"), "next_query_danmaku_list_time"), 10, 64) if !valid || err != nil || next <= start || next > danmakuMaxDurationMS { return hongguoDanmakuPage{}, errors.New("红果弹幕时间段格式无效") } page := hongguoDanmakuPage{Items: []hongguoDanmakuItem{}, StartMS: start, NextMS: min(next, duration)} var cursor struct { Total int64 `json:"danmaku_count"` } if raw := mapString(nestedMap(data, "common_list_info"), "cursor"); len(raw) <= 8192 && json.Unmarshal([]byte(raw), &cursor) == nil && cursor.Total >= 0 { page.Total = cursor.Total } seen := map[string]bool{} for _, raw := range rows { row, _ := raw.(map[string]any) comment := nestedMap(row, "comment") common := nestedMap(comment, "common") if mapString(common, "group_id") != videoID || mapString(common, "status") != "1" { continue } position, err := strconv.ParseInt(mapString(nestedMap(comment, "expand"), "offset_time"), 10, 64) text := strings.TrimSpace(strings.Map(func(r rune) rune { if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { return ' ' } return r }, mapString(nestedMap(common, "content"), "text"))) id := mapString(comment, "comment_id") if err != nil || position < start || position >= page.NextMS || text == "" || id == "" || len(id) > 120 || seen[id] { continue } if runes := []rune(text); len(runes) > 180 { text = string(runes[:180]) + "…" } seen[id] = true page.Items = append(page.Items, hongguoDanmakuItem{ID: id, Text: text, TimeMS: position}) if len(page.Items) == 90 { break } } sort.SliceStable(page.Items, func(i, j int) bool { return page.Items[i].TimeMS < page.Items[j].TimeMS }) return page, nil }