206 lines
7.6 KiB
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
|
|
}
|