#### 直播回放显示表情
配置文件中添加配置项`弹幕表情`(>v0.14.9)。默认为true,当为true时,将会保存弹幕中的表情png到emots目录下,并在回放时显示表情。
-为了能顺利保存,会将某些字符进行转换(<=v0.14.18),如:[dog?]=>[dog?].png。
-
-但之后(>v0.14.18)存储通过md5作为文件名,如:[dog?]=>md5("[dog?]")+".png"=>0c8427bdb9854d85e9cfe59c45d70583.png
+为了能顺利保存,存储通过md5作为文件名,如:[dog?]=>md5("[dog?]")+".png"=>0c8427bdb9854d85e9cfe59c45d70583.png
从而避免对原表情产生修改,且避免不同环境对文件名支持的差异。
保存的表情可以通过`http://{Web服务地址}{直播Web服务路径}emots/{md5}.png`获取。
+在(>v0.16.6)中,在录制结束时,本场录制出现的表情将会从emots目录复制到录制文件夹下的`emotes.zip`中。
+当请求`http://{Web服务地址}{直播Web服务路径}emots/{md5}.png`时,如请求头`Referer`中的`ref`url参数为有效的录播文件夹名,并且该文件夹下有`emotes.zip`。
+则在回放中使用`emotes.zip`中的表情,这确保了录播文件夹迁移到其他环境时,即使emots目录没有对应表情,也可以正常回放表情。
+
#### 直播流停用服务器
配置文件中添加配置项`直播流停用服务器`(>v0.14.3)。默认为空,编写正则字符串,当获取到的服务器链接与字符串匹配时,将会停用。
"errors"
"fmt"
"io"
+ "io/fs"
"net"
"net/http"
"net/http/pprof"
return
}
- f := file.New("emots/"+strings.TrimPrefix(r.URL.Path, spath+"emots/"), 0, true).CheckRoot("emots/")
- if !f.IsExist() {
- w.WriteHeader(http.StatusNotFound)
- return
+ ref := ""
+ if u, e := url.Parse(r.Header.Get("Referer")); e == nil {
+ ref = u.Query().Get("ref")
+ if ref != "now" {
+ if s, ok := c.C.K_v.LoadV(`直播流保存位置`).(string); ok && s != "" {
+ if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") {
+ ref = s + ref + "/"
+ } else {
+ ref = s + "/" + ref + "/"
+ }
+ }
+ } else {
+ ref = ""
+ }
}
+ emoteDir := replyFunc.DanmuEmotes.GetEmotesDir(ref)
- // mod
- if info, e := f.Stat(); e == nil && pweb.NotModified(r, w, info.ModTime()) {
- return
+ if f, e := emoteDir.Open(strings.TrimPrefix(r.URL.Path, spath+"emots/")); e != nil {
+ if errors.Is(e, fs.ErrNotExist) {
+ w.WriteHeader(http.StatusNotFound)
+ } else {
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ } else if info, e := f.Stat(); e != nil {
+ w.WriteHeader(http.StatusNotFound)
+ } else if !pweb.NotModified(r, w, info.ModTime()) {
+ _, _ = io.Copy(w, f)
}
-
- b, _ := f.ReadAll(humanize.KByte, humanize.MByte)
- _, _ = w.Write(b)
})
// 直播流播放器
// Ass
replyFunc.Ass.ToAss(filePath)
+ // emots
+ if e := replyFunc.DanmuEmotes.PackEmotes(filePath); e != nil {
+ msglog.L(`E: `, e)
+ }
+
Recoder.Stop()
}
import (
"context"
+ "io/fs"
"iter"
"net/http"
"time"
Hashr(s string) (r string)
SetLayerN(n int)
IsErrNoEmote(e error) bool
+ PackEmotes(dir string) error
+ GetEmotesDir(dir string) fs.FS
}](`danmuEmotes`)
package danmuemotes
import (
+ "archive/zip"
+ "bytes"
"context"
"encoding/json"
"errors"
+ "io"
+ "io/fs"
+ "iter"
+ "regexp"
+ "strconv"
"strings"
+ "github.com/dustin/go-humanize"
c "github.com/qydysky/bili_danmu/CV"
comp "github.com/qydysky/part/component2"
file "github.com/qydysky/part/file"
Hashr(s string) (r string)
SetLayerN(n int)
IsErrNoEmote(e error) bool
+ PackEmotes(dir string) error
+ GetEmotesDir(dir string) fs.FS
}
func init() {
}
return string(rr)
}
+
+func (t *danmuEmotes) PackEmotes(dir string) error {
+ var (
+ w *zip.Writer
+ set = make(map[string]struct{})
+ r, _ = regexp.Compile(`\[.*?\]`)
+ )
+
+ for line := range loadCsv(dir, "0.csv") {
+ for _, v := range r.FindAllString(line.Text, -1) {
+ key := t.Hashr(v)
+ if _, ok := set[key]; ok {
+ continue
+ } else {
+ set[key] = struct{}{}
+ }
+
+ f := file.New(t.Dir+key+".png", 0, false)
+ if f.IsExist() {
+ if w == nil {
+ f := file.Open(dir + "emotes.zip")
+ if f.IsExist() {
+ _ = f.Delete()
+ }
+ w = zip.NewWriter(f.File())
+ defer w.Close()
+ }
+ if iw, e := w.Create(key + ".png"); e != nil {
+ return e
+ } else if _, e := io.Copy(iw, f); e != nil {
+ return e
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func (t *danmuEmotes) GetEmotesDir(dir string) fs.FS {
+ if dir != "" && file.IsExist(dir+"emotes.zip") {
+ if rc, e := zip.OpenReader(dir + "emotes.zip"); e == nil {
+ return rc
+ }
+ }
+ return file.DirFS(t.Dir)
+}
+
+func loadCsv(savePath string, filename ...string) iter.Seq[Data] {
+ return func(yield func(Data) bool) {
+ csvf := file.New(savePath+append(filename, "0.csv")[0], 0, false)
+ defer csvf.Close()
+
+ if !csvf.IsExist() {
+ return
+ }
+
+ var data = Data{}
+ for i := 0; true; i += 1 {
+ if line, e := csvf.ReadUntil([]byte{'\n'}, humanize.KByte, humanize.MByte); len(line) != 0 {
+ lined := bytes.SplitN(line, []byte{','}, 3)
+ if len(lined) == 3 {
+ if t, e := strconv.ParseFloat(string(lined[0]), 64); e == nil {
+ if e := json.Unmarshal(lined[2], &data); e == nil {
+ data.Time = t
+ if data.Style.Color == "" {
+ data.Style.Color = "#FFFFFF"
+ }
+ if !yield(data) {
+ return
+ }
+ }
+ }
+ }
+ } else if e != nil {
+ break
+ }
+ }
+ }
+}
+
+type DataStyle struct {
+ Color string `json:"color"`
+ Border bool `json:"border"`
+ Mode int `json:"mode"`
+}
+
+type Data struct {
+ Text string `json:"text"`
+ Style DataStyle `json:"style"`
+ Time float64 `json:"time"`
+}
require (
github.com/gotk3/gotk3 v0.6.4
github.com/mdp/qrterminal/v3 v3.2.0
- github.com/qydysky/part v0.28.20250330170611
+ github.com/qydysky/part v0.28.20250406180726
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
golang.org/x/text v0.23.0 // indirect
github.com/qydysky/biliApi v0.0.0-20250406112014-bf8c070170f6/go.mod h1:1FbgCj+aOwIvuRRuX/l5uTLb3JIwWyJSa0uEfwpYV/8=
github.com/qydysky/brotli v0.0.0-20240828134800-e9913a6e7ed9 h1:k451T+bpsLr+Dq9Ujo+Qtx0iomRA1XXS5ttlEojvfuQ=
github.com/qydysky/brotli v0.0.0-20240828134800-e9913a6e7ed9/go.mod h1:cI8/gy/wjy2Eb+p2IUj2ZuDnC8R5Vrx3O0VMPvMvphA=
-github.com/qydysky/part v0.28.20250330170611 h1:8ll4oVALYXi0wFce12r8BkYRdlw8U50VZs7FI6AZTog=
-github.com/qydysky/part v0.28.20250330170611/go.mod h1:RHYTy8EbqCP6OioVf6BkvFcfWLNO0S220zl0DDlY84Y=
+github.com/qydysky/part v0.28.20250406180726 h1:uCHzGPpH3USZR7tilD5H0h07DjZNMF4XU2K+U+Q7TIc=
+github.com/qydysky/part v0.28.20250406180726/go.mod h1:RHYTy8EbqCP6OioVf6BkvFcfWLNO0S220zl0DDlY84Y=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=