Files
djk/internal/app/ui_playback_danmaku.go

206 lines
7.6 KiB
Go

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
}